Skip to main content

qualia_client_core/
agent_registry.rs

1//! Per-principal roster of software agents.
2//!
3//! The chat graph is primarily human↔human. A *software agent* is never a
4//! free-standing chat actor — it is always defined **under** a human principal
5//! and is *invoked* into a conversation on that principal's behalf (see
6//! [`crate::chat_agents`], which binds a sub-agent DID to a session). This
7//! module is the durable **roster** of the agents a principal has configured:
8//! their persona, backend, tool allowlist, sensitivity ceiling, and
9//! outcome-sharing posture.
10//!
11//! Agents are diverse in backend (see [`AgentBackendSpec`]):
12//!
13//! - [`AgentBackendSpec::LocalEngine`] — runs in-process on the native
14//!   `p64`/`q42` engine. No outbound traffic; preferred for all work, and the
15//!   only backend suitable for the most sensitive material.
16//! - [`AgentBackendSpec::RemoteMcp`] — reached over an external provider's MCP
17//!   interface (e.g. a hosted Claude / Google / X model). This is opt-in and
18//!   costly, and by default handles only non-sensitive material. **Only the
19//!   *configuration* lives here** — the actual MCP client (transport, calls)
20//!   lives in the sibling `remote_mcp.rs`; this module never performs network
21//!   I/O.
22//!
23//! ## Persistence
24//!
25//! The roster is stored as pretty-printed JSON at
26//! `<storage_root>/Agents/roster.json`, mirroring the load/save-under-a-dir
27//! pattern used by [`crate::social_peers`] and [`crate::node_identity`]. The
28//! filesystem functions take an explicit `storage_root: &Path` so they are
29//! testable against a temporary directory. A missing or empty roster is treated
30//! as un-initialised and yields a roster seeded with a single default local
31//! agent ([`default_local_agent`]); the roster is therefore never observed
32//! empty, and a pure [`load_roster`] never writes to disk.
33
34use std::fs;
35use std::path::{Path, PathBuf};
36
37use serde::{Deserialize, Serialize};
38
39/// Outcome-sharing policy for an agent's processed results.
40///
41/// Re-exported from [`crate::chat_agents`] so a roster entry carries exactly the
42/// same shape (visibility owner-only / participants / specific DIDs,
43/// `share_provenance`, `share_model_attribution`, `allow_peer_llm_context`,
44/// `allowed_dids`) that the group-chat layer already understands.
45pub use crate::chat_agents::OutcomeSharingPolicy as OutcomeSharing;
46
47/// Sensitivity scale floor: public / non-sensitive material only.
48///
49/// `max_sensitivity` on an [`AgentDefinition`] is the *ceiling* of what an agent
50/// may handle: `0` = public, higher values = progressively more sensitive
51/// material the principal permits this agent to see.
52pub const SENSITIVITY_PUBLIC: u8 = 0;
53
54/// Default sensitivity ceiling for a fully-local agent.
55///
56/// A local agent runs in-process with no outbound traffic, so it may handle the
57/// principal's most sensitive material. Remote agents should be given a
58/// deliberately lower ceiling by the principal.
59pub const SENSITIVITY_LOCAL_DEFAULT: u8 = u8::MAX;
60
61/// How a [`AgentBackendSpec::RemoteMcp`] agent's MCP server is reached.
62///
63/// This is configuration only; the connection is made by the sibling
64/// `remote_mcp.rs`, never by this module.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum McpTransport {
68    /// A raw TCP endpoint (`host:port`).
69    Tcp { host: String, port: u16 },
70    /// A streamable-HTTP MCP endpoint.
71    Http {
72        url: String,
73        /// Optional OS-keychain connection ID. The bearer token itself is
74        /// never represented in the roster.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        credential_id: Option<String>,
77    },
78    /// A locally-launched MCP server spoken to over stdio.
79    Stdio { command: String, args: Vec<String> },
80}
81
82/// Which inference backend an agent uses.
83///
84/// Local is preferred; remote is opt-in and metered.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum AgentBackendSpec {
88    /// The native in-process engine. `model_id = None` means "use the
89    /// principal's currently-active model"; `Some` pins a specific model.
90    LocalEngine { model_id: Option<String> },
91    /// An external provider reached over its MCP interface.
92    ///
93    /// - `endpoint` — human-readable label / base address of the provider.
94    /// - `transport` — how the MCP client actually connects (see
95    ///   [`McpTransport`]).
96    /// - `infer_tool` — the MCP tool name to call for inference; `None` lets the
97    ///   client pick its default.
98    /// - `model` — the remote model identifier to request; `None` uses the
99    ///   provider's default.
100    RemoteMcp {
101        endpoint: String,
102        transport: McpTransport,
103        infer_tool: Option<String>,
104        model: Option<String>,
105    },
106}
107
108/// The role played by a semantic tag in an agent's pointed study graph.
109/// Tags are declarative grounding hints and never confer data/tool authority.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum AgentSemanticFacet {
113    Classification,
114    Specialisation,
115    Geography,
116    Language,
117    Method,
118    Dataset,
119    Tool,
120    Constraint,
121}
122
123/// An ontology-addressable point in the agent's declared study graph.
124/// `broader_iri` links a specialization to its selected parent, giving a
125/// bounded path such as Researcher → History → Australian History.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AgentSemanticTag {
128    pub iri: String,
129    pub label: String,
130    pub facet: AgentSemanticFacet,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub broader_iri: Option<String>,
133}
134
135/// Ontology-linked profile used to narrow routing and disclose an agent's
136/// declared scope.  The profile may recommend tools/datasets but permissions
137/// and tool allowlists remain separately authoritative.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
139pub struct AgentSemanticProfile {
140    #[serde(default)]
141    pub tags: Vec<AgentSemanticTag>,
142}
143
144impl AgentSemanticProfile {
145    /// Short human/model-readable focus terms used by ontology routing.  This
146    /// is bounded and cold-path only; it never loads or exports a dataset.
147    pub fn focus_terms(&self) -> Vec<String> {
148        self.tags
149            .iter()
150            .map(|tag| tag.label.trim().to_ascii_lowercase())
151            .filter(|label| !label.is_empty())
152            .take(32)
153            .collect()
154    }
155
156    pub fn briefing(&self) -> String {
157        if self.tags.is_empty() {
158            return String::new();
159        }
160        let mut lines = Vec::new();
161        lines.push("[Agent semantic study profile — use as a bounded routing focus; it does not grant permissions]".to_string());
162        for tag in self.tags.iter().take(32) {
163            let facet = match tag.facet {
164                AgentSemanticFacet::Classification => "classification",
165                AgentSemanticFacet::Specialisation => "specialisation",
166                AgentSemanticFacet::Geography => "geography",
167                AgentSemanticFacet::Language => "language",
168                AgentSemanticFacet::Method => "method",
169                AgentSemanticFacet::Dataset => "dataset",
170                AgentSemanticFacet::Tool => "tool capability",
171                AgentSemanticFacet::Constraint => "constraint",
172            };
173            lines.push(format!("- {facet}: {} ({})", tag.label, tag.iri));
174        }
175        lines.join("\n")
176    }
177}
178
179/// Conversation material an agent is permitted to receive for a turn.
180///
181/// This is deliberately narrower than the session itself.  Selecting an agent
182/// in a conversation never grants it the entire transcript by implication.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum ConversationAccess {
186    None,
187    #[default]
188    AddressedMessage,
189    SessionSummary,
190    PermittedHistory,
191}
192
193/// Whether graph/retrieval results may be included in an agent context manifest.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
195#[serde(rename_all = "snake_case")]
196pub enum RetrievalAccess {
197    None,
198    #[default]
199    PermittedScopes,
200}
201
202/// Whether files selected in the conversation may enter an agent context manifest.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
204#[serde(rename_all = "snake_case")]
205pub enum AttachmentAccess {
206    None,
207    MetadataOnly,
208    #[default]
209    PermittedAttachments,
210}
211
212/// Default visibility of an agent's completed answer.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
214#[serde(rename_all = "snake_case")]
215pub enum ContextVisibility {
216    #[default]
217    OwnerOnly,
218    NamedAgents,
219    SessionParticipants,
220}
221
222/// Directional context contract for a named agent.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct AgentContextPolicy {
225    #[serde(default)]
226    pub conversation: ConversationAccess,
227    #[serde(default)]
228    pub retrieval: RetrievalAccess,
229    #[serde(default)]
230    pub attachments: AttachmentAccess,
231    /// Stable roster slugs whose *completed summaries* may be supplied to this agent.
232    /// Empty means no other agent output is included.
233    #[serde(default)]
234    pub allowed_source_agents: Vec<String>,
235    #[serde(default)]
236    pub default_visibility: ContextVisibility,
237    /// Stable roster slugs permitted to receive this agent's output as context.
238    #[serde(default)]
239    pub allowed_recipient_agents: Vec<String>,
240    #[serde(default)]
241    pub may_share_raw_prompt: bool,
242    #[serde(default)]
243    pub may_share_attachments: bool,
244    #[serde(default)]
245    pub may_share_graph_records: bool,
246    #[serde(default = "default_share_provenance")]
247    pub may_share_provenance: bool,
248    /// Require a person to review a turn-specific context manifest before dispatch.
249    #[serde(default)]
250    pub require_turn_confirmation: bool,
251}
252
253const fn default_share_provenance() -> bool {
254    true
255}
256
257impl Default for AgentContextPolicy {
258    fn default() -> Self {
259        Self {
260            conversation: ConversationAccess::AddressedMessage,
261            retrieval: RetrievalAccess::PermittedScopes,
262            attachments: AttachmentAccess::PermittedAttachments,
263            allowed_source_agents: Vec::new(),
264            default_visibility: ContextVisibility::OwnerOnly,
265            allowed_recipient_agents: Vec::new(),
266            may_share_raw_prompt: false,
267            may_share_attachments: false,
268            may_share_graph_records: false,
269            may_share_provenance: true,
270            require_turn_confirmation: false,
271        }
272    }
273}
274
275/// Per-agent data boundary.  An empty allowlist means the agent may use only
276/// the ontology scopes already admitted to its chat session; a non-empty list
277/// further intersects that session scope.  It never widens access.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
279pub struct AgentDataPolicy {
280    /// Installed ontology/data-source IDs this agent may retrieve from.
281    /// Stable IDs are used rather than labels so a rename cannot widen access.
282    #[serde(default)]
283    pub allowed_ontology_ids: Vec<String>,
284}
285
286/// Residency preference for a local model selected by an agent.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
288#[serde(rename_all = "snake_case")]
289pub enum ModelResidencyPreference {
290    #[default]
291    OnDemand,
292    KeepWarm,
293    Pinned,
294}
295
296/// Scheduler priority for work requested through an agent.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
298#[serde(rename_all = "snake_case")]
299pub enum AgentJobPriority {
300    #[default]
301    Interactive,
302    Normal,
303    Background,
304}
305
306/// Consent required before an agent may use its configured remote backend.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
308#[serde(rename_all = "snake_case")]
309pub enum RemoteConsentPolicy {
310    Never,
311    #[default]
312    PerTurn,
313    Preapproved,
314}
315
316/// Per-agent scheduler and placement policy.  It describes a preference, not a
317/// reservation: the runtime may decline a pinned/keep-warm request when the
318/// caller's device budget cannot safely admit it.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct AgentExecutionPolicy {
321    #[serde(default)]
322    pub residency: ModelResidencyPreference,
323    #[serde(default)]
324    pub priority: AgentJobPriority,
325    #[serde(default = "default_max_parallel_turns")]
326    pub max_parallel_turns: u8,
327    #[serde(default)]
328    pub remote_consent: RemoteConsentPolicy,
329    #[serde(default)]
330    pub allow_scheduled_runs: bool,
331}
332
333const fn default_max_parallel_turns() -> u8 {
334    1
335}
336
337impl Default for AgentExecutionPolicy {
338    fn default() -> Self {
339        Self {
340            residency: ModelResidencyPreference::OnDemand,
341            priority: AgentJobPriority::Interactive,
342            max_parallel_turns: 1,
343            remote_consent: RemoteConsentPolicy::PerTurn,
344            allow_scheduled_runs: false,
345        }
346    }
347}
348
349/// A single agent in a principal's roster.
350///
351/// Keyed by [`slug`](AgentDefinition::slug), a stable identifier that survives
352/// renames of `display_name`. All fields are public so callers (e.g. the
353/// command/API layer) can build and edit definitions directly; use
354/// [`AgentDefinition::new`] for a conservatively-defaulted, timestamped entry.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct AgentDefinition {
357    /// Stable identifier for this agent (unique within the roster).
358    pub slug: String,
359    /// Human-friendly label shown in the UI.
360    pub display_name: String,
361    /// Free-form description of what the agent is for.
362    pub description: String,
363    /// The inference backend this agent uses.
364    pub backend: AgentBackendSpec,
365    /// The agent's persona / system prompt.
366    pub system_prompt: String,
367    /// MCP tool allowlist. Empty = no tools allowed; a single `"*"` entry = all
368    /// tools allowed. See [`AgentDefinition::has_tool`].
369    #[serde(default)]
370    pub allowed_mcp_tools: Vec<String>,
371    /// Ceiling of material sensitivity this agent may handle (`0` = public).
372    #[serde(default)]
373    pub max_sensitivity: u8,
374    /// How this agent's processed outcomes may be shared with peers.
375    #[serde(default)]
376    pub outcome_sharing: OutcomeSharing,
377    /// Pointed ontology-linked study graph: classification, specialization,
378    /// geographic/language scope, methods, datasets, tools and constraints.
379    #[serde(default)]
380    pub semantic_profile: AgentSemanticProfile,
381    /// Fine-grained, directional context contract.  Missing in pre-existing
382    /// roster files means the safe default above, preserving compatibility.
383    #[serde(default)]
384    pub context_policy: AgentContextPolicy,
385    /// Narrower per-agent data-source boundary, intersected with session
386    /// permissions at inference time.
387    #[serde(default)]
388    pub data_policy: AgentDataPolicy,
389    /// Model residency and remote-consent preferences, interpreted by the job
390    /// scheduler rather than by the inference hot path.
391    #[serde(default)]
392    pub execution_policy: AgentExecutionPolicy,
393    /// Persisted schema marker for future non-breaking roster migrations.
394    #[serde(default)]
395    pub roster_version: u16,
396    /// Whether the agent is currently available for invocation.
397    pub enabled: bool,
398    /// Unix seconds at which this agent was first created.
399    pub created_at_unix: u64,
400    /// Unix seconds at which this agent was last written.
401    pub updated_at_unix: u64,
402}
403
404impl AgentDefinition {
405    /// Construct a new agent with a conservative default posture: empty tool
406    /// allowlist, public-only sensitivity ([`SENSITIVITY_PUBLIC`]), default
407    /// (owner-only) outcome sharing, and `enabled = true`. Both timestamps are
408    /// stamped with the current wall-clock. Raise the sensitivity ceiling or
409    /// widen the allowlist deliberately after construction.
410    pub fn new(
411        slug: impl Into<String>,
412        display_name: impl Into<String>,
413        description: impl Into<String>,
414        backend: AgentBackendSpec,
415        system_prompt: impl Into<String>,
416    ) -> Self {
417        let now = unix_now();
418        Self {
419            slug: slug.into(),
420            display_name: display_name.into(),
421            description: description.into(),
422            backend,
423            system_prompt: system_prompt.into(),
424            allowed_mcp_tools: Vec::new(),
425            max_sensitivity: SENSITIVITY_PUBLIC,
426            outcome_sharing: OutcomeSharing::default(),
427            semantic_profile: AgentSemanticProfile::default(),
428            context_policy: AgentContextPolicy::default(),
429            data_policy: AgentDataPolicy::default(),
430            execution_policy: AgentExecutionPolicy::default(),
431            roster_version: 1,
432            enabled: true,
433            created_at_unix: now,
434            updated_at_unix: now,
435        }
436    }
437
438    /// Whether this agent is permitted to use the MCP tool named `tool`.
439    ///
440    /// An empty allowlist permits nothing; a `"*"` entry permits everything;
441    /// otherwise the tool must be listed by exact name.
442    pub fn has_tool(&self, tool: &str) -> bool {
443        self.allowed_mcp_tools.iter().any(|t| t == "*" || t == tool)
444    }
445}
446
447// ---------------------------------------------------------------------------
448// Seed
449// ---------------------------------------------------------------------------
450
451/// The default local agent that seeds an un-initialised roster.
452///
453/// Slug `"local"`, a fully-local ([`AgentBackendSpec::LocalEngine`] with no
454/// pinned model) backend, no MCP tools, the maximum sensitivity ceiling
455/// ([`SENSITIVITY_LOCAL_DEFAULT`], safe because nothing leaves the device), and
456/// enabled. Timestamps are stamped with the current wall-clock.
457pub fn default_local_agent() -> AgentDefinition {
458    let now = unix_now();
459    AgentDefinition {
460        slug: "local".to_string(),
461        display_name: "Your local agent".to_string(),
462        description: "Runs entirely on this device via the native Qualia inference engine. \
463             No data leaves the principal's control, so it is the preferred agent for all \
464             work — especially anything sensitive."
465            .to_string(),
466        backend: AgentBackendSpec::LocalEngine { model_id: None },
467        system_prompt: "You are a software agent acting on behalf of, and under the authority \
468             of, your human principal. You run locally on the principal's own device via the \
469             native engine; their data does not leave their control. Ground every answer in the \
470             principal's own records and cite the provenance you relied on; if you cannot ground \
471             a claim, say so plainly rather than inventing one. Spend the principal's time and \
472             resources only on the purpose they have declared, and defer to their explicit \
473             decisions at all times."
474            .to_string(),
475        allowed_mcp_tools: Vec::new(),
476        max_sensitivity: SENSITIVITY_LOCAL_DEFAULT,
477        outcome_sharing: OutcomeSharing::default(),
478        semantic_profile: AgentSemanticProfile::default(),
479        context_policy: AgentContextPolicy::default(),
480        data_policy: AgentDataPolicy::default(),
481        execution_policy: AgentExecutionPolicy::default(),
482        roster_version: 1,
483        enabled: true,
484        created_at_unix: now,
485        updated_at_unix: now,
486    }
487}
488
489// ---------------------------------------------------------------------------
490// Session binding
491// ---------------------------------------------------------------------------
492
493/// Derive the deterministic sub-agent DID under which an agent acts in a
494/// session, scoped to `principal_did` + `session_id`.
495///
496/// A thin wrapper over [`crate::chat_agents::compile_sub_agent_did`] — the same
497/// derivation the chat layer uses, so a roster agent and its in-session
498/// sub-agent share one DID. Writing the session's `agent_config.json` is the
499/// caller's responsibility; this only computes the DID.
500pub fn bind_agent_did(principal_did: &str, session_id: &str) -> String {
501    crate::chat_agents::compile_sub_agent_did(principal_did, session_id)
502}
503
504// ---------------------------------------------------------------------------
505// Persistence
506// ---------------------------------------------------------------------------
507
508fn roster_path(storage_root: &Path) -> PathBuf {
509    storage_root.join("Agents").join("roster.json")
510}
511
512fn unix_now() -> u64 {
513    std::time::SystemTime::now()
514        .duration_since(std::time::UNIX_EPOCH)
515        .unwrap_or_default()
516        .as_secs()
517}
518
519/// Load the principal's agent roster from `<storage_root>/Agents/roster.json`.
520///
521/// If the file is missing, empty, unreadable, unparseable, or parses to an empty
522/// list, a roster seeded with a single [`default_local_agent`] is returned. This
523/// is a **pure read**: it never writes to disk (the seed exists only in the
524/// returned value until something is explicitly saved).
525pub fn load_roster(storage_root: &Path) -> Vec<AgentDefinition> {
526    let roster = fs::read_to_string(roster_path(storage_root))
527        .ok()
528        .filter(|t| !t.trim().is_empty())
529        .and_then(|t| serde_json::from_str::<Vec<AgentDefinition>>(&t).ok())
530        .unwrap_or_default();
531
532    if roster.is_empty() {
533        vec![default_local_agent()]
534    } else {
535        roster
536    }
537}
538
539/// Persist `roster` to `<storage_root>/Agents/roster.json` as pretty JSON,
540/// creating the `Agents` directory if needed.
541pub fn save_roster(storage_root: &Path, roster: &[AgentDefinition]) -> Result<(), String> {
542    let path = roster_path(storage_root);
543    if let Some(parent) = path.parent() {
544        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
545    }
546    let text = serde_json::to_string_pretty(roster).map_err(|e| e.to_string())?;
547    fs::write(path, text).map_err(|e| e.to_string())
548}
549
550/// Insert or update `agent` in the stored roster, keyed by
551/// [`slug`](AgentDefinition::slug), then persist.
552///
553/// If an agent with the same slug exists it is replaced in place (preserving its
554/// original `created_at_unix`); otherwise `agent` is appended. `updated_at_unix`
555/// is bumped to the current wall-clock in both cases. Because [`load_roster`]
556/// seeds the default local agent when the store is empty, the first upsert of a
557/// *new* agent also materialises that seed to disk.
558pub fn upsert_agent(storage_root: &Path, agent: AgentDefinition) -> Result<(), String> {
559    upsert_agent_at(storage_root, agent, unix_now())
560}
561
562/// [`upsert_agent`] with an explicit `now_unix` timestamp, for deterministic
563/// testing and callers that already hold a clock reading.
564pub fn upsert_agent_at(
565    storage_root: &Path,
566    mut agent: AgentDefinition,
567    now_unix: u64,
568) -> Result<(), String> {
569    validate_agent(&agent)?;
570    agent.updated_at_unix = now_unix;
571    let mut roster = load_roster(storage_root);
572    if let Some(slot) = roster.iter_mut().find(|a| a.slug == agent.slug) {
573        // A stable slug keeps its original creation time across edits.
574        agent.created_at_unix = slot.created_at_unix;
575        *slot = agent;
576    } else {
577        roster.push(agent);
578    }
579    save_roster(storage_root, &roster)
580}
581
582/// Validate values that cross the roster/UI boundary.  This is intentionally
583/// a cold-path check: it protects stable mention keys and prevents a malformed
584/// execution preference from becoming a scheduler ambiguity.
585pub fn validate_agent(agent: &AgentDefinition) -> Result<(), String> {
586    let slug = agent.slug.trim();
587    if slug.is_empty() || slug.len() > 64 {
588        return Err("agent slug must contain 1-64 characters".to_string());
589    }
590    if !slug
591        .bytes()
592        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
593    {
594        return Err(
595            "agent slug may contain only lowercase letters, digits, and hyphens".to_string(),
596        );
597    }
598    if agent.display_name.trim().is_empty() || agent.display_name.chars().count() > 96 {
599        return Err("agent name must contain 1-96 characters".to_string());
600    }
601    if agent.system_prompt.len() > 32 * 1024 {
602        return Err("agent instructions exceed the 32 KiB limit".to_string());
603    }
604    if agent.execution_policy.max_parallel_turns == 0
605        || agent.execution_policy.max_parallel_turns > 4
606    {
607        return Err("agent max_parallel_turns must be between 1 and 4".to_string());
608    }
609    if agent.semantic_profile.tags.len() > 32 {
610        return Err("agent semantic profile supports at most 32 tags".to_string());
611    }
612    for tag in &agent.semantic_profile.tags {
613        if tag.iri.trim().is_empty() || tag.iri.len() > 256 {
614            return Err(
615                "each agent semantic tag needs an IRI of at most 256 characters".to_string(),
616            );
617        }
618        if tag.label.trim().is_empty() || tag.label.chars().count() > 96 {
619            return Err("each agent semantic tag needs a label of 1-96 characters".to_string());
620        }
621        if tag
622            .broader_iri
623            .as_deref()
624            .is_some_and(|iri| iri.trim().is_empty() || iri.len() > 256)
625        {
626            return Err("agent semantic tag broader IRI is invalid".to_string());
627        }
628    }
629    if agent.data_policy.allowed_ontology_ids.len() > 32 {
630        return Err("agent data policy supports at most 32 ontology IDs".to_string());
631    }
632    if agent.data_policy.allowed_ontology_ids.iter().any(|id| {
633        id.trim().is_empty() || id.len() > 160 || id.contains(['/', '\\', '\0'])
634    }) {
635        return Err("agent data policy has an invalid ontology ID".to_string());
636    }
637    match &agent.backend {
638        AgentBackendSpec::LocalEngine { model_id } => {
639            if model_id.as_deref().is_some_and(|id| id.trim().is_empty()) {
640                return Err("local agent model_id must not be blank".to_string());
641            }
642        }
643        AgentBackendSpec::RemoteMcp { endpoint, .. } if endpoint.trim().is_empty() => {
644            return Err("remote agent endpoint is required".to_string());
645        }
646        AgentBackendSpec::RemoteMcp { .. } => {}
647    }
648    Ok(())
649}
650
651/// Remove the agent with the given `slug` from the stored roster, then persist.
652///
653/// Removing an absent slug is a no-op success. Note that removing the *last*
654/// agent leaves an empty store, which [`load_roster`] will re-seed with the
655/// default local agent on the next read.
656pub fn remove_agent(storage_root: &Path, slug: &str) -> Result<(), String> {
657    let mut roster = load_roster(storage_root);
658    roster.retain(|a| a.slug != slug);
659    save_roster(storage_root, &roster)
660}
661
662/// Fetch a single agent by `slug`, if present in the (possibly seeded) roster.
663pub fn get_agent(storage_root: &Path, slug: &str) -> Option<AgentDefinition> {
664    load_roster(storage_root)
665        .into_iter()
666        .find(|a| a.slug == slug)
667}
668
669// ---------------------------------------------------------------------------
670// Tests — filesystem is confined to a tempdir; no network.
671// ---------------------------------------------------------------------------
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use tempfile::tempdir;
677
678    fn remote(slug: &str, transport: McpTransport) -> AgentDefinition {
679        AgentDefinition {
680            slug: slug.to_string(),
681            display_name: format!("Remote {slug}"),
682            description: "external provider".to_string(),
683            backend: AgentBackendSpec::RemoteMcp {
684                endpoint: "https://provider.example/mcp".to_string(),
685                transport,
686                infer_tool: Some("infer".to_string()),
687                model: Some("big-model".to_string()),
688            },
689            system_prompt: "persona".to_string(),
690            allowed_mcp_tools: vec!["search".to_string()],
691            max_sensitivity: SENSITIVITY_PUBLIC,
692            outcome_sharing: OutcomeSharing::default(),
693            semantic_profile: AgentSemanticProfile::default(),
694            context_policy: AgentContextPolicy::default(),
695            data_policy: AgentDataPolicy::default(),
696            execution_policy: AgentExecutionPolicy::default(),
697            roster_version: 1,
698            enabled: true,
699            created_at_unix: 100,
700            updated_at_unix: 100,
701        }
702    }
703
704    #[test]
705    fn default_local_agent_shape() {
706        let a = default_local_agent();
707        assert_eq!(a.slug, "local");
708        assert_eq!(a.display_name, "Your local agent");
709        assert!(matches!(
710            a.backend,
711            AgentBackendSpec::LocalEngine { model_id: None }
712        ));
713        assert!(a.enabled);
714        assert!(a.allowed_mcp_tools.is_empty());
715        assert_eq!(a.max_sensitivity, SENSITIVITY_LOCAL_DEFAULT);
716        // Empty allowlist ⇒ no tool is permitted.
717        assert!(!a.has_tool("anything"));
718    }
719
720    #[test]
721    fn new_uses_conservative_defaults() {
722        let a = AgentDefinition::new(
723            "claude",
724            "Claude",
725            "remote",
726            AgentBackendSpec::RemoteMcp {
727                endpoint: "e".to_string(),
728                transport: McpTransport::Http {
729                    url: "https://x/mcp".to_string(),
730                    credential_id: None,
731                },
732                infer_tool: None,
733                model: None,
734            },
735            "persona",
736        );
737        assert!(a.enabled);
738        assert!(a.allowed_mcp_tools.is_empty());
739        assert_eq!(a.max_sensitivity, SENSITIVITY_PUBLIC);
740        assert_eq!(a.outcome_sharing, OutcomeSharing::default());
741        assert_eq!(a.context_policy, AgentContextPolicy::default());
742        assert_eq!(a.execution_policy, AgentExecutionPolicy::default());
743        assert!(a.semantic_profile.tags.is_empty());
744        assert_eq!(a.created_at_unix, a.updated_at_unix);
745    }
746
747    #[test]
748    fn legacy_roster_without_new_policies_loads_safe_defaults() {
749        let dir = tempdir().unwrap();
750        save_blob(
751            dir.path(),
752            r#"[{"slug":"legacy","display_name":"Legacy","description":"x","backend":{"local_engine":{"model_id":null}},"system_prompt":"","allowed_mcp_tools":[],"max_sensitivity":0,"outcome_sharing":{"visibility":"owner_only","share_provenance":true,"share_model_attribution":false,"allow_peer_llm_context":false,"allowed_dids":[]},"enabled":true,"created_at_unix":1,"updated_at_unix":1}]"#,
753        );
754        let legacy = get_agent(dir.path(), "legacy").unwrap();
755        assert_eq!(legacy.context_policy, AgentContextPolicy::default());
756        assert_eq!(legacy.execution_policy, AgentExecutionPolicy::default());
757        assert_eq!(legacy.roster_version, 0);
758    }
759
760    #[test]
761    fn rejects_invalid_stable_slug_and_parallelism() {
762        let dir = tempdir().unwrap();
763        let mut a = default_local_agent();
764        a.slug = "Not stable".to_string();
765        assert!(upsert_agent(dir.path(), a).is_err());
766
767        let mut a = default_local_agent();
768        a.execution_policy.max_parallel_turns = 5;
769        assert!(upsert_agent(dir.path(), a).is_err());
770    }
771
772    #[test]
773    fn semantic_profile_is_bounded_and_produces_routing_focus() {
774        let mut agent = default_local_agent();
775        agent.semantic_profile.tags = vec![
776            AgentSemanticTag {
777                iri: "q42:Researcher".to_string(),
778                label: "Researcher".to_string(),
779                facet: AgentSemanticFacet::Classification,
780                broader_iri: None,
781            },
782            AgentSemanticTag {
783                iri: "q42:AustralianHistory".to_string(),
784                label: "Australian History".to_string(),
785                facet: AgentSemanticFacet::Specialisation,
786                broader_iri: Some("q42:History".to_string()),
787            },
788        ];
789        assert_eq!(
790            agent.semantic_profile.focus_terms(),
791            vec!["researcher", "australian history"]
792        );
793        assert!(
794            agent
795                .semantic_profile
796                .briefing()
797                .contains("Australian History")
798        );
799        validate_agent(&agent).unwrap();
800    }
801
802    #[test]
803    fn has_tool_honors_membership_and_wildcard() {
804        let mut a = default_local_agent();
805        assert!(!a.has_tool("read"));
806
807        a.allowed_mcp_tools = vec!["read".to_string(), "write".to_string()];
808        assert!(a.has_tool("read"));
809        assert!(a.has_tool("write"));
810        assert!(!a.has_tool("delete"));
811
812        a.allowed_mcp_tools = vec!["*".to_string()];
813        assert!(a.has_tool("read"));
814        assert!(a.has_tool("literally-anything"));
815    }
816
817    #[test]
818    fn load_roster_seeds_when_missing_and_does_not_write() {
819        let dir = tempdir().unwrap();
820        let roster = load_roster(dir.path());
821        assert_eq!(roster.len(), 1);
822        assert_eq!(roster[0].slug, "local");
823        // A pure load must not create the file.
824        assert!(!roster_path(dir.path()).exists());
825    }
826
827    #[test]
828    fn load_roster_seeds_on_empty_and_empty_array_files() {
829        let dir = tempdir().unwrap();
830        // Whitespace-only content.
831        save_blob(dir.path(), "   \n");
832        assert_eq!(load_roster(dir.path())[0].slug, "local");
833        // An explicitly empty array.
834        save_blob(dir.path(), "[]");
835        assert_eq!(load_roster(dir.path()).len(), 1);
836        assert_eq!(load_roster(dir.path())[0].slug, "local");
837    }
838
839    fn save_blob(root: &Path, blob: &str) {
840        let path = roster_path(root);
841        fs::create_dir_all(path.parent().unwrap()).unwrap();
842        fs::write(path, blob).unwrap();
843    }
844
845    #[test]
846    fn save_then_load_roundtrip_all_transports() {
847        let dir = tempdir().unwrap();
848        let roster = vec![
849            default_local_agent(),
850            remote(
851                "tcp-agent",
852                McpTransport::Tcp {
853                    host: "10.0.0.1".to_string(),
854                    port: 9000,
855                },
856            ),
857            remote(
858                "http-agent",
859                McpTransport::Http {
860                    url: "https://x/mcp".to_string(),
861                    credential_id: None,
862                },
863            ),
864            remote(
865                "stdio-agent",
866                McpTransport::Stdio {
867                    command: "mcp-server".to_string(),
868                    args: vec!["--flag".to_string(), "v".to_string()],
869                },
870            ),
871        ];
872        save_roster(dir.path(), &roster).unwrap();
873        let back = load_roster(dir.path());
874        assert_eq!(back, roster);
875    }
876
877    #[test]
878    fn upsert_appends_new_then_replaces_by_slug() {
879        let dir = tempdir().unwrap();
880
881        // Append a new agent onto the (seeded) store.
882        let mut a = remote(
883            "worker",
884            McpTransport::Http {
885                url: "u".to_string(),
886                credential_id: None,
887            },
888        );
889        a.created_at_unix = 100;
890        a.updated_at_unix = 100;
891        upsert_agent_at(dir.path(), a, 555).unwrap();
892
893        let roster = load_roster(dir.path());
894        // Seed 'local' materialised + the new 'worker'.
895        assert!(roster.iter().any(|x| x.slug == "local"));
896        let stored = get_agent(dir.path(), "worker").unwrap();
897        assert_eq!(stored.created_at_unix, 100, "created preserved on append");
898        assert_eq!(stored.updated_at_unix, 555, "updated bumped to now_unix");
899
900        // Replace by slug: created preserved from the stored entry, updated bumped.
901        let mut edited = remote(
902            "worker",
903            McpTransport::Http {
904                url: "u2".to_string(),
905                credential_id: None,
906            },
907        );
908        edited.display_name = "renamed".to_string();
909        edited.created_at_unix = 9999; // should be ignored in favour of stored 100
910        upsert_agent_at(dir.path(), edited, 777).unwrap();
911
912        let after = get_agent(dir.path(), "worker").unwrap();
913        assert_eq!(after.display_name, "renamed");
914        assert_eq!(after.created_at_unix, 100, "created preserved on replace");
915        assert_eq!(after.updated_at_unix, 777);
916        // Replacement, not duplication.
917        let count = load_roster(dir.path())
918            .iter()
919            .filter(|x| x.slug == "worker")
920            .count();
921        assert_eq!(count, 1);
922    }
923
924    #[test]
925    fn remove_agent_removes_and_is_noop_for_absent() {
926        let dir = tempdir().unwrap();
927        upsert_agent_at(
928            dir.path(),
929            remote(
930                "gone",
931                McpTransport::Http {
932                    url: "u".to_string(),
933                    credential_id: None,
934                },
935            ),
936            1,
937        )
938        .unwrap();
939        assert!(get_agent(dir.path(), "gone").is_some());
940
941        remove_agent(dir.path(), "gone").unwrap();
942        assert!(get_agent(dir.path(), "gone").is_none());
943
944        // No-op success for an absent slug.
945        remove_agent(dir.path(), "never-existed").unwrap();
946    }
947
948    #[test]
949    fn removing_last_agent_reseeds_local_on_next_load() {
950        let dir = tempdir().unwrap();
951        // Persist just the local seed, then remove it.
952        save_roster(dir.path(), &[default_local_agent()]).unwrap();
953        remove_agent(dir.path(), "local").unwrap();
954        // File now holds an empty list; load re-seeds.
955        let roster = load_roster(dir.path());
956        assert_eq!(roster.len(), 1);
957        assert_eq!(roster[0].slug, "local");
958    }
959
960    #[test]
961    fn get_agent_found_and_missing() {
962        let dir = tempdir().unwrap();
963        // The seeded 'local' is reachable without any prior save.
964        assert!(get_agent(dir.path(), "local").is_some());
965        assert!(get_agent(dir.path(), "nope").is_none());
966    }
967
968    #[test]
969    fn bind_agent_did_matches_chat_layer_and_is_deterministic() {
970        let a = bind_agent_did("did:qualia:root:abc", "sess-1");
971        let b = bind_agent_did("did:qualia:root:abc", "sess-1");
972        assert_eq!(a, b);
973        assert!(a.starts_with("did:qualia:subagent:"));
974        assert_eq!(
975            a,
976            crate::chat_agents::compile_sub_agent_did("did:qualia:root:abc", "sess-1")
977        );
978    }
979}