Skip to main content

zeroclaw_config/
multi_agent.rs

1//! Multi-agent runtime types: alias newtypes, access-mode enum, peer
2//! external entries, and the nested config structs that wire into
3//! [`crate::schema::AliasedAgentConfig`] and [`crate::schema::Config`].
4//!
5//! Cross-agent semantics, peer-group resolution, and SubAgent permission
6//! inheritance live in the runtime crate; this module only carries the
7//! data shapes.
8
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12use zeroclaw_macros::Configurable;
13
14crate::define_provider_ref!(AgentAlias, "agents");
15crate::define_provider_ref!(PeerGroupName, "peer_groups");
16crate::define_provider_ref!(PeerUsername, "channels.peers");
17
18/// Cross-agent filesystem grant.
19///
20/// Used as the value type in `[agents.<alias>.workspace.access]` maps.
21/// A missing entry means no cross-agent access at all (jailed). The enum
22/// only encodes the granted modes; absence is the safe default.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AccessMode {
27    /// Read access only. Cross-agent `file_read` is permitted; writes are not.
28    Read,
29    /// Write access only. Cross-agent `file_write` is permitted; reads are not.
30    Write,
31    /// Both read and write. The agent can `file_read` and `file_write` against
32    /// the target's workspace.
33    ReadWrite,
34}
35
36/// Per-agent memory backend selector.
37///
38/// Closed set; the schema is law. The enum mirrors the storage-instance
39/// outer keys under `Config.storage.<kind>.<alias>`: `sqlite`, `postgres`,
40/// `qdrant`, `markdown`, `lucid`, plus `none` for the no-storage case.
41///
42/// An agent's backend is locked at agent creation and immutable on
43/// subsequent loads. `Config::validate()` enforces immutability against
44/// the persisted on-disk state.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
47#[serde(rename_all = "snake_case")]
48pub enum MemoryBackendKind {
49    /// No memory backend. Recall returns empty; stores are no-ops.
50    None,
51    /// Embedded SQLite (`crates/zeroclaw-memory/src/sqlite.rs`). Default for
52    /// new installs because every supported platform can run it without
53    /// extra services.
54    #[default]
55    Sqlite,
56    /// PostgreSQL with optional pgvector
57    /// (`crates/zeroclaw-memory/src/postgres.rs`, feature `memory-postgres`).
58    Postgres,
59    /// Qdrant vector store (`crates/zeroclaw-memory/src/qdrant.rs`).
60    Qdrant,
61    /// Markdown files in the agent's workspace
62    /// (`crates/zeroclaw-memory/src/markdown.rs`).
63    Markdown,
64    /// Hybrid local SQLite + external Lucid CLI
65    /// (`crates/zeroclaw-memory/src/lucid.rs`).
66    Lucid,
67}
68
69/// Per-agent filesystem and cross-agent access settings, nested under
70/// `[agents.<alias>.workspace]`.
71///
72/// `path = None` means derive the working directory from the install
73/// root and agent alias (`<install>/agents/<alias>/workspace/`); set
74/// `Some(path)` to put a specific agent's workspace on a different disk
75/// or filesystem. The `access` map is the inbound cross-agent filesystem
76/// allowlist (key = sibling agent alias, value = read/write/read+write
77/// grant); empty means jailed. `unrestricted_filesystem` is the escape
78/// hatch for agents that genuinely need to read or write outside any
79/// per-agent scope; off by default and audited.
80///
81/// `read_memory_from` is the cross-agent memory allowlist (parallel to
82/// `access` but for the memory layer). The schema validates entries
83/// for cross-reference and same-backend invariants at config load.
84#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
85#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
86#[prefix = "agent-workspace"]
87#[serde(default)]
88pub struct AgentWorkspaceConfig {
89    /// Optional explicit workspace path. `None` = derive from
90    /// `<install>/agents/<alias>/workspace/`.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub path: Option<PathBuf>,
93    /// Cross-agent filesystem allowlist (inbound declaration). Key is
94    /// the target sibling agent alias; value is the granted mode. Empty
95    /// map = jailed (own workspace only).
96    pub access: BTreeMap<AgentAlias, AccessMode>,
97    /// Escape hatch: when `true`, the agent can read or write anywhere
98    /// the host filesystem permits. Off by default; flipping this on is
99    /// auditable.
100    pub unrestricted_filesystem: bool,
101    /// Cross-agent memory allowlist (inbound declaration). Each alias
102    /// listed here is a sibling agent this agent may recall memory
103    /// rows from. Empty = own only.
104    pub read_memory_from: Vec<AgentAlias>,
105}
106
107/// Per-agent memory backend selection, nested under
108/// `[agents.<alias>.memory]`.
109///
110/// The `backend` field is locked at agent creation and immutable on
111/// subsequent loads (`Config::validate()` enforces this against the
112/// persisted on-disk state). Cross-backend memory sharing across the
113/// per-agent `read_memory_from` allowlist is rejected at validation:
114/// allowlist entries must point at same-backend siblings.
115#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
116#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
117#[prefix = "agent-memory"]
118#[serde(default)]
119pub struct AgentMemoryConfig {
120    /// The backend kind this agent uses. Defaults to `Sqlite` for new
121    /// agents; once an agent has on-disk data the value is locked.
122    pub backend: MemoryBackendKind,
123}
124
125/// `[peer_groups.<name>]` — mutual-opt-in peer group on a channel type.
126#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
127#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
128#[prefix = "peer-group"]
129#[serde(default)]
130pub struct PeerGroupConfig {
131    /// Either a channel type (`"telegram"`) or a dotted channel alias
132    /// (`"telegram.work"`). A bare type applies to every alias of that
133    /// type; a dotted form scopes the group to that single instance.
134    pub channel: String,
135    /// Member agents by alias.
136    pub agents: Vec<AgentAlias>,
137    /// Non-agent members by channel-native username.
138    pub external_peers: Vec<PeerUsername>,
139    /// Per-group blocklist; subtracts from the resolved peer set.
140    pub ignore: Vec<PeerUsername>,
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn agent_alias_round_trips_through_serde() {
149        // TOML's root must be a table; in real usage AgentAlias lives inside
150        // structs. Round-tripping through JSON exercises the same serde path
151        // as serialization inside a struct.
152        let alias = AgentAlias::new("researcher");
153        let json = serde_json::to_string(&alias).unwrap();
154        assert_eq!(json, "\"researcher\"");
155        let back: AgentAlias = serde_json::from_str(&json).unwrap();
156        assert_eq!(alias, back);
157    }
158
159    #[test]
160    fn access_mode_serializes_snake_case() {
161        let cases = [
162            (AccessMode::Read, "\"read\""),
163            (AccessMode::Write, "\"write\""),
164            (AccessMode::ReadWrite, "\"read_write\""),
165        ];
166        for (mode, expected) in cases {
167            let json = serde_json::to_string(&mode).unwrap();
168            assert_eq!(json, expected, "mode={mode:?}");
169            let back: AccessMode = serde_json::from_str(&json).unwrap();
170            assert_eq!(back, mode);
171        }
172    }
173
174    #[test]
175    fn external_peers_round_trip_as_inline_string_array() {
176        let toml_input = r#"
177external_peers = ["@user_1", "@user_2"]
178"#;
179        #[derive(Deserialize)]
180        struct Wrapper {
181            external_peers: Vec<PeerUsername>,
182        }
183        let parsed: Wrapper = toml::from_str(toml_input).unwrap();
184        assert_eq!(parsed.external_peers.len(), 2);
185        assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
186        assert_eq!(parsed.external_peers[1].as_str(), "@user_2");
187    }
188
189    #[test]
190    fn alias_newtypes_are_distinct_at_type_level() {
191        // Compile-time: AgentAlias and PeerGroupName don't accidentally
192        // assign to each other. The cast through `String` is the only path.
193        let agent = AgentAlias::new("alpha");
194        let group: PeerGroupName = PeerGroupName::new(agent.as_str());
195        assert_eq!(agent.as_str(), group.as_str());
196    }
197
198    #[test]
199    fn memory_backend_kind_serializes_snake_case() {
200        let cases = [
201            (MemoryBackendKind::None, "\"none\""),
202            (MemoryBackendKind::Sqlite, "\"sqlite\""),
203            (MemoryBackendKind::Postgres, "\"postgres\""),
204            (MemoryBackendKind::Qdrant, "\"qdrant\""),
205            (MemoryBackendKind::Markdown, "\"markdown\""),
206            (MemoryBackendKind::Lucid, "\"lucid\""),
207        ];
208        for (kind, expected) in cases {
209            let json = serde_json::to_string(&kind).unwrap();
210            assert_eq!(json, expected, "backend={kind:?}");
211            let back: MemoryBackendKind = serde_json::from_str(&json).unwrap();
212            assert_eq!(back, kind);
213        }
214    }
215
216    #[test]
217    fn memory_backend_kind_default_is_sqlite() {
218        assert_eq!(MemoryBackendKind::default(), MemoryBackendKind::Sqlite);
219    }
220
221    #[test]
222    fn agent_workspace_config_round_trips_with_access_map() {
223        let toml_input = r#"
224unrestricted_filesystem = false
225read_memory_from = ["beta"]
226
227[access]
228beta = "read"
229gamma = "read_write"
230"#;
231        let parsed: AgentWorkspaceConfig = toml::from_str(toml_input).unwrap();
232        assert_eq!(parsed.path, None);
233        assert!(!parsed.unrestricted_filesystem);
234        assert_eq!(parsed.read_memory_from.len(), 1);
235        assert_eq!(parsed.read_memory_from[0], "beta");
236        assert_eq!(parsed.access.len(), 2);
237        let beta = AgentAlias::new("beta");
238        let gamma = AgentAlias::new("gamma");
239        assert_eq!(parsed.access.get(&beta), Some(&AccessMode::Read));
240        assert_eq!(parsed.access.get(&gamma), Some(&AccessMode::ReadWrite));
241    }
242
243    #[test]
244    fn agent_workspace_config_default_is_jailed() {
245        let cfg = AgentWorkspaceConfig::default();
246        assert_eq!(cfg.path, None);
247        assert!(cfg.access.is_empty());
248        assert!(!cfg.unrestricted_filesystem);
249        assert!(cfg.read_memory_from.is_empty());
250    }
251
252    #[test]
253    fn agent_memory_config_round_trips() {
254        let toml_input = r#"backend = "postgres""#;
255        let parsed: AgentMemoryConfig = toml::from_str(toml_input).unwrap();
256        assert_eq!(parsed.backend, MemoryBackendKind::Postgres);
257    }
258
259    #[test]
260    fn agent_memory_config_default_is_sqlite() {
261        assert_eq!(
262            AgentMemoryConfig::default().backend,
263            MemoryBackendKind::Sqlite
264        );
265    }
266
267    #[test]
268    fn peer_group_config_round_trips_with_external_peers_and_ignore() {
269        let toml_input = r#"
270channel = "telegram.prod"
271agents = ["alpha", "beta"]
272external_peers = ["@user_1", "@user_2"]
273ignore = ["@known_spammer"]
274"#;
275        let parsed: PeerGroupConfig = toml::from_str(toml_input).unwrap();
276        assert_eq!(parsed.channel, "telegram.prod");
277        assert_eq!(parsed.agents.len(), 2);
278        assert_eq!(parsed.agents[0], "alpha");
279        assert_eq!(parsed.agents[1], "beta");
280        assert_eq!(parsed.external_peers.len(), 2);
281        assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
282        assert_eq!(parsed.ignore.len(), 1);
283        assert_eq!(parsed.ignore[0].as_str(), "@known_spammer");
284    }
285
286    #[test]
287    fn peer_group_config_default_is_empty() {
288        let cfg = PeerGroupConfig::default();
289        assert!(cfg.channel.is_empty());
290        assert!(cfg.agents.is_empty());
291        assert!(cfg.external_peers.is_empty());
292        assert!(cfg.ignore.is_empty());
293    }
294}