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/// Preferred output modality for a peer group.
126///
127/// Controls how the agent delivers replies to peers in this group when no
128/// stronger per-turn signal is present. `Mirror` (default) preserves the
129/// existing input-driven behaviour: voice in → voice out, text in → text out.
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
132#[serde(rename_all = "snake_case")]
133pub enum OutputModality {
134    /// Always reply in kind — voice note if user sent voice, text otherwise.
135    #[default]
136    Mirror,
137    /// Always deliver via TTS as a voice note, regardless of input modality.
138    /// Applies to proactive messages (cron, announces) as well as replies.
139    Voice,
140    /// Always deliver as text, even if user sent a voice note.
141    Text,
142}
143
144/// `[peer_groups.<name>]` — mutual-opt-in peer group on a channel type.
145#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
146#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
147#[prefix = "peer_group"]
148#[serde(default)]
149pub struct PeerGroupConfig {
150    /// Either a channel type (`"telegram"`) or a dotted channel alias
151    /// (`"telegram.work"`). A bare type applies to every alias of that
152    /// type; a dotted form scopes the group to that single instance.
153    pub channel: String,
154    /// Member agents by alias.
155    pub agents: Vec<AgentAlias>,
156    /// Non-agent members by channel-native username.
157    pub external_peers: Vec<PeerUsername>,
158    /// Per-group blocklist; subtracts from the resolved peer set.
159    pub ignore: Vec<PeerUsername>,
160    /// Preferred output modality for all peers in this group.
161    /// Defaults to `mirror` (input-driven). Set to `voice` to have the
162    /// agent always reply and deliver proactive messages (cron, announces)
163    /// as TTS voice notes on channels that support audio output.
164    pub output_modality: OutputModality,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn agent_alias_round_trips_through_serde() {
173        // TOML's root must be a table; in real usage AgentAlias lives inside
174        // structs. Round-tripping through JSON exercises the same serde path
175        // as serialization inside a struct.
176        let alias = AgentAlias::new("researcher");
177        let json = serde_json::to_string(&alias).unwrap();
178        assert_eq!(json, "\"researcher\"");
179        let back: AgentAlias = serde_json::from_str(&json).unwrap();
180        assert_eq!(alias, back);
181    }
182
183    #[test]
184    fn access_mode_serializes_snake_case() {
185        let cases = [
186            (AccessMode::Read, "\"read\""),
187            (AccessMode::Write, "\"write\""),
188            (AccessMode::ReadWrite, "\"read_write\""),
189        ];
190        for (mode, expected) in cases {
191            let json = serde_json::to_string(&mode).unwrap();
192            assert_eq!(json, expected, "mode={mode:?}");
193            let back: AccessMode = serde_json::from_str(&json).unwrap();
194            assert_eq!(back, mode);
195        }
196    }
197
198    #[test]
199    fn external_peers_round_trip_as_inline_string_array() {
200        let toml_input = r#"
201external_peers = ["@user_1", "@user_2"]
202"#;
203        #[derive(Deserialize)]
204        struct Wrapper {
205            external_peers: Vec<PeerUsername>,
206        }
207        let parsed: Wrapper = toml::from_str(toml_input).unwrap();
208        assert_eq!(parsed.external_peers.len(), 2);
209        assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
210        assert_eq!(parsed.external_peers[1].as_str(), "@user_2");
211    }
212
213    #[test]
214    fn alias_newtypes_are_distinct_at_type_level() {
215        // Compile-time: AgentAlias and PeerGroupName don't accidentally
216        // assign to each other. The cast through `String` is the only path.
217        let agent = AgentAlias::new("alpha");
218        let group: PeerGroupName = PeerGroupName::new(agent.as_str());
219        assert_eq!(agent.as_str(), group.as_str());
220    }
221
222    #[test]
223    fn memory_backend_kind_serializes_snake_case() {
224        let cases = [
225            (MemoryBackendKind::None, "\"none\""),
226            (MemoryBackendKind::Sqlite, "\"sqlite\""),
227            (MemoryBackendKind::Postgres, "\"postgres\""),
228            (MemoryBackendKind::Qdrant, "\"qdrant\""),
229            (MemoryBackendKind::Markdown, "\"markdown\""),
230            (MemoryBackendKind::Lucid, "\"lucid\""),
231        ];
232        for (kind, expected) in cases {
233            let json = serde_json::to_string(&kind).unwrap();
234            assert_eq!(json, expected, "backend={kind:?}");
235            let back: MemoryBackendKind = serde_json::from_str(&json).unwrap();
236            assert_eq!(back, kind);
237        }
238    }
239
240    #[test]
241    fn memory_backend_kind_default_is_sqlite() {
242        assert_eq!(MemoryBackendKind::default(), MemoryBackendKind::Sqlite);
243    }
244
245    #[test]
246    fn agent_workspace_config_round_trips_with_access_map() {
247        let toml_input = r#"
248unrestricted_filesystem = false
249read_memory_from = ["beta"]
250
251[access]
252beta = "read"
253gamma = "read_write"
254"#;
255        let parsed: AgentWorkspaceConfig = toml::from_str(toml_input).unwrap();
256        assert_eq!(parsed.path, None);
257        assert!(!parsed.unrestricted_filesystem);
258        assert_eq!(parsed.read_memory_from.len(), 1);
259        assert_eq!(parsed.read_memory_from[0], "beta");
260        assert_eq!(parsed.access.len(), 2);
261        let beta = AgentAlias::new("beta");
262        let gamma = AgentAlias::new("gamma");
263        assert_eq!(parsed.access.get(&beta), Some(&AccessMode::Read));
264        assert_eq!(parsed.access.get(&gamma), Some(&AccessMode::ReadWrite));
265    }
266
267    #[test]
268    fn agent_workspace_config_default_is_jailed() {
269        let cfg = AgentWorkspaceConfig::default();
270        assert_eq!(cfg.path, None);
271        assert!(cfg.access.is_empty());
272        assert!(!cfg.unrestricted_filesystem);
273        assert!(cfg.read_memory_from.is_empty());
274    }
275
276    #[test]
277    fn agent_memory_config_round_trips() {
278        let toml_input = r#"backend = "postgres""#;
279        let parsed: AgentMemoryConfig = toml::from_str(toml_input).unwrap();
280        assert_eq!(parsed.backend, MemoryBackendKind::Postgres);
281    }
282
283    #[test]
284    fn agent_memory_config_default_is_sqlite() {
285        assert_eq!(
286            AgentMemoryConfig::default().backend,
287            MemoryBackendKind::Sqlite
288        );
289    }
290
291    #[test]
292    fn peer_group_config_round_trips_with_external_peers_and_ignore() {
293        let toml_input = r#"
294channel = "telegram.prod"
295agents = ["alpha", "beta"]
296external_peers = ["@user_1", "@user_2"]
297ignore = ["@known_spammer"]
298"#;
299        let parsed: PeerGroupConfig = toml::from_str(toml_input).unwrap();
300        assert_eq!(parsed.channel, "telegram.prod");
301        assert_eq!(parsed.agents.len(), 2);
302        assert_eq!(parsed.agents[0], "alpha");
303        assert_eq!(parsed.agents[1], "beta");
304        assert_eq!(parsed.external_peers.len(), 2);
305        assert_eq!(parsed.external_peers[0].as_str(), "@user_1");
306        assert_eq!(parsed.ignore.len(), 1);
307        assert_eq!(parsed.ignore[0].as_str(), "@known_spammer");
308    }
309
310    #[test]
311    fn peer_group_config_default_is_empty() {
312        let cfg = PeerGroupConfig::default();
313        assert!(cfg.channel.is_empty());
314        assert!(cfg.agents.is_empty());
315        assert!(cfg.external_peers.is_empty());
316        assert!(cfg.ignore.is_empty());
317        // Default modality preserves the existing input-driven behavior.
318        assert_eq!(cfg.output_modality, OutputModality::Mirror);
319    }
320
321    #[test]
322    fn output_modality_serializes_snake_case() {
323        let cases = [
324            (OutputModality::Mirror, "\"mirror\""),
325            (OutputModality::Voice, "\"voice\""),
326            (OutputModality::Text, "\"text\""),
327        ];
328        for (modality, expected) in cases {
329            let json = serde_json::to_string(&modality).unwrap();
330            assert_eq!(json, expected, "modality={modality:?}");
331            let back: OutputModality = serde_json::from_str(&json).unwrap();
332            assert_eq!(back, modality);
333        }
334    }
335
336    #[test]
337    fn peer_group_output_modality_parses_voice_and_defaults_to_mirror() {
338        let with_voice: PeerGroupConfig = toml::from_str(
339            r#"
340channel = "telegram"
341external_peers = ["@alice"]
342output_modality = "voice"
343"#,
344        )
345        .unwrap();
346        assert_eq!(with_voice.output_modality, OutputModality::Voice);
347        assert_eq!(with_voice.external_peers[0].as_str(), "@alice");
348
349        // Omitting the field falls back to mirror (current behavior).
350        let defaulted: PeerGroupConfig = toml::from_str(r#"channel = "telegram""#).unwrap();
351        assert_eq!(defaulted.output_modality, OutputModality::Mirror);
352    }
353}