Skip to main content

qualia_client_core/api/
agents.rs

1//! Agent roster, MCP tool loop
2
3#![allow(non_snake_case)]
4
5use super::*;
6
7use std::path::Path;
8
9fn agent_roster_storage() -> Result<String, String> {
10    let state = crate::state::APP_STATE
11        .get()
12        .ok_or("Application not initialized")?;
13    let storage = state
14        .config
15        .lock()
16        .map_err(|e| e.to_string())?
17        .storage_path
18        .clone();
19    Ok(storage)
20}
21
22pub fn agent_roster_list() -> Result<serde_json::Value, String> {
23    let storage = agent_roster_storage()?;
24    let roster = crate::agent_registry::load_roster(Path::new(&storage));
25    serde_json::to_value(roster).map_err(|e| e.to_string())
26}
27
28pub fn agent_roster_get(slug: String) -> Result<serde_json::Value, String> {
29    let storage = agent_roster_storage()?;
30    match crate::agent_registry::get_agent(Path::new(&storage), &slug) {
31        Some(a) => serde_json::to_value(a).map_err(|e| e.to_string()),
32        None => Ok(serde_json::Value::Null),
33    }
34}
35
36pub fn agent_roster_upsert(agent_json: String) -> Result<(), String> {
37    let storage = agent_roster_storage()?;
38    let agent: crate::agent_registry::AgentDefinition =
39        serde_json::from_str(&agent_json).map_err(|e| format!("invalid agent JSON: {e}"))?;
40    crate::agent_registry::upsert_agent(Path::new(&storage), agent)
41}
42
43pub fn agent_roster_remove(slug: String) -> Result<(), String> {
44    let storage = agent_roster_storage()?;
45    crate::agent_registry::remove_agent(Path::new(&storage), &slug)
46}
47
48/// Truthful runtime projection for one roster agent.  Definitions are durable;
49/// this response describes only the currently effective local-model residency
50/// and recent decode measurement, so callers do not mistake "configured" for
51/// "loaded on GPU".
52pub fn agent_runtime_status(slug: String) -> Result<serde_json::Value, String> {
53    let storage = agent_roster_storage()?;
54    let agent = crate::agent_registry::get_agent(Path::new(&storage), &slug)
55        .ok_or_else(|| format!("unknown agent @{slug}"))?;
56    let active = crate::api::load_active_model_record_from_disk();
57    let (backend, configured_model, resident) = match &agent.backend {
58        crate::agent_registry::AgentBackendSpec::LocalEngine { model_id } => {
59            let resident = active.as_ref().is_some_and(|active| {
60                model_id
61                    .as_deref()
62                    .map_or(true, |configured| configured == active.model_id)
63            });
64            ("local", model_id.clone(), resident)
65        }
66        crate::agent_registry::AgentBackendSpec::RemoteMcp { model, .. } => {
67            ("remote_mcp", model.clone(), false)
68        }
69    };
70    Ok(serde_json::json!({
71        "slug": agent.slug,
72        "enabled": agent.enabled,
73        "backend": backend,
74        "configured_model_id": configured_model,
75        "resident": resident,
76        "active_model_id": active.as_ref().map(|record| record.model_id.clone()),
77        "lifecycle_state": crate::model_lifecycle::lifecycle_label(
78            crate::model_lifecycle::get_model_lifecycle_state()
79        ),
80        "last_decode_tokens_per_sec": crate::model_lifecycle::get_last_decode_tok_s(),
81        "last_decode_at_unix": crate::model_lifecycle::get_last_decode_tok_s_at_unix(),
82    }))
83}
84
85// ── Principal-gated MCP tool loop (U3-A / U3-B) ────────────────────────────────
86// Propose → Permit/Deny → execute. Deny never dispatches. Allowlist from roster.
87
88/// List local in-process MCP tools (`tools/list`) for Talk / allowlist UI.
89pub fn mcp_list_local_tools() -> Result<serde_json::Value, String> {
90    let tools = crate::mcp_tool_loop::mcp_list_local_tools()?;
91    serde_json::to_value(tools).map_err(|e| e.to_string())
92}
93
94/// Principal-gated MCP tool call. `principal_permitted = false` → Err without MCP.
95/// Tool must be on the agent's `allowed_mcp_tools` (or `*`); empty allowlist denies all.
96pub fn mcp_call_tool_gated(
97    agent_slug: String,
98    tool_name: String,
99    arguments_json: String,
100    principal_permitted: bool,
101) -> Result<String, String> {
102    let storage = agent_roster_storage()?;
103    crate::mcp_tool_loop::mcp_call_tool_gated(
104        Path::new(&storage),
105        &agent_slug,
106        &tool_name,
107        &arguments_json,
108        principal_permitted,
109    )
110}
111
112/// Convenience: set `allowed_mcp_tools` on a roster agent (persist via upsert).
113pub fn agent_set_allowed_mcp_tools(slug: String, tools: Vec<String>) -> Result<(), String> {
114    let storage = agent_roster_storage()?;
115    crate::mcp_tool_loop::agent_set_allowed_mcp_tools(Path::new(&storage), &slug, tools)
116}
117
118/// If allowlist is empty, seed `list_capabilities` + `computer_vision` (dogfood-safe).
119/// Does not Permit any call — only widens the roster allowlist.
120pub fn mcp_ensure_safe_tool_allowlist(slug: String) -> Result<serde_json::Value, String> {
121    let storage = agent_roster_storage()?;
122    let tools = crate::mcp_tool_loop::ensure_safe_tool_allowlist(Path::new(&storage), &slug)?;
123    serde_json::to_value(tools).map_err(|e| e.to_string())
124}
125
126/// Convenience: create/update a REMOTE-MCP agent from primitives so the UI never hand-builds the
127/// backend enum. `transport_kind` ∈ `"tcp"` | `"http"` | `"stdio"`; `endpoint` is `host:port` / a URL /
128/// a command line respectively.
129pub fn agent_roster_add_remote(
130    slug: String,
131    display_name: String,
132    transport_kind: String,
133    endpoint: String,
134    infer_tool: Option<String>,
135    model: Option<String>,
136    system_prompt: Option<String>,
137) -> Result<(), String> {
138    use crate::agent_registry::{AgentBackendSpec, McpTransport};
139    if slug.trim().is_empty() {
140        return Err("agent slug is required".to_string());
141    }
142    let transport = match transport_kind.to_lowercase().as_str() {
143        "tcp" => {
144            let (host, port) = endpoint
145                .rsplit_once(':')
146                .ok_or_else(|| "TCP endpoint must be host:port".to_string())?;
147            let port: u16 = port
148                .trim()
149                .parse()
150                .map_err(|_| "invalid TCP port".to_string())?;
151            McpTransport::Tcp {
152                host: host.trim().to_string(),
153                port,
154            }
155        }
156        "http" => McpTransport::Http {
157            url: endpoint.trim().to_string(),
158            credential_id: None,
159        },
160        "stdio" => {
161            let mut parts = endpoint.split_whitespace().map(|s| s.to_string());
162            let command = parts
163                .next()
164                .ok_or_else(|| "stdio endpoint needs a command".to_string())?;
165            McpTransport::Stdio {
166                command,
167                args: parts.collect(),
168            }
169        }
170        other => return Err(format!("unknown transport '{other}' (use tcp|http|stdio)")),
171    };
172    let backend = AgentBackendSpec::RemoteMcp {
173        endpoint: endpoint.trim().to_string(),
174        transport,
175        infer_tool: infer_tool.filter(|s| !s.trim().is_empty()),
176        model: model.filter(|s| !s.trim().is_empty()),
177    };
178    let mut agent = crate::agent_registry::AgentDefinition::new(
179        slug,
180        display_name,
181        "Remote agent reached over MCP.".to_string(),
182        backend,
183        system_prompt.unwrap_or_default(),
184    );
185    agent.enabled = true;
186    let storage = agent_roster_storage()?;
187    crate::agent_registry::upsert_agent(Path::new(&storage), agent)
188}
189
190/// Store a bearer credential for a user-owned connection in the operating
191/// system keychain. The secret is intentionally write-only at this API boundary.
192#[cfg(not(target_arch = "wasm32"))]
193pub fn provider_credential_store(connection_id: String, bearer: String) -> Result<(), String> {
194    crate::provider_credentials::store_bearer_credential(&connection_id, &bearer)
195}
196
197/// Remove a user-owned connection credential from the operating-system keychain.
198#[cfg(not(target_arch = "wasm32"))]
199pub fn provider_credential_remove(connection_id: String) -> Result<(), String> {
200    crate::provider_credentials::remove_bearer_credential(&connection_id)
201}
202
203/// Verify that a configured remote-MCP agent can answer the non-generative
204/// `tools/list` handshake.  This never sends a chat prompt.
205#[cfg(not(target_arch = "wasm32"))]
206pub fn agent_remote_connection_test(slug: String) -> Result<serde_json::Value, String> {
207    let storage = agent_roster_storage()?;
208    let agent = crate::agent_registry::get_agent(Path::new(&storage), &slug)
209        .ok_or_else(|| format!("no agent '{slug}' in roster"))?;
210    let transport = match &agent.backend {
211        crate::agent_registry::AgentBackendSpec::RemoteMcp { transport, .. } => transport,
212        crate::agent_registry::AgentBackendSpec::LocalEngine { .. } => {
213            return Err("only remote MCP agents have a connection to test".into());
214        }
215    };
216    let tool_count = crate::remote_mcp::remote_mcp_probe(transport)?;
217    Ok(serde_json::json!({
218        "ok": true,
219        "agent_slug": agent.slug,
220        "tool_count": tool_count,
221    }))
222}
223
224/// Backend kind of a roster agent: `"local"` | `"remote"` (unknown/empty slug → `"local"`).
225pub fn agent_backend_kind(slug: Option<String>) -> Result<String, String> {
226    let slug = match slug {
227        Some(s) if !s.is_empty() => s,
228        _ => return Ok("local".to_string()),
229    };
230    let storage = agent_roster_storage()?;
231    match crate::agent_registry::get_agent(Path::new(&storage), &slug) {
232        Some(a) => Ok(match a.backend {
233            crate::agent_registry::AgentBackendSpec::LocalEngine { .. } => "local".to_string(),
234            crate::agent_registry::AgentBackendSpec::RemoteMcp { .. } => "remote".to_string(),
235        }),
236        None => Ok("local".to_string()),
237    }
238}
239
240/// Run one turn against a REMOTE-MCP agent from the roster (native-only). Privacy-gated via the job
241/// router (Classified/sanctuary never leaves the device), then issues an MCP `tools/call` to the
242/// provider and appends the reply as an agent message. Returns a ChatInferenceResult-shaped JSON.
243#[cfg(not(target_arch = "wasm32"))]
244pub fn run_remote_agent_turn(
245    session_id: String,
246    slug: String,
247    prompt: String,
248    per_turn_consent: bool,
249) -> Result<serde_json::Value, String> {
250    use crate::agent_registry::AgentBackendSpec;
251    let storage = agent_roster_storage()?;
252    let agent = crate::agent_registry::get_agent(Path::new(&storage), &slug)
253        .ok_or_else(|| format!("no agent '{slug}' in roster"))?;
254    if !agent.enabled {
255        return Ok(remote_turn_blocked(&format!(
256            "agent '{}' is disabled",
257            agent.display_name
258        )));
259    }
260    match agent.execution_policy.remote_consent {
261        crate::agent_registry::RemoteConsentPolicy::Never => {
262            return Ok(remote_turn_blocked("this agent's remote connection is disabled by its policy"));
263        }
264        crate::agent_registry::RemoteConsentPolicy::PerTurn if !per_turn_consent => {
265            return Ok(remote_turn_blocked("remote dispatch requires this turn's explicit confirmation"));
266        }
267        crate::agent_registry::RemoteConsentPolicy::PerTurn
268        | crate::agent_registry::RemoteConsentPolicy::Preapproved => {}
269    }
270    let (transport, infer_tool, model) = match &agent.backend {
271        AgentBackendSpec::RemoteMcp {
272            transport,
273            infer_tool,
274            model,
275            ..
276        } => (transport.clone(), infer_tool.clone(), model.clone()),
277        AgentBackendSpec::LocalEngine { .. } => {
278            return Err("agent is local — use the local inference path".to_string());
279        }
280    };
281
282    // Privacy-first placement: a configured remote agent implies consent, but sanctuary/Classified
283    // context must never leave the device.
284    let local_active = crate::model_lifecycle::lifecycle_label(
285        crate::model_lifecycle::get_model_lifecycle_state(),
286    ) == "Active";
287    let inputs = crate::job_router::RoutingInputs {
288        sensitivity: wellfare_core::record::SensitivityClass::Restricted,
289        local_available: local_active,
290        external_consented: per_turn_consent || matches!(
291            agent.execution_policy.remote_consent,
292            crate::agent_registry::RemoteConsentPolicy::Preapproved
293        ),
294        requires_capability: None,
295        local_has_capability: false,
296        estimated_cost_microcents: 0,
297    };
298    match crate::job_router::route_job(&inputs, &crate::job_router::RoutingPolicy::default()) {
299        crate::job_router::RoutingDecision::Blocked { reason }
300        | crate::job_router::RoutingDecision::NeedsConsent { reason } => {
301            return Ok(remote_turn_blocked(&reason));
302        }
303        _ => {}
304    }
305
306    let system = if agent.system_prompt.trim().is_empty() {
307        None
308    } else {
309        Some(agent.system_prompt.as_str())
310    };
311    let text = crate::remote_mcp::remote_mcp_infer(
312        &transport,
313        infer_tool.as_deref(),
314        model.as_deref(),
315        system,
316        &prompt,
317    )?;
318    if !text.trim().is_empty() {
319        let _ = append_chat_message(session_id, "agent".to_string(), text.clone());
320    }
321    Ok(serde_json::json!({
322        "text": text,
323        "committed": true,
324        "block_reason": serde_json::Value::Null,
325        "agent_backend": "remote",
326        "model_id": model,
327        "provenance_hashes": [],
328        "citations": [],
329        "tokens_generated": 0,
330        "inference_duration_ms": 0,
331    }))
332}
333
334#[cfg(not(target_arch = "wasm32"))]
335fn remote_turn_blocked(reason: &str) -> serde_json::Value {
336    serde_json::json!({
337        "text": "",
338        "committed": false,
339        "block_reason": reason,
340        "agent_backend": "remote",
341    })
342}
343
344/// Store a chat turn's inline CML context (`#project:` / `#topic:` / `#task:` / `[[concept]]`) into the
345/// person's inforg (their private library). No-op if the message has no tags. Returns concepts stored.
346pub fn ingest_chat_cml(session_id: String, text: String) -> Result<usize, String> {
347    let storage = agent_roster_storage()?;
348    crate::cml_context::ingest_turn(Path::new(&storage), &session_id, &text).map(|v| v.len())
349}