1use crate::session::{A2aRouteAuth, ChatGoalState, ServerState, WsChannel};
13use car_proto::*;
14use car_verify;
15use futures::{SinkExt, StreamExt};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use std::collections::HashMap;
20use std::net::SocketAddr;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::Arc;
23use tokio::net::TcpStream;
24use tokio::sync::Mutex;
25use tokio_tungstenite::{accept_async, tungstenite::Message};
26use tracing::{error, info, instrument};
27
28#[cfg(test)]
29struct CredentialFanoutTestGate {
30 ready: tokio::sync::Notify,
31 release: tokio::sync::Notify,
32}
33
34#[cfg(test)]
35fn credential_fanout_test_gate() -> &'static CredentialFanoutTestGate {
36 static GATE: std::sync::OnceLock<CredentialFanoutTestGate> = std::sync::OnceLock::new();
37 GATE.get_or_init(|| CredentialFanoutTestGate {
38 ready: tokio::sync::Notify::new(),
39 release: tokio::sync::Notify::new(),
40 })
41}
42
43#[cfg(test)]
44async fn pause_credential_fanout_for_test() {
45 if std::env::var_os("CAR_TEST_PAUSE_CREDENTIAL_FANOUT").is_some() {
46 let gate = credential_fanout_test_gate();
47 gate.ready.notify_one();
48 gate.release.notified().await;
49 }
50}
51
52#[cfg(not(test))]
53async fn pause_credential_fanout_for_test() {}
54
55#[cfg(test)]
56fn credential_handoff_test_gate() -> &'static CredentialFanoutTestGate {
57 static GATE: std::sync::OnceLock<CredentialFanoutTestGate> = std::sync::OnceLock::new();
58 GATE.get_or_init(|| CredentialFanoutTestGate {
59 ready: tokio::sync::Notify::new(),
60 release: tokio::sync::Notify::new(),
61 })
62}
63
64#[cfg(test)]
65async fn pause_credential_handoff_for_test() {
66 if std::env::var_os("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF").is_some() {
67 let gate = credential_handoff_test_gate();
68 gate.ready.notify_one();
69 gate.release.notified().await;
70 }
71}
72
73#[cfg(not(test))]
74async fn pause_credential_handoff_for_test() {}
75
76fn credential_event_is_eligible(
77 session: &crate::session::ClientSession,
78 transport_auth_required: bool,
79 host_role_required: bool,
80) -> bool {
81 (!transport_auth_required || session.authenticated.load(Ordering::Acquire))
82 && (!host_role_required || session.is_host.load(Ordering::Acquire))
83 && session.negotiated_protocol_version.load(Ordering::Acquire)
84 == car_proto::PROTOCOL_VERSION
85}
86
87fn credential_status_advances(
88 previous: Option<car_auth::CredentialReadStatus>,
89 next: car_auth::CredentialReadStatus,
90) -> bool {
91 let Some(previous) = previous else {
92 return true;
93 };
94 if next.generation != previous.generation {
95 return next.generation > previous.generation;
96 }
97 previous.state == car_auth::CredentialReadStatusState::Pending
98 && next.state != car_auth::CredentialReadStatusState::Pending
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[allow(dead_code)]
103pub struct JsonRpcMessage {
104 #[serde(default)]
105 pub jsonrpc: String,
106 #[serde(default)]
107 pub method: Option<String>,
108 #[serde(default)]
109 pub params: Value,
110 #[serde(default)]
111 pub id: Value,
112 #[serde(default)]
114 pub result: Option<Value>,
115 #[serde(default)]
116 pub error: Option<Value>,
117}
118
119#[derive(Debug, Serialize)]
120pub struct JsonRpcResponse {
121 pub jsonrpc: &'static str,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub result: Option<Value>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub error: Option<JsonRpcError>,
126 pub id: Value,
127}
128
129#[derive(Debug, Serialize)]
130pub struct JsonRpcError {
131 pub code: i32,
132 pub message: String,
133}
134
135impl JsonRpcResponse {
136 pub fn success(id: Value, result: Value) -> Self {
137 Self {
138 jsonrpc: "2.0",
139 result: Some(result),
140 error: None,
141 id,
142 }
143 }
144 pub fn error(id: Value, code: i32, message: &str) -> Self {
145 Self {
146 jsonrpc: "2.0",
147 result: None,
148 error: Some(JsonRpcError {
149 code,
150 message: message.to_string(),
151 }),
152 id,
153 }
154 }
155}
156
157fn auth_required_message() -> String {
168 const PREFIX: &str = "auth required: send `session.auth` with the per-launch token";
169 const SUFFIX: &str = "as the first frame on this connection";
170 match car_home::override_root() {
171 Some(root) => format!(
172 "{PREFIX} from {} {SUFFIX} (CAR_HOME is set, so this daemon's token \
173 lives under its own state root, not the default per-platform directory)",
174 root.join("ai.parslee.car").join("auth-token").display(),
175 ),
176 None => format!(
177 "{PREFIX} from ~/Library/Application Support/ai.parslee.car/auth-token \
178 (macOS), $XDG_RUNTIME_DIR/ai.parslee.car/auth-token (Linux), or \
179 %LOCALAPPDATA%\\ai.parslee.car\\auth-token (Windows) {SUFFIX} \
180 (setting CAR_HOME moves the token under that state root instead)"
181 ),
182 }
183}
184
185pub(crate) fn require_str<'a>(params: &'a Value, field: &str) -> Result<&'a str, String> {
200 params
201 .get(field)
202 .and_then(|v| v.as_str())
203 .ok_or_else(|| format!("invalid params: {field} is required"))
204}
205
206pub(crate) fn opt_str<'a>(params: &'a Value, field: &str) -> Option<&'a str> {
208 params.get(field).and_then(|v| v.as_str())
209}
210
211pub(crate) fn str_or<'a>(params: &'a Value, field: &str, default: &'a str) -> &'a str {
213 params
214 .get(field)
215 .and_then(|v| v.as_str())
216 .unwrap_or(default)
217}
218
219pub(crate) fn typed_params<T: serde::de::DeserializeOwned>(params: &Value) -> Result<T, String> {
222 serde_json::from_value(params.clone()).map_err(|e| format!("invalid params: {e}"))
223}
224
225#[instrument(
230 name = "ws.connection",
231 skip_all,
232 fields(peer = %peer),
233)]
234pub async fn handle_connection(
235 stream: TcpStream,
236 peer: SocketAddr,
237 state: Arc<ServerState>,
238) -> Result<(), Box<dyn std::error::Error>> {
239 let ws_stream = accept_async(stream).await?;
240 let (write, read) = ws_stream.split();
241 run_dispatch(read, Box::pin(write), peer.to_string(), state).await
242}
243
244#[cfg(unix)]
255#[instrument(
256 name = "ws.connection",
257 skip_all,
258 fields(peer = %peer),
259)]
260pub async fn handle_connection_unix(
261 stream: tokio::net::UnixStream,
262 peer: String,
263 state: Arc<ServerState>,
264) -> Result<(), Box<dyn std::error::Error>> {
265 let ws_stream = tokio_tungstenite::accept_async(stream).await?;
266 let (write, read) = ws_stream.split();
267 run_dispatch(read, Box::pin(write), peer, state).await
268}
269
270fn handler_default_deadline_secs() -> u64 {
271 std::env::var("CAR_HANDLER_TIMEOUT")
272 .ok()
273 .and_then(|s| s.trim().parse::<u64>().ok())
274 .unwrap_or(1800)
275}
276
277const DEADLINE_EXEMPT_SUBSTRINGS: &[&str] = &[
319 "stream",
320 "subscribe",
321 "run_loop",
322 "dispatch_turn",
323 "heal.run",
324];
325const DAEMON_OWNED_AUTH_METHODS: &[&str] = &[
329 "auth.authority_hint",
330 "auth.start",
331 "auth.complete",
332 "auth.completion_status",
333 "auth.snapshot",
334 "auth.status",
335 "auth.switch_org",
336 "auth.accounts",
337 "auth.switch_account",
338 "auth.remove_account",
339 "auth.logout",
340];
341const DEADLINE_FAST_SUFFIXES: &[&str] = &[".list", ".get", ".query", ".count"];
347const DEADLINE_FAST_PREFIXES: &[&str] = &["auth."];
348const DEADLINE_FAST_EXACT: &[&str] = &[
349 "agents.health",
350 "memory.admission_table",
353 "memory.evaluate",
354 "memory.intervene",
355 "server.handshake",
356 "server.schema",
357 "session.auth",
358 "session.init",
359];
360const DEADLINE_FAST_SECS: u64 = 180;
361const PROPOSAL_DEADLINE_GRACE_SECS: u64 = 15;
384const DEFAULT_PROPOSAL_MAX_RETRIES: u64 = 3;
390const HANDLER_DEADLINE_ERROR_CODE: i32 = -32004;
394
395fn requires_protocol_handshake(method: &str) -> bool {
400 method == "host.subscribe"
401 || method.starts_with("auth.")
402 || method == "diagnostics.secret_store_activity"
403 || method == "session.clear_halt"
404 || method == "infer.cancel"
405 || method == "infer.deadline"
406 || method == "runs.cancel"
407 || matches!(
408 method,
409 "agent_permissions.set_tool"
410 | "agent_permissions.reset_tool"
411 | "agent_permissions.evaluate_tool"
412 )
413 || method == "feedback.compose_preview"
416 || method == "feedback.submit"
417 || method == "feedback.status"
418 || method == "feedback.list"
419}
420
421fn session_has_capability(session: &crate::session::ClientSession, capability: &str) -> bool {
422 session
423 .negotiated_capabilities
424 .read()
425 .map(|capabilities| capabilities.contains(capability))
426 .unwrap_or(false)
427}
428
429fn require_feedback_capability(session: &crate::session::ClientSession) -> Result<(), String> {
435 if session_has_capability(session, car_proto::FEEDBACK_CAPABILITY) {
436 Ok(())
437 } else {
438 Err(format!(
439 "{} negotiate `{}` as a required or optional capability before calling `feedback.*`",
440 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
441 car_proto::FEEDBACK_CAPABILITY
442 ))
443 }
444}
445
446fn require_agent_tool_override_capability(
447 session: &crate::session::ClientSession,
448) -> Result<(), String> {
449 if session_has_capability(session, car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY) {
450 Ok(())
451 } else {
452 Err(format!(
453 "this operation requires negotiated capability {}",
454 car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY
455 ))
456 }
457}
458
459const KEEPALIVE_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
466const KEEPALIVE_DEAD_AFTER: std::time::Duration = std::time::Duration::from_secs(90); const KEEPALIVE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const DEFAULT_INFER_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
469const AUTH_STATE_RESPONSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(95);
476const AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX: &str =
477 "auth coordination deadline before redemption; safe to retry auth.complete";
478const AUTH_START_RETRY_PREFIX: &str =
479 "auth.start coordination deadline before reservation; safe to retry auth.start serially";
480const AUTH_STATUS_RETRY_PREFIX: &str =
481 "auth.completion_status coordination deadline; retry the proof read serially";
482#[cfg(test)]
487const AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON: std::time::Duration =
488 std::time::Duration::from_secs(480);
489
490fn is_daemon_owned_auth_method(method: &str) -> bool {
491 DAEMON_OWNED_AUTH_METHODS.contains(&method)
492}
493
494fn server_deadline_for(method: &str, params: &Value) -> Option<std::time::Duration> {
495 if DEADLINE_EXEMPT_SUBSTRINGS
496 .iter()
497 .any(|s| method.contains(s))
498 {
499 return None;
500 }
501 let fast = DEADLINE_FAST_SUFFIXES.iter().any(|s| method.ends_with(s))
502 || DEADLINE_FAST_PREFIXES.iter().any(|p| method.starts_with(p))
503 || DEADLINE_FAST_EXACT.contains(&method);
504 let secs = if fast {
505 DEADLINE_FAST_SECS
506 } else if method == "proposal.submit" {
507 proposal_submit_deadline_secs(params)
508 } else {
509 handler_default_deadline_secs()
510 };
511 Some(std::time::Duration::from_secs(secs))
512}
513
514fn inference_total_timeout() -> std::time::Duration {
515 std::env::var("CAR_INFER_TIMEOUT_SECS")
516 .ok()
517 .and_then(|value| value.parse::<u64>().ok())
518 .map(std::time::Duration::from_secs)
519 .unwrap_or(DEFAULT_INFER_TOTAL_TIMEOUT)
520}
521
522fn proposal_submit_deadline_secs(params: &Value) -> u64 {
542 let default_secs = handler_default_deadline_secs();
543 match derive_proposal_budget_secs(params) {
544 0 => default_secs,
545 derived => default_secs.max(derived.saturating_add(PROPOSAL_DEADLINE_GRACE_SECS)),
546 }
547}
548
549fn derive_proposal_budget_secs(params: &Value) -> u64 {
568 let Some(actions) = params
569 .get("proposal")
570 .and_then(|p| p.get("actions"))
571 .and_then(|a| a.as_array())
572 else {
573 return 0;
574 };
575 if actions.is_empty() {
576 return 0;
577 }
578
579 let mut total_ms: u64 = 0;
582 for action in actions {
583 let budget_ms = action
584 .get("timeout_ms")
585 .and_then(|v| v.as_u64())
586 .unwrap_or(crate::session::DEFAULT_TOOL_TIMEOUT_MS);
587 total_ms =
588 total_ms.saturating_add(budget_ms.saturating_mul(proposal_action_attempts(action)));
589 }
590
591 total_ms.div_ceil(1000)
594}
595
596fn proposal_action_attempts(action: &Value) -> u64 {
604 let is_retry = action
605 .get("failure_behavior")
606 .and_then(|v| v.as_str())
607 .map(|s| s.eq_ignore_ascii_case("retry"))
608 .unwrap_or(false);
609
610 if is_retry {
611 action
612 .get("max_retries")
613 .and_then(|v| v.as_u64())
614 .unwrap_or(DEFAULT_PROPOSAL_MAX_RETRIES)
615 .saturating_add(1)
616 } else {
617 1
618 }
619}
620
621#[derive(Debug)]
622enum HandlerFailure {
623 Dispatch(String),
624 MethodNotFound(String),
625 InvalidParams(String),
626 Deadline(String),
627 ContentRefused(String),
631 ProtocolCapability(String),
632 CatalogPrecondition(String),
633 RunOwnershipConflict(String),
634 RunTraceCorruption(String),
635}
636
637impl HandlerFailure {
638 fn from_dispatch(message: String) -> Self {
650 if message.starts_with("unknown method:") {
651 Self::MethodNotFound(message)
652 } else if message.starts_with("invalid params:") {
653 Self::InvalidParams(message)
654 } else if message.starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX) {
655 Self::ContentRefused(message)
656 } else if message.starts_with(car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX) {
657 Self::ProtocolCapability(message)
658 } else if message.starts_with(car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX) {
659 Self::CatalogPrecondition(message)
660 } else if message.starts_with(car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX) {
661 Self::RunOwnershipConflict(message)
662 } else if message.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
663 Self::RunTraceCorruption(message)
664 } else {
665 Self::Dispatch(message)
666 }
667 }
668
669 fn json_rpc_code(&self) -> i32 {
670 match self {
671 Self::Dispatch(_) => -32603,
672 Self::MethodNotFound(_) => -32601,
673 Self::InvalidParams(_) => -32602,
674 Self::Deadline(_) => HANDLER_DEADLINE_ERROR_CODE,
675 Self::ContentRefused(_) => car_proto::CONTENT_REFUSED_ERROR_CODE,
676 Self::ProtocolCapability(_) => car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
677 Self::CatalogPrecondition(_) => car_proto::CATALOG_PRECONDITION_MISMATCH_ERROR_CODE,
678 Self::RunOwnershipConflict(_) => car_proto::RUN_OWNERSHIP_CONFLICT_ERROR_CODE,
679 Self::RunTraceCorruption(_) => car_proto::RUN_TRACE_CORRUPTION_ERROR_CODE,
680 }
681 }
682
683 fn message(&self) -> &str {
684 match self {
685 Self::Dispatch(message)
686 | Self::MethodNotFound(message)
687 | Self::InvalidParams(message)
688 | Self::Deadline(message)
689 | Self::ContentRefused(message)
690 | Self::ProtocolCapability(message)
691 | Self::CatalogPrecondition(message)
692 | Self::RunOwnershipConflict(message)
693 | Self::RunTraceCorruption(message) => message,
694 }
695 }
696}
697
698fn json_rpc_response_for_handler_result(
699 id: Value,
700 result: Result<Value, HandlerFailure>,
701) -> JsonRpcResponse {
702 match result {
703 Ok(value) => JsonRpcResponse::success(id, value),
704 Err(error) => JsonRpcResponse::error(id, error.json_rpc_code(), error.message()),
705 }
706}
707
708async fn dispatch_with_server_deadline<F>(
709 method: &str,
710 params: &Value,
711 dispatch: F,
712) -> Result<Value, HandlerFailure>
713where
714 F: std::future::Future<Output = Result<Value, String>>,
715{
716 match server_deadline_for(method, params) {
717 Some(deadline) => match tokio::time::timeout(deadline, dispatch).await {
718 Ok(result) => result.map_err(HandlerFailure::from_dispatch),
719 Err(_) => Err(HandlerFailure::Deadline(format!(
720 "handler '{}' exceeded the server-side deadline of {}s and was abandoned",
721 method,
722 deadline.as_secs()
723 ))),
724 },
725 None => dispatch.await.map_err(HandlerFailure::from_dispatch),
726 }
727}
728
729async fn await_daemon_owned_auth_result(
730 method: &str,
731 result_rx: tokio::sync::oneshot::Receiver<Result<Value, HandlerFailure>>,
732) -> Result<Value, HandlerFailure> {
733 let receive = async {
734 match result_rx.await {
735 Ok(result) => result,
736 Err(_) => Err(HandlerFailure::Dispatch(format!(
737 "daemon-owned auth operation `{method}` stopped before publishing a result"
738 ))),
739 }
740 };
741 if !matches!(
742 method,
743 "auth.start" | "auth.complete" | "auth.completion_status"
744 ) {
745 return receive.await;
746 }
747
748 tokio::time::timeout(AUTH_STATE_RESPONSE_DEADLINE, receive)
749 .await
750 .unwrap_or_else(|_| match method {
751 "auth.complete" => Err(HandlerFailure::Deadline(format!(
752 "auth.complete durable claim outcome is ambiguous after {}s; do not replay the \
753 authorization code and reconcile the exact attempt through auth.completion_status",
754 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
755 ))),
756 "auth.start" => Err(HandlerFailure::Deadline(format!(
757 "auth.start reservation outcome is ambiguous after {}s; the daemon-owned \
758 reservation continues, so do not overlap another auth.start",
759 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
760 ))),
761 _ => Err(HandlerFailure::Deadline(format!(
762 "handler 'auth.completion_status' exceeded its {}s response deadline; \
763 daemon-owned reconciliation continues and a later proof read is safe",
764 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
765 ))),
766 })
767}
768
769fn classified_auth_failure(method: &str, error: car_auth::AuthOperationError) -> HandlerFailure {
770 match error {
771 car_auth::AuthOperationError::CoordinationDeadline(detail) => {
772 let message = match method {
773 "auth.complete" => format!("{AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX}: {detail}"),
774 "auth.start" => format!("{AUTH_START_RETRY_PREFIX}: {detail}"),
775 "auth.completion_status" => format!("{AUTH_STATUS_RETRY_PREFIX}: {detail}"),
776 _ => format!(
777 "{method} coordination deadline before state work; retry the request safely: \
778 {detail}"
779 ),
780 };
781 HandlerFailure::Deadline(message)
782 }
783 car_auth::AuthOperationError::Terminal(message) => HandlerFailure::from_dispatch(message),
784 }
785}
786
787#[cfg(test)]
788mod assistant_identity_tests {
789 use super::{
790 assistant_identity_wire, handle_assistant_identity_set, JsonRpcMessage, ServerState,
791 };
792 use car_identity::{AssistantIdentity, FocusArea, IdentityStore};
793 use car_memgine::{MemKind, MemgineEngine};
794 use serde_json::json;
795 use std::sync::Arc;
796
797 fn identity_value(engine: &MemgineEngine) -> Option<String> {
798 engine
799 .graph
800 .inner
801 .node_indices()
802 .filter_map(|nix| engine.graph.inner.node_weight(nix))
803 .find(|node| node.kind == MemKind::Identity && node.key == "identity")
804 .map(|node| node.value.clone())
805 }
806
807 #[test]
808 fn the_wire_shape_carries_everything_a_host_needs_to_address_the_assistant() {
809 let identity = AssistantIdentity::new("Jarvis")
815 .expect("valid name")
816 .with_spellings(vec!["jervis".into()])
817 .expect("valid spellings");
818 let wire = assistant_identity_wire(&identity);
819
820 assert_eq!(wire["name"], "Jarvis");
821 assert_eq!(wire["spellings"][0], "jervis");
822 assert_eq!(wire["brand"], car_identity::BRAND_NAME);
823 assert!(wire["role"].is_null());
824 assert_eq!(wire["focus_areas"], json!([]));
825 assert_eq!(wire["apps"], json!([]));
826
827 let aliases: Vec<String> =
828 serde_json::from_value(wire["aliases"].clone()).expect("aliases");
829 for expected in ["jarvis", "hey jarvis", "jervis", "hey jervis"] {
830 assert!(
831 aliases.contains(&expected.to_string()),
832 "{expected} missing from {aliases:?}"
833 );
834 }
835 }
836
837 #[tokio::test]
838 async fn profile_rpc_persists_prompts_and_rehydrates_the_identity_memory_node() {
839 let tmp = tempfile::TempDir::new().expect("scratch root");
840 let store = IdentityStore::with_base_dir(tmp.path());
841 let engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
842 let config = crate::session::ServerStateConfig::new(tmp.path().join("journals"))
843 .with_shared_memgine(engine.clone())
844 .with_identity_store(store.clone())
845 .with_approval_journal(tmp.path().join("approvals.jsonl"))
846 .with_trajectory_dir(tmp.path().join("trajectories"));
847 let state = Arc::new(ServerState::with_config_and_isolated_feedback_diagnostics(
848 config,
849 tmp.path().join("models"),
850 tmp.path().join("huggingface"),
851 ));
852 let session = state
853 .create_session(
854 "identity-profile-test",
855 Arc::new(crate::session::WsChannel::test_stub()),
856 )
857 .await
858 .expect("session");
859 let request = JsonRpcMessage {
860 jsonrpc: "2.0".into(),
861 method: Some("assistant.identity.set".into()),
862 params: json!({
863 "user_name": "Dana",
864 "role": "Customer success lead",
865 "focus_areas": ["email", "research"],
866 "apps": ["Outlook", "Notion"]
867 }),
868 id: json!(1),
869 result: None,
870 error: None,
871 };
872
873 let response = handle_assistant_identity_set(&request, &session, &state)
874 .await
875 .expect("profile write through daemon handler");
876 assert_eq!(response["role"], "Customer success lead");
877 assert_eq!(response["focus_areas"], json!(["email", "research"]));
878 let persisted = std::fs::read_to_string(store.path()).expect("identity.json");
879 assert!(persisted.contains("\"role\": \"Customer success lead\""));
880 assert!(persisted.contains("\"focus_areas\""));
881 assert!(persisted.contains("\"apps\""));
882 let live_engine = engine.lock().await;
883 let live_identity = identity_value(&live_engine).expect("live identity node");
884 drop(live_engine);
885 assert!(live_identity.contains("Role: Customer success lead"));
886
887 drop(state);
891 let restarted_engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
892 let restarted_config =
893 crate::session::ServerStateConfig::new(tmp.path().join("journals-restarted"))
894 .with_shared_memgine(restarted_engine.clone())
895 .with_identity_store(store.clone())
896 .with_approval_journal(tmp.path().join("approvals-restarted.jsonl"))
897 .with_trajectory_dir(tmp.path().join("trajectories-restarted"));
898 let _restarted = ServerState::with_config_and_isolated_feedback_diagnostics(
899 restarted_config,
900 tmp.path().join("models-restarted"),
901 tmp.path().join("huggingface-restarted"),
902 );
903 let restarted_guard = restarted_engine.lock().await;
904 let reloaded_identity =
905 identity_value(&restarted_guard).expect("identity node after restart");
906 assert!(reloaded_identity.contains("Role: Customer success lead"));
907 assert!(reloaded_identity.contains("Focus areas: email, research"));
908
909 let loaded = store.load().expect("persisted identity reload");
910 assert_eq!(
911 loaded.focus_areas,
912 vec![FocusArea::Email, FocusArea::Research]
913 );
914 let prompt = crate::assistant::prompt::chat_prompt(&loaded, "local host", &[]);
915 assert!(prompt.contains("- Role: Customer success lead"));
916 assert!(prompt.contains("- What they want help with: email, research"));
917 }
918
919 #[test]
920 fn the_brand_travels_alongside_the_name_never_instead_of_it() {
921 let wire = assistant_identity_wire(&AssistantIdentity::new("Friday").unwrap());
924 assert_eq!(wire["name"], "Friday");
925 assert_eq!(wire["brand"], "Parslee Core");
926 }
927}
928
929#[cfg(test)]
930mod deadline_policy_tests {
931 use super::{
932 agent_scope_implicitly_allows, auth_completion_network_with_deadline,
933 auth_completion_value, await_daemon_owned_auth_result, classified_auth_failure,
934 dispatch_with_server_deadline, handler_default_deadline_secs, inference_dispatch_error,
935 is_daemon_owned_auth_method, json_rpc_response_for_handler_result,
936 requires_protocol_handshake, select_auth_start_api_base, server_deadline_for,
937 stream_dispatch_error, HandlerFailure, AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX,
938 AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON, AUTH_START_RETRY_PREFIX,
939 AUTH_STATE_RESPONSE_DEADLINE, AUTH_STATUS_RETRY_PREFIX, DAEMON_OWNED_AUTH_METHODS,
940 DEADLINE_FAST_SECS, DEFAULT_PROPOSAL_MAX_RETRIES, HANDLER_DEADLINE_ERROR_CODE,
941 PROPOSAL_DEADLINE_GRACE_SECS,
942 };
943 use serde_json::{json, Value};
944 use std::time::Duration;
945
946 #[test]
947 fn auth_start_base_precedence_is_passive_and_does_not_read_secrets() {
948 const SENTINEL: &str = "CAR_AUTH_START_BASE_CONTRACT_CHILD";
949 if std::env::var_os(SENTINEL).is_none() {
950 let status = std::process::Command::new(std::env::current_exe().unwrap())
951 .arg("--exact")
952 .arg(
953 "handler::deadline_policy_tests::auth_start_base_precedence_is_passive_and_does_not_read_secrets",
954 )
955 .arg("--nocapture")
956 .env(SENTINEL, "1")
957 .status()
958 .expect("spawn isolated auth.start base contract");
959 assert!(status.success(), "isolated auth.start contract failed");
960 return;
961 }
962
963 let before = car_secrets::secret_store_activity();
964
965 assert_eq!(
966 select_auth_start_api_base(
967 &json!({ "api_base": " https://explicit.example.test/ " }),
968 Some("https://environment.example.test/"),
969 ),
970 "https://explicit.example.test"
971 );
972 assert_eq!(
973 select_auth_start_api_base(
974 &json!({ "api_base": " " }),
975 Some(" https://environment.example.test/ "),
976 ),
977 "https://environment.example.test"
978 );
979 assert_eq!(
980 select_auth_start_api_base(&json!({}), Some(" ")),
981 car_auth::DEFAULT_API_BASE
982 );
983 assert_eq!(car_secrets::secret_store_activity(), before);
984 }
985
986 fn action(timeout_ms: u64, failure_behavior: &str) -> Value {
988 json!({
989 "id": "a1",
990 "type": "tool_call",
991 "tool": "shell",
992 "parameters": {},
993 "failure_behavior": failure_behavior,
994 "timeout_ms": timeout_ms,
995 })
996 }
997
998 fn submit_params(actions: Vec<Value>) -> Value {
999 json!({ "proposal": { "id": "p1", "goal": "g", "actions": actions } })
1000 }
1001
1002 #[test]
1008 fn proposal_submit_deadline_covers_a_retried_actions_full_budget() {
1009 let params = submit_params(vec![action(600_000, "retry")]);
1010 let deadline =
1011 server_deadline_for("proposal.submit", ¶ms).expect("proposal.submit is bounded");
1012
1013 assert!(
1015 deadline > Duration::from_secs(2400),
1016 "must outlast the executor's own 2400s worst case, got {deadline:?}"
1017 );
1018 assert!(
1019 deadline > Duration::from_secs(1800),
1020 "must RAISE the flat 1800s generous default, got {deadline:?}"
1021 );
1022 assert_eq!(
1023 deadline,
1024 Duration::from_secs(2400 + PROPOSAL_DEADLINE_GRACE_SECS)
1025 .max(Duration::from_secs(handler_default_deadline_secs())),
1026 "derived deadline is the action budget plus the server transport grace"
1027 );
1028 }
1029
1030 #[test]
1034 fn proposal_submit_deadline_covers_chained_action_budgets() {
1035 let params = submit_params(vec![
1036 action(900_000, "abort"),
1037 action(900_000, "abort"),
1038 action(300_000, "skip"),
1039 ]);
1040 let sum = Duration::from_secs(900 + 900 + 300);
1041 let deadline =
1042 server_deadline_for("proposal.submit", ¶ms).expect("proposal.submit is bounded");
1043
1044 assert!(
1045 deadline > sum,
1046 "chained budgets summing to {sum:?} must not be reaped, got {deadline:?}"
1047 );
1048 assert!(
1049 deadline > Duration::from_secs(1800),
1050 "a 2100s chain must RAISE the flat 1800s default, got {deadline:?}"
1051 );
1052 assert_eq!(
1053 deadline,
1054 (sum + Duration::from_secs(PROPOSAL_DEADLINE_GRACE_SECS))
1055 .max(Duration::from_secs(handler_default_deadline_secs()))
1056 );
1057 }
1058
1059 #[test]
1063 fn a_short_proposal_keeps_exactly_the_generous_default() {
1064 let params = submit_params(vec![action(5_000, "abort")]);
1065 assert_eq!(
1066 server_deadline_for("proposal.submit", ¶ms),
1067 Some(Duration::from_secs(handler_default_deadline_secs())),
1068 "5s of budget must not lower the deadline below the generous default"
1069 );
1070 }
1071
1072 #[test]
1076 fn actions_without_an_explicit_budget_use_the_daemon_tool_default() {
1077 let params = json!({
1078 "proposal": {
1079 "actions": [
1080 { "id": "a1", "type": "tool_call", "failure_behavior": "retry" },
1081 { "id": "a2", "type": "tool_call", "failure_behavior": "retry" },
1082 ]
1083 }
1084 });
1085 let expected = 2 * 4 * (crate::session::DEFAULT_TOOL_TIMEOUT_MS / 1000);
1087 assert_eq!(
1088 server_deadline_for("proposal.submit", ¶ms),
1089 Some(
1090 Duration::from_secs(expected + PROPOSAL_DEADLINE_GRACE_SECS)
1091 .max(Duration::from_secs(handler_default_deadline_secs()))
1092 )
1093 );
1094 }
1095
1096 #[test]
1102 fn server_grace_is_strictly_inside_the_client_transport_grace() {
1103 assert!(
1104 PROPOSAL_DEADLINE_GRACE_SECS < 30,
1105 "server grace must fire before the client's 30s PROPOSAL_TRANSPORT_GRACE_SECS"
1106 );
1107 }
1108
1109 #[test]
1112 fn derived_retry_default_matches_car_ir() {
1113 let action: car_ir::Action = serde_json::from_value(json!({
1114 "id": "a1",
1115 "type": "tool_call",
1116 "tool": "shell",
1117 }))
1118 .expect("minimal action should deserialize");
1119 assert_eq!(u64::from(action.max_retries), DEFAULT_PROPOSAL_MAX_RETRIES);
1120 }
1121
1122 #[test]
1123 fn schema_discovery_is_available_before_protocol_negotiation() {
1124 assert!(!requires_protocol_handshake("server.schema"));
1125 assert!(agent_scope_implicitly_allows("server.schema"));
1126 }
1127
1128 #[test]
1129 fn classifies_methods_into_expected_buckets() {
1130 let default = Duration::from_secs(handler_default_deadline_secs());
1131 let fast = Duration::from_secs(DEADLINE_FAST_SECS);
1132
1133 for m in [
1135 "infer_stream",
1136 "runs.subscribe",
1137 "scheduler.run_loop",
1138 "voice.dispatch_turn",
1139 "voice.tts_stream.start",
1140 ] {
1141 assert_eq!(
1142 server_deadline_for(m, &Value::Null),
1143 None,
1144 "{m} should be exempt"
1145 );
1146 }
1147 for method in DAEMON_OWNED_AUTH_METHODS {
1148 assert!(
1149 is_daemon_owned_auth_method(method),
1150 "{method} must bypass connection-owned dispatch"
1151 );
1152 }
1153 for m in [
1155 "memory.query",
1156 "memory.evaluate",
1157 "memory.intervene",
1158 "agents.list",
1159 "meeting.get",
1160 "runs.list",
1161 "agents.health",
1162 "server.handshake",
1163 "server.schema",
1164 ] {
1165 assert_eq!(
1166 server_deadline_for(m, &Value::Null),
1167 Some(fast),
1168 "{m} should be fast"
1169 );
1170 }
1171 for m in [
1176 "memory.persist",
1177 "memory.add_fact",
1178 "memory.update_status",
1179 "memory.maintain",
1180 "memory.save_knowledge",
1181 "memory.save_procedural",
1182 "memory.delete",
1183 "memory.consolidate",
1184 "proposal.submit",
1185 "workflow.run",
1186 "multi.swarm",
1187 ] {
1188 assert_eq!(
1189 server_deadline_for(m, &Value::Null),
1190 Some(default),
1191 "{m} should take the generous default"
1192 );
1193 }
1194 }
1195
1196 #[tokio::test(start_paused = true)]
1197 async fn bounded_handler_deadline_has_a_distinct_json_rpc_code() {
1198 let task = tokio::spawn(async {
1199 dispatch_with_server_deadline(
1200 "memory.query",
1201 &Value::Null,
1202 std::future::pending::<Result<Value, String>>(),
1203 )
1204 .await
1205 });
1206
1207 tokio::time::advance(Duration::from_secs(DEADLINE_FAST_SECS)).await;
1208 let failure = task
1209 .await
1210 .expect("deadline task should join")
1211 .expect_err("pending handler should exceed its deadline");
1212
1213 assert!(matches!(failure, HandlerFailure::Deadline(_)));
1214 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1215 }
1216
1217 #[test]
1218 fn auth_state_response_bound_exceeds_every_serial_storage_component() {
1219 let component_total = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1220 + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1221 + car_auth::AUTH_STATE_OPERATION_BUDGET
1222 + car_auth::AUTH_STATE_OPERATION_BUDGET;
1223 assert!(
1224 AUTH_STATE_RESPONSE_DEADLINE > component_total,
1225 "auth state response bound must include scheduling margin"
1226 );
1227 }
1228
1229 #[test]
1230 fn one_safe_preclaim_retry_and_proof_fit_the_host_reconciliation_horizon() {
1231 let first_safe_retry_budget =
1236 car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT + car_auth::AUTH_PROCESS_LOCK_TIMEOUT;
1237 let pre_lease_claim_budget = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1238 + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1239 + car_auth::AUTH_STATE_OPERATION_BUDGET;
1240 let hydration_reserve = Duration::from_secs(30);
1241 let total_budget = first_safe_retry_budget
1242 + pre_lease_claim_budget
1243 + car_auth::LOGIN_ATTEMPT_WORKER_TTL
1244 + AUTH_STATE_RESPONSE_DEADLINE
1245 + hydration_reserve;
1246
1247 assert!(
1248 total_budget < AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON,
1249 "one safe preclaim retry must still expire, prove, and hydrate before the host stops"
1250 );
1251 }
1252
1253 #[tokio::test(start_paused = true)]
1254 async fn proof_response_timeout_reports_that_daemon_reconciliation_continues() {
1255 let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1256 let task = tokio::spawn(await_daemon_owned_auth_result(
1257 "auth.completion_status",
1258 result_rx,
1259 ));
1260
1261 tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1262 let failure = task
1263 .await
1264 .expect("proof response task should join")
1265 .expect_err("an unpublished proof must hit its explicit response bound");
1266
1267 assert!(matches!(failure, HandlerFailure::Deadline(_)));
1268 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1269 assert!(failure.message().contains("reconciliation continues"));
1270 }
1271
1272 #[tokio::test(start_paused = true)]
1273 async fn auth_start_and_complete_have_explicit_state_response_bounds() {
1274 for method in ["auth.start", "auth.complete"] {
1275 let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1276 let task = tokio::spawn(await_daemon_owned_auth_result(method, result_rx));
1277
1278 tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1279 let failure = task
1280 .await
1281 .expect("auth response task should join")
1282 .expect_err("unpublished auth state response must hit its explicit bound");
1283
1284 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1285 assert!(failure.message().contains("95s"), "{method}: {failure:?}");
1286 }
1287 }
1288
1289 #[test]
1290 fn coordination_deadlines_are_retryable_but_terminal_state_errors_are_not() {
1291 for (method, prefix) in [
1292 ("auth.start", AUTH_START_RETRY_PREFIX),
1293 ("auth.complete", AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX),
1294 ("auth.completion_status", AUTH_STATUS_RETRY_PREFIX),
1295 ] {
1296 let failure = classified_auth_failure(
1297 method,
1298 car_auth::AuthOperationError::CoordinationDeadline("contended".into()),
1299 );
1300 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1301 assert!(failure.message().starts_with(prefix), "{failure:?}");
1302 }
1303
1304 let failure = classified_auth_failure(
1305 "auth.completion_status",
1306 car_auth::AuthOperationError::Terminal("corrupt state".into()),
1307 );
1308 assert_eq!(failure.json_rpc_code(), -32603);
1309 assert_eq!(failure.message(), "corrupt state");
1310 }
1311
1312 #[tokio::test(start_paused = true)]
1313 async fn auth_completion_network_phase_has_an_injectable_total_deadline() {
1314 let task = tokio::spawn(auth_completion_network_with_deadline(
1315 Duration::from_secs(1),
1316 std::future::pending::<Result<(), String>>(),
1317 ));
1318
1319 tokio::time::advance(Duration::from_secs(1)).await;
1320 let error = task
1321 .await
1322 .expect("network deadline task should join")
1323 .expect_err("stalled auth network work must time out");
1324
1325 assert_eq!(
1326 error,
1327 "Parslee login network phase timed out after 1s; no credentials were saved"
1328 );
1329 }
1330
1331 #[test]
1332 fn protocol_routing_failures_use_standard_json_rpc_codes() {
1333 let unknown =
1334 HandlerFailure::from_dispatch("unknown method: future.namespace.call".to_string());
1335 assert!(matches!(unknown, HandlerFailure::MethodNotFound(_)));
1336 assert_eq!(unknown.json_rpc_code(), -32601);
1337
1338 let invalid = HandlerFailure::from_dispatch("invalid params: key is required".to_string());
1339 assert!(matches!(invalid, HandlerFailure::InvalidParams(_)));
1340 assert_eq!(invalid.json_rpc_code(), -32602);
1341
1342 let wire =
1343 serde_json::to_value(json_rpc_response_for_handler_result(json!(7), Err(invalid)))
1344 .expect("serialize response");
1345 assert_eq!(wire["error"]["code"], -32602);
1346 assert_eq!(wire["error"]["message"], "invalid params: key is required");
1347 }
1348
1349 #[tokio::test]
1350 async fn ordinary_handler_failure_remains_internal_error() {
1351 let failure = dispatch_with_server_deadline(
1352 "auth.complete",
1353 &Value::Null,
1354 std::future::ready(Err("token exchange failed".to_string())),
1355 )
1356 .await
1357 .expect_err("handler failure should propagate");
1358
1359 assert!(matches!(failure, HandlerFailure::Dispatch(_)));
1360 assert_eq!(failure.json_rpc_code(), -32603);
1361 }
1362
1363 #[test]
1364 fn a_gateway_content_refusal_survives_the_trip_to_its_own_error_code() {
1365 let refusal = car_inference::InferenceError::ContentRefused {
1366 provider: "parslee".to_string(),
1367 kind: Some("invalid_request_error".to_string()),
1368 code: Some("content_policy_violation".to_string()),
1369 message: "The response was filtered due to the prompt triggering the content \
1370 management policy"
1371 .to_string(),
1372 };
1373
1374 let failure = HandlerFailure::from_dispatch(inference_dispatch_error(&refusal));
1377
1378 assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1379 assert_eq!(
1380 failure.json_rpc_code(),
1381 car_proto::CONTENT_REFUSED_ERROR_CODE
1382 );
1383 assert!(
1386 failure.message().contains("code=content_policy_violation"),
1387 "{failure:?}"
1388 );
1389 assert!(
1390 failure
1391 .message()
1392 .starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
1393 "{failure:?}"
1394 );
1395 }
1396
1397 #[test]
1398 fn ordinary_inference_failures_are_not_classified_as_content_refusals() {
1399 let generic: Vec<car_inference::InferenceError> = vec![
1404 car_inference::InferenceError::InferenceFailed("managed inference failed".to_string()),
1405 car_inference::InferenceError::InferenceFailed(
1406 "content refused by the operator's local policy".to_string(),
1407 ),
1408 car_inference::InferenceError::ModelNotFound("gpt-5.6-sol".to_string()),
1409 car_inference::InferenceError::TokenizationError("bad utf-8 boundary".to_string()),
1410 ];
1411
1412 assert_eq!(
1415 generic[0].to_string(),
1416 "inference failed: managed inference failed"
1417 );
1418
1419 for error in &generic {
1420 let failure = HandlerFailure::from_dispatch(inference_dispatch_error(error));
1421 assert!(
1422 matches!(failure, HandlerFailure::Dispatch(_)),
1423 "must stay a generic dispatch failure: {failure:?}"
1424 );
1425 assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1426 }
1427 }
1428
1429 #[test]
1430 fn a_refusal_that_lands_mid_stream_reaches_the_same_error_code() {
1431 let flattened = "The response was filtered due to the prompt triggering the content \
1434 management policy (type=invalid_request_error, \
1435 code=content_policy_violation)";
1436
1437 let failure = HandlerFailure::from_dispatch(stream_dispatch_error(flattened.to_string()));
1438
1439 assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1440 assert_eq!(
1441 failure.json_rpc_code(),
1442 car_proto::CONTENT_REFUSED_ERROR_CODE
1443 );
1444 assert!(failure.message().contains(flattened), "{failure:?}");
1446 }
1447
1448 #[test]
1449 fn an_untagged_stream_failure_is_not_promoted_to_a_refusal() {
1450 for plain in [
1454 "managed inference failed",
1455 "upstream connection reset",
1456 "the model discussed content policy at length",
1458 "boom (type=server_error, code=internal)",
1460 ] {
1461 let failure = HandlerFailure::from_dispatch(stream_dispatch_error(plain.to_string()));
1462 assert!(
1463 matches!(failure, HandlerFailure::Dispatch(_)),
1464 "must stay generic: {failure:?}"
1465 );
1466 assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1467 }
1468 }
1469
1470 #[test]
1471 fn a_content_refusal_is_distinguishable_from_a_handler_deadline() {
1472 let refusal = HandlerFailure::from_dispatch(inference_dispatch_error(
1473 &car_inference::InferenceError::ContentRefused {
1474 provider: "parslee".to_string(),
1475 kind: None,
1476 code: None,
1477 message: "declined".to_string(),
1478 },
1479 ));
1480 let deadline = HandlerFailure::Deadline("handler deadline".to_string());
1481
1482 assert_eq!(
1483 refusal.json_rpc_code(),
1484 car_proto::CONTENT_REFUSED_ERROR_CODE
1485 );
1486 assert_eq!(deadline.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1487 assert_ne!(refusal.json_rpc_code(), deadline.json_rpc_code());
1488 assert_ne!(refusal.json_rpc_code(), -32603);
1489 }
1490
1491 #[test]
1492 fn deadline_failure_serializes_as_the_actual_json_rpc_envelope() {
1493 let response = json_rpc_response_for_handler_result(
1494 json!(42),
1495 Err(HandlerFailure::Deadline("handler deadline".to_string())),
1496 );
1497 let wire = serde_json::to_value(response).expect("serialize response");
1498
1499 assert_eq!(
1500 wire,
1501 json!({
1502 "jsonrpc": "2.0",
1503 "error": {
1504 "code": HANDLER_DEADLINE_ERROR_CODE,
1505 "message": "handler deadline",
1506 },
1507 "id": 42,
1508 })
1509 );
1510 }
1511
1512 #[test]
1513 fn attempt_completion_wire_value_carries_the_generation_and_session() {
1514 let record = car_auth::AuthCompletionRecord {
1515 attempt_id: "attempt-7".to_string(),
1516 generation: 19,
1517 account_id: Some("account-b".to_string()),
1518 session: Some(r#"{"Account":{"Id":"account-b"}}"#.to_string()),
1519 };
1520
1521 assert_eq!(
1522 auth_completion_value(&record),
1523 json!({
1524 "state": "complete",
1525 "attempt_id": "attempt-7",
1526 "generation": 19,
1527 "account_id": "account-b",
1528 "session": {"Account": {"Id": "account-b"}},
1529 })
1530 );
1531 }
1532}
1533
1534#[instrument(
1544 name = "ws.dispatch",
1545 skip_all,
1546 fields(client_id = tracing::field::Empty, peer = %peer),
1547)]
1548pub async fn run_dispatch<R>(
1549 mut read: R,
1550 write: crate::session::WsSink,
1551 peer: String,
1552 state: Arc<ServerState>,
1553) -> Result<(), Box<dyn std::error::Error>>
1554where
1555 R: futures::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
1556 + Unpin
1557 + Send,
1558{
1559 let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
1560 tracing::Span::current().record("client_id", client_id.as_str());
1561
1562 info!("New connection from {}", peer);
1563
1564 let channel = Arc::new(WsChannel {
1565 write: Mutex::new(write),
1566 pending: Mutex::new(HashMap::new()),
1567 active_actions: Mutex::new(HashMap::new()),
1568 next_id: AtomicU64::new(1),
1569 });
1570
1571 let session = match state.create_session(&client_id, channel.clone()).await {
1576 Ok(session) => session,
1577 Err(e) => {
1578 error!("refusing connection from {}: {}", peer, e);
1579 return Err(e.into());
1580 }
1581 };
1582
1583 let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1592
1593 let mut openrouter_updates = crate::openrouter_auth::subscribe_authority_updates();
1599 let openrouter_session = session.clone();
1600 let openrouter_auth_required = state.auth_token.get().is_some();
1601 conn_tasks.spawn(async move {
1602 loop {
1603 if openrouter_updates.changed().await.is_err() {
1604 break;
1605 }
1606 if openrouter_auth_required
1607 && !openrouter_session
1608 .authenticated
1609 .load(std::sync::atomic::Ordering::Acquire)
1610 {
1611 continue;
1612 }
1613 let Some(status) = openrouter_updates.borrow_and_update().clone() else {
1614 continue;
1615 };
1616 let notification = serde_json::json!({
1617 "jsonrpc": "2.0",
1618 "method": "openrouter.auth.event",
1619 "params": status,
1620 });
1621 let Ok(text) = serde_json::to_string(¬ification) else {
1622 continue;
1623 };
1624 if openrouter_session
1625 .channel
1626 .write
1627 .lock()
1628 .await
1629 .send(Message::Text(text.into()))
1630 .await
1631 .is_err()
1632 {
1633 break;
1634 }
1635 }
1636 });
1637
1638 let credential_session = session.clone();
1644 let credential_transport_auth_required = state.auth_token.get().is_some();
1645 let credential_host_role_required = state.host_token.get().is_some();
1646 let (credential_activation_tx, mut credential_activation_rx) =
1647 tokio::sync::watch::channel(false);
1648 let (credential_ready_tx, credential_ready_rx) = tokio::sync::watch::channel(false);
1649 let (credential_lag_tx, mut credential_lag_rx) = tokio::sync::oneshot::channel();
1650 conn_tasks.spawn(async move {
1651 pause_credential_fanout_for_test().await;
1652 let mut credential_lag_tx = Some(credential_lag_tx);
1653
1654 while !credential_event_is_eligible(
1661 &credential_session,
1662 credential_transport_auth_required,
1663 credential_host_role_required,
1664 ) {
1665 if credential_activation_rx.changed().await.is_err() {
1666 return;
1667 }
1668 }
1669
1670 let car_auth::CredentialReadEventHandoff {
1676 snapshot,
1677 events: mut credential_events,
1678 } = car_auth::subscribe_credential_read_event_handoff();
1679 pause_credential_handoff_for_test().await;
1680 let mut reconciled = snapshot.into_iter();
1681 let mut last_status = None;
1682 let _ = credential_ready_tx.send(true);
1683 loop {
1684 let status = match reconciled.next() {
1685 Some(status) => status,
1686 None => {
1687 let closed = credential_events.closed();
1688 tokio::select! {
1689 biased;
1690 reason = closed => {
1691 if let Some(sender) = credential_lag_tx.take() {
1692 let _ = sender.send(reason);
1693 }
1694 break;
1695 }
1696 event = credential_events.recv() => match event {
1697 Ok(status) => status,
1698 Err(reason) => {
1699 if let Some(sender) = credential_lag_tx.take() {
1700 let _ = sender.send(reason);
1701 }
1702 break;
1703 }
1704 }
1705 }
1706 }
1707 };
1708 if !credential_status_advances(last_status, status) {
1709 continue;
1710 }
1711 let notification = serde_json::json!({
1712 "jsonrpc": "2.0",
1713 "method": "auth.credential.event",
1714 "params": status,
1715 });
1716 let Ok(text) = serde_json::to_string(¬ification) else {
1717 continue;
1718 };
1719 let closed = credential_events.closed();
1720 tokio::select! {
1721 biased;
1722 reason = closed => {
1723 if let Some(sender) = credential_lag_tx.take() {
1724 let _ = sender.send(reason);
1725 }
1726 break;
1727 }
1728 result = async {
1729 credential_session
1730 .channel
1731 .write
1732 .lock()
1733 .await
1734 .send(Message::Text(text.into()))
1735 .await
1736 } => {
1737 if result.is_err() {
1738 break;
1739 }
1740 last_status = Some(status);
1741 }
1742 }
1743 }
1744 });
1745
1746 let mut keepalive = tokio::time::interval(KEEPALIVE_PING_INTERVAL);
1757 keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1758 keepalive.tick().await;
1761 let mut last_inbound = tokio::time::Instant::now();
1762 loop {
1763 while conn_tasks.try_join_next().is_some() {}
1766
1767 let msg = tokio::select! {
1771 closed = &mut credential_lag_rx => {
1772 match closed {
1773 Ok(reason) => info!(
1774 client = %client_id,
1775 ?reason,
1776 "credential event subscriber closed; terminating connection"
1777 ),
1778 Err(_) => info!(
1779 client = %client_id,
1780 "credential event monitor ended; terminating connection"
1781 ),
1782 }
1783 break;
1784 }
1785 maybe = read.next() => match maybe {
1786 Some(Ok(m)) => {
1789 last_inbound = tokio::time::Instant::now();
1790 m
1791 }
1792 Some(Err(e)) => {
1801 info!("read error from {}: {}; closing", client_id, e);
1802 break;
1803 }
1804 None => break,
1805 },
1806 _ = keepalive.tick() => {
1807 if last_inbound.elapsed() >= KEEPALIVE_DEAD_AFTER {
1808 info!(
1809 "keepalive: no frames from {} for {}s; closing",
1810 client_id,
1811 last_inbound.elapsed().as_secs()
1812 );
1813 break;
1814 }
1815 let sent = tokio::time::timeout(KEEPALIVE_WRITE_TIMEOUT, async {
1824 let mut guard = channel.write.lock().await;
1825 guard.send(Message::Ping(Vec::new().into())).await
1826 })
1827 .await;
1828 if !matches!(sent, Ok(Ok(()))) {
1829 info!("keepalive: ping to {} failed/timed out; closing", client_id);
1830 break;
1831 }
1832 continue;
1833 }
1834 };
1835 if msg.is_text() {
1836 let text = match msg.to_text() {
1837 Ok(t) => t,
1838 Err(e) => {
1839 info!("non-text frame from {}: {}; closing", client_id, e);
1840 break;
1841 }
1842 };
1843 let parsed: JsonRpcMessage = match serde_json::from_str(text) {
1844 Ok(m) => m,
1845 Err(e) => {
1846 send_response(
1847 &session.channel,
1848 JsonRpcResponse::error(Value::Null, -32700, &format!("Parse error: {}", e)),
1849 )
1850 .await
1851 .ok();
1852 continue;
1853 }
1854 };
1855
1856 if parsed.method.is_none() && (parsed.result.is_some() || parsed.error.is_some()) {
1858 if let Some(id_str) = parsed.id.as_str() {
1859 let mut pending = session.channel.pending.lock().await;
1860 if let Some(tx) = pending.remove(id_str) {
1861 let tool_resp = if let Some(result) = parsed.result {
1862 ToolExecuteResponse {
1863 action_id: id_str.to_string(),
1864 output: Some(result),
1865 error: None,
1866 terminal: false,
1867 }
1868 } else {
1869 let err_msg = parsed
1870 .error
1871 .as_ref()
1872 .and_then(|e| e.get("message"))
1873 .and_then(|m| m.as_str())
1874 .unwrap_or("unknown error")
1875 .to_string();
1876 let terminal = parsed
1879 .error
1880 .as_ref()
1881 .and_then(|error| error.get("data"))
1882 .and_then(|data| data.get("terminal"))
1883 .and_then(Value::as_bool)
1884 .unwrap_or(false);
1885 ToolExecuteResponse {
1886 action_id: id_str.to_string(),
1887 output: None,
1888 error: Some(err_msg),
1889 terminal,
1890 }
1891 };
1892 let _ = tx.send(tool_resp);
1893 continue;
1894 }
1895 }
1896 }
1897
1898 if let Some(method) = parsed.method.as_deref() {
1905 if !agent_method_scope_allows(&session, method) {
1906 if !parsed.id.is_null() {
1907 let response = JsonRpcResponse::error(
1908 parsed.id.clone(),
1909 AGENT_METHOD_NOT_ALLOWED_ERROR_CODE,
1910 &format!("{AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX}`{method}`"),
1911 );
1912 let _ = send_response(&session.channel, response).await;
1913 }
1914 info!(
1915 client = %client_id,
1916 method = %method,
1917 "agent_method_not_allowed: scoped supervised-agent session denied before dispatch"
1918 );
1919 continue;
1920 }
1921 }
1922
1923 if try_forward_agent_chat_event(&parsed, &state).await {
1932 continue;
1933 }
1934
1935 if crate::browser_relay::try_handle_producer_push(&parsed, &state, &session).await {
1940 continue;
1941 }
1942
1943 if let Some(method) = &parsed.method {
1945 info!(method = %method, "dispatching JSON-RPC method");
1946
1947 if state.auth_token.get().is_some()
1955 && !session
1956 .authenticated
1957 .load(std::sync::atomic::Ordering::Acquire)
1958 && method != "session.auth"
1959 {
1960 let resp =
1961 JsonRpcResponse::error(parsed.id.clone(), -32001, &auth_required_message());
1962 let _ = send_response(&session.channel, resp).await;
1963 info!(client = %client_id, method = %method,
1964 "rejecting non-auth method on unauthenticated session; closing");
1965 break;
1966 }
1967
1968 if method == "session.auth"
1974 && parsed
1975 .params
1976 .get("agent_id")
1977 .and_then(Value::as_str)
1978 .is_some()
1979 {
1980 let result = dispatch_with_server_deadline(
1981 method,
1982 &parsed.params,
1983 handle_session_auth(&parsed, &session, &state),
1984 )
1985 .await;
1986 if result.is_ok()
1987 && credential_event_is_eligible(
1988 &session,
1989 credential_transport_auth_required,
1990 credential_host_role_required,
1991 )
1992 {
1993 let _ = credential_activation_tx.send(true);
1994 let mut ready = credential_ready_rx.clone();
1995 while !*ready.borrow_and_update() {
1996 if ready.changed().await.is_err() {
1997 break;
1998 }
1999 }
2000 }
2001 let response = json_rpc_response_for_handler_result(parsed.id, result);
2002 let _ = send_response(&session.channel, response).await;
2003 continue;
2004 }
2005
2006 if method == "server.handshake" {
2014 let (response, handshake_succeeded) =
2015 match handle_server_handshake(&parsed, &session) {
2016 Ok(result) => {
2017 (JsonRpcResponse::success(parsed.id.clone(), result), true)
2018 }
2019 Err(error) => (
2020 JsonRpcResponse::error(
2021 parsed.id.clone(),
2022 error.code,
2023 &error.message,
2024 ),
2025 false,
2026 ),
2027 };
2028 if handshake_succeeded
2029 && credential_event_is_eligible(
2030 &session,
2031 credential_transport_auth_required,
2032 credential_host_role_required,
2033 )
2034 {
2035 let _ = credential_activation_tx.send(true);
2036 let mut ready = credential_ready_rx.clone();
2037 while !*ready.borrow_and_update() {
2038 if ready.changed().await.is_err() {
2039 break;
2040 }
2041 }
2042 }
2043 let _ = send_response(&session.channel, response).await;
2044 continue;
2045 }
2046
2047 if requires_protocol_handshake(method)
2048 && session
2049 .negotiated_protocol_version
2050 .load(std::sync::atomic::Ordering::Acquire)
2051 != car_proto::PROTOCOL_VERSION
2052 {
2053 let message = format!(
2054 "{} call `server.handshake` with {{\"protocol_version\":{}}} \
2055 and wait for an exact-version success before `{}`",
2056 car_proto::PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX,
2057 car_proto::PROTOCOL_VERSION,
2058 method,
2059 );
2060 let response = JsonRpcResponse::error(
2061 parsed.id.clone(),
2062 car_proto::PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE,
2063 &message,
2064 );
2065 let _ = send_response(&session.channel, response).await;
2066 info!(
2067 client = %client_id,
2068 method = %method,
2069 "rejecting handshake-gated method before negotiation"
2070 );
2071 continue;
2072 }
2073
2074 if crate::HOST_MANAGEMENT_METHODS.contains(&method.as_str()) {
2080 if let Err(error) = require_approval_authority(&session, &state) {
2081 let response = json_rpc_response_for_handler_result(
2082 parsed.id.clone(),
2083 Err(HandlerFailure::Dispatch(error)),
2084 );
2085 let _ = send_response(&session.channel, response).await;
2086 continue;
2087 }
2088 }
2089
2090 if state.approval_gate.requires_approval(method.as_str()) {
2102 match gate_high_risk_method(method.as_str(), &parsed.params, &state).await {
2103 Ok(()) => {}
2104 Err(reason) => {
2105 let resp = JsonRpcResponse::error(parsed.id.clone(), -32003, &reason);
2106 let _ = send_response(&session.channel, resp).await;
2107 info!(
2108 client = %client_id,
2109 method = %method,
2110 reason = %reason,
2111 "approval gate blocked dispatch"
2112 );
2113 continue;
2114 }
2115 }
2116 }
2117
2118 if is_daemon_owned_auth_method(method) {
2126 let response_id = parsed.id.clone();
2127 if let Err(error) = require_approval_authority(&session, &state) {
2128 let response = json_rpc_response_for_handler_result(
2129 response_id,
2130 Err(HandlerFailure::Dispatch(error)),
2131 );
2132 let _ = send_response(&session.channel, response).await;
2133 continue;
2134 }
2135
2136 let method_owned = method.clone();
2137 let method_for_waiter = method_owned.clone();
2138 let parsed_owned = parsed;
2139 let state_owned = state.clone();
2140 let result_rx = state
2141 .spawn_durable_operation(method_owned.clone(), async move {
2142 dispatch_daemon_owned_auth(
2143 method_owned.as_str(),
2144 &parsed_owned,
2145 &state_owned,
2146 )
2147 .await
2148 })
2149 .await;
2150 let response_channel = session.channel.clone();
2151 conn_tasks.spawn(async move {
2152 let result =
2153 await_daemon_owned_auth_result(method_for_waiter.as_str(), result_rx)
2154 .await;
2155 let response = json_rpc_response_for_handler_result(response_id, result);
2156 let _ = send_response(&response_channel, response).await;
2157 });
2158 continue;
2159 }
2160
2161 let session_task = session.clone();
2176 let state_task = state.clone();
2177 let method_owned = method.clone();
2178 let parsed_task = parsed;
2179 let credential_activation_tx_task = credential_activation_tx.clone();
2180 let credential_ready_rx_task = credential_ready_rx.clone();
2181 conn_tasks.spawn(async move {
2184 let session = session_task;
2185 let state = state_task;
2186 let parsed = parsed_task;
2187 let bound_agent = session.agent_id.lock().await.clone();
2201 let bound_agent = bound_agent.as_deref();
2202 let dispatch = async {
2206 match method_owned.as_str() {
2207 "session.auth" => handle_session_auth(&parsed, &session, &state).await,
2208 "capabilities.list" => Ok(handle_capabilities_list(
2209 bound_agent,
2210 session.is_host.load(std::sync::atomic::Ordering::Acquire),
2211 )),
2212 "parslee.auth" => handle_parslee_auth(&state).await,
2213 "parslee.capabilities" => crate::parslee_capabilities::discover().await,
2214 "parslee.m365.generate_document" => {
2215 crate::parslee_m365::generate_document(&parsed.params).await
2216 }
2217 "openrouter.status" => handle_openrouter_status(&session, &state),
2218 "openrouter.auth_start" => {
2219 handle_openrouter_auth_start(&parsed, &session, &state).await
2220 }
2221 "openrouter.auth_cancel" => {
2222 handle_openrouter_auth_cancel(&parsed, &session, &state)
2223 }
2224 "openrouter.disconnect" => {
2225 handle_openrouter_disconnect(&session, &state).await
2226 }
2227 "session.init" => handle_session_init(&parsed, &session).await,
2228 "session.bindSandbox" => {
2229 handle_session_bind_sandbox(&parsed, &session, &state).await
2230 }
2231 "session.bindSubstrate" => {
2232 handle_session_bind_substrate(&parsed, &session, &state).await
2233 }
2234 "session.clear_halt" => handle_session_clear_halt(&session).await,
2235 "server.schema" => handle_server_schema(),
2236 "host.subscribe" => handle_host_subscribe(&session, &state).await,
2237 "supervision.subscribe" => {
2238 handle_supervision_subscribe(&parsed, &session, &state).await
2239 }
2240 "supervision.unsubscribe" => {
2241 handle_supervision_unsubscribe(&session, &state).await
2242 }
2243 "supervision.pending" => handle_supervision_pending(&state).await,
2244 "supervision.decide" => {
2245 handle_supervision_decide(&parsed, &state).await
2246 }
2247 "host.agents" => handle_host_agents(&session, &state).await,
2248 "host.events" => handle_host_events(&parsed, &session).await,
2249 "host.approvals" => handle_host_approvals(&session).await,
2250 "host.register_agent" => {
2251 handle_host_register_agent(&parsed, &session).await
2252 }
2253 "host.unregister_agent" => {
2254 handle_host_unregister_agent(&parsed, &session).await
2255 }
2256 "host.set_status" => handle_host_set_status(&parsed, &session).await,
2257 "host.register_device" => {
2258 handle_host_register_device(&parsed, &session).await
2259 }
2260 "host.update_device" => {
2261 handle_host_update_device(&parsed, &session).await
2262 }
2263 "host.devices" => handle_host_devices(&session).await,
2264 "mobile.runtime" => handle_mobile_runtime(&state).await,
2265 "host.notify" => handle_host_notify(&parsed, &session).await,
2266 "host.request_approval" => {
2267 handle_host_request_approval(&parsed, &session).await
2268 }
2269 "host.resolve_approval" => {
2270 handle_host_resolve_approval(&parsed, &session).await
2271 }
2272 "tools.register" => handle_tools_register(&parsed, &session).await,
2273 "tools.list" => handle_tools_list(&session).await,
2274 "tools.unregister" => handle_tools_unregister(&parsed, &session).await,
2275 "tools.poll" => handle_tools_poll(&parsed, &session).await,
2276 "tools.cancel" => handle_tools_cancel(&parsed, &session).await,
2277 "tools.stream.subscribe" => {
2278 handle_tools_stream_subscribe(&session).await
2279 }
2280 "proposal.submit" => {
2281 handle_proposal_submit(&parsed, &session, &state).await
2282 }
2283 "policy.register" => handle_policy_register(&parsed, &session).await,
2284 "policy.unregister" => {
2285 handle_policy_unregister(&parsed, &session).await
2286 }
2287 "policy.list" => handle_policy_list(&parsed, &session).await,
2288 "session.policy.open" => handle_session_policy_open(&session).await,
2289 "session.policy.close" => {
2290 handle_session_policy_close(&parsed, &session).await
2291 }
2292 "verify" => handle_verify(&parsed, &session).await,
2293 "verify.monte_carlo" => {
2294 handle_verify_monte_carlo(&parsed, &session).await
2295 }
2296 "permission.get_tier" => handle_permission_get_tier(&session).await,
2297 "permission.set_tier" => {
2298 handle_permission_set_tier(&parsed, &session, &state).await
2299 }
2300 "permission.classify" => {
2301 handle_permission_classify(&parsed, &session).await
2302 }
2303 "permission.evaluate" => {
2304 handle_permission_evaluate(&parsed, &session, &state).await
2305 }
2306 "permission.pending" => {
2307 handle_permission_pending(&parsed, &session, &state).await
2308 }
2309 "permission.approve" => {
2310 handle_permission_decision(&parsed, &session, &state, true).await
2311 }
2312 "permission.reject" => {
2313 handle_permission_decision(&parsed, &session, &state, false).await
2314 }
2315 "agent_permissions.get" => {
2316 crate::agent_permissions::handle_get(&parsed)
2317 }
2318 "agent_permissions.set" => {
2319 require_agent_permissions_authority(&session, &state).await?;
2320 crate::agent_permissions::handle_set(&parsed)
2321 }
2322 "agent_permissions.set_default" => {
2323 require_agent_permissions_authority(&session, &state).await?;
2324 crate::agent_permissions::handle_set_default(&parsed)
2325 }
2326 "agent_permissions.reset" => {
2327 require_agent_permissions_authority(&session, &state).await?;
2328 crate::agent_permissions::handle_reset(&parsed)
2329 }
2330 "agent_permissions.evaluate" => {
2331 crate::agent_permissions::handle_evaluate(&parsed)
2332 }
2333 "agent_permissions.set_tool" => {
2334 require_agent_permissions_authority(&session, &state).await?;
2335 require_agent_tool_override_capability(&session)?;
2336 handle_agent_permission_set_tool(&parsed, &state).await
2337 }
2338 "agent_permissions.reset_tool" => {
2339 require_agent_permissions_authority(&session, &state).await?;
2340 require_agent_tool_override_capability(&session)?;
2341 crate::agent_permissions::handle_reset_tool(&parsed)
2342 }
2343 "agent_permissions.evaluate_tool" => {
2344 require_agent_permissions_authority(&session, &state).await?;
2345 require_agent_tool_override_capability(&session)?;
2346 handle_agent_permission_evaluate_tool(&parsed, &state).await
2347 }
2348 "sync.status" => handle_sync_status(&state).await,
2350 "sync.append" => handle_sync_append(&parsed, &state).await,
2351 "sync.record_turn" => handle_sync_record_turn(&parsed, &state).await,
2352 "sync.record_intent" => {
2353 handle_sync_record_intent(&parsed, &state, &session).await
2354 }
2355 "sync.pump" => handle_sync_pump(&state).await,
2356 "sync.knowledge" => handle_sync_knowledge(&parsed, &state).await,
2357 "sync.checkpoint" => handle_sync_checkpoint(&state).await,
2358 "sync.rebase" => handle_sync_rebase(&state).await,
2359 "sync.transcript" => handle_sync_transcript(&parsed, &state).await,
2360 "sync.resume" => handle_sync_resume(&parsed, &state).await,
2361 "sync.assistant_checkpoint.put" => {
2362 handle_sync_assistant_checkpoint_put(&parsed, &state).await
2363 }
2364 "sync.assistant_checkpoint.get" => {
2365 handle_sync_assistant_checkpoint_get(&parsed, &state).await
2366 }
2367 "sync.assistant_action.put" => {
2368 handle_sync_assistant_action_put(&parsed, &state).await
2369 }
2370 "sync.assistant_action.get" => {
2371 handle_sync_assistant_action_get(&parsed, &state).await
2372 }
2373 "sync.fence_check" => {
2374 handle_sync_fence_check(&parsed, &state, &session).await
2375 }
2376 "lease.acquire" => {
2377 handle_lease_acquire(&parsed, &state, &session).await
2378 }
2379 "lease.renew" => handle_lease_renew(&parsed, &state, &session).await,
2380 "lease.release" => {
2381 handle_lease_release(&parsed, &state, &session).await
2382 }
2383 "lease.status" => {
2384 handle_lease_status(&parsed, &state, bound_agent).await
2385 }
2386 "messaging.config.get" => {
2387 handle_messaging_config_get(&parsed, &session, &state).await
2388 }
2389 "messaging.config.set" => {
2390 handle_messaging_config_set(&parsed, &session, &state).await
2391 }
2392 "messaging.pairing.start" => {
2393 handle_messaging_pairing_start(&parsed, &session, &state).await
2394 }
2395 "messaging.pairing.status" => {
2396 handle_messaging_pairing_status(&parsed, &session, &state).await
2397 }
2398 "messaging.status" => {
2399 handle_messaging_status(&parsed, &session, &state).await
2400 }
2401 "messaging.test_send" => {
2402 handle_messaging_test_send(&parsed, &session, &state).await
2403 }
2404 "state.get" => handle_state_get(&parsed, &session).await,
2405 "state.set" => handle_state_set(&parsed, &session).await,
2406 "state.exists" => handle_state_exists(&parsed, &session).await,
2407 "state.keys" => handle_state_keys(&parsed, &session).await,
2408 "state.snapshot" => handle_state_snapshot(&parsed, &session).await,
2409 "memory.add_fact" => handle_memory_add_fact(&parsed, &session).await,
2410 "memory.set_admission_table" => {
2411 handle_memory_set_admission_table(&parsed, &session).await
2412 }
2413 "memory.admission_table" => {
2414 handle_memory_admission_table(&parsed, &session).await
2415 }
2416 "memory.update_status" => {
2417 handle_memory_update_status(&parsed, &session).await
2418 }
2419 "memory.maintain" => handle_memory_maintain(&parsed, &session).await,
2420 "memory.save_knowledge" => {
2421 handle_memory_save_knowledge(&parsed, &session).await
2422 }
2423 "memory.save_procedural" => {
2424 handle_memory_save_procedural(&parsed, &session).await
2425 }
2426 "memory.delete" => handle_memory_delete(&parsed, &session).await,
2427 "memory.query" => handle_memory_query(&parsed, &session).await,
2428 "memory.evaluate" => handle_memory_evaluate(&parsed, &session).await,
2429 "memory.intervene" => handle_memory_intervene(&parsed, &session).await,
2430 "memory.build_context" => {
2431 handle_memory_build_context(&parsed, &session).await
2432 }
2433 "memory.build_context_fast" => {
2434 handle_memory_build_context_fast(&parsed, &session).await
2435 }
2436 "memory.consolidate" => handle_memory_consolidate(&session).await,
2437 "memory.utility_get" => handle_memory_utility_get(&session).await,
2438 "memory.utility_set" => {
2439 handle_memory_utility_set(&parsed, &session).await
2440 }
2441 "cascade.run" => handle_cascade_run(&parsed, &session, &state).await,
2442 "evolution.plan" => {
2443 handle_evolution_plan(&parsed, &session, &state).await
2444 }
2445 "evolution.run" => {
2446 handle_evolution_run(&parsed, &session, &state).await
2447 }
2448 "selfheal.status" => handle_selfheal_status(&state).await,
2449 "selfheal.detections" => {
2450 handle_selfheal_detections(&parsed, &state).await
2451 }
2452 "selfheal.dismiss" => handle_selfheal_dismiss(&parsed, &state).await,
2453 "selfheal.fix" => handle_selfheal_fix(&parsed, &state).await,
2454 "selfheal.run" => handle_selfheal_run(&state).await,
2455 "heal.status" => handle_heal_status(&state).await,
2456 "heal.run" => handle_heal_run(&state).await,
2457 "memory.fact_count" => handle_memory_fact_count(&session).await,
2458 "memory.persist" => handle_memory_persist(&parsed, &session).await,
2459 "memory.load" => handle_memory_load(&parsed, &session).await,
2460 "skill.ingest" => handle_skill_ingest(&parsed, &session).await,
2461 "skill.find" => handle_skill_find(&parsed, &session).await,
2462 "skill.report" => handle_skill_report(&parsed, &session).await,
2463 "skill.gate_deployment" => {
2464 handle_skill_gate_deployment(&parsed, &session).await
2465 }
2466 "skill.enforce_deployment" => {
2467 handle_skill_enforce_deployment(&parsed, &session, &state).await
2468 }
2469 "skill.ingest_governed" => {
2470 handle_skill_ingest_governed(&parsed, &session, &state).await
2471 }
2472 "skill.adopt_pack" => {
2473 handle_skill_adopt_pack(&parsed, &session, &state).await
2474 }
2475 "skill.repair" => handle_skill_repair(&parsed, &session).await,
2476 "skills.ingest_distilled" => {
2477 handle_skills_ingest_distilled(&parsed, &session).await
2478 }
2479 "skills.evolve" => handle_skills_evolve(&parsed, &session).await,
2480 "skills.domains_needing_evolution" => {
2481 handle_skills_domains_needing_evolution(&parsed, &session).await
2482 }
2483 "skills.ingest_provisional" => {
2484 handle_skills_ingest_provisional(&parsed, &session).await
2485 }
2486 "skills.gate" => handle_skills_gate(&parsed, &session).await,
2487 "skill.meta" => handle_skill_meta(&parsed, &session).await,
2488 "skill.export" => handle_skill_export(&parsed, &session).await,
2489 "skill.import" => handle_skill_import(&parsed, &session).await,
2490 "multi.swarm" => handle_multi_swarm(&parsed, &session).await,
2491 "multi.pipeline" => handle_multi_pipeline(&parsed, &session).await,
2492 "multi.supervisor" => handle_multi_supervisor(&parsed, &session).await,
2493 "multi.map_reduce" => handle_multi_map_reduce(&parsed, &session).await,
2494 "multi.vote" => handle_multi_vote(&parsed, &session).await,
2495 "multi.tournament" => handle_multi_tournament(&parsed, &session).await,
2496 "multi.subtask" => handle_multi_subtask(&parsed, &session).await,
2497 "schedule.suggest" => handle_schedule_suggest(&parsed),
2498 "scheduler.create" => handle_scheduler_create(&parsed),
2499 "scheduler.run" => handle_scheduler_run(&parsed, &session).await,
2500 "scheduler.run_loop" => {
2501 handle_scheduler_run_loop(&parsed, &session).await
2502 }
2503 "scheduler.os_render" => handle_scheduler_os_render(&parsed),
2504 "scheduler.os_install" => handle_scheduler_os_install(&parsed),
2505 "scheduler.os_uninstall" => handle_scheduler_os_uninstall(&parsed),
2506 "scheduler.os_list" => handle_scheduler_os_list(&parsed),
2507 "scheduler.os_reconcile" => handle_scheduler_os_reconcile(&parsed),
2508 "tasks.schedule" => handle_tasks_schedule(&parsed, &session, &state),
2509 "tasks.list" => handle_tasks_list(&parsed),
2510 "tasks.unschedule" => {
2511 handle_tasks_unschedule(&parsed, &session, &state)
2512 }
2513 "diagnostics.secret_store_activity" => {
2514 handle_secret_store_activity(&session, &state)
2515 }
2516 "feedback.compose_preview" => {
2523 require_feedback_capability(&session)?;
2524 crate::feedback::handle_compose_preview(&parsed, &state, &session)
2525 .await
2526 }
2527 "feedback.submit" => {
2528 require_feedback_capability(&session)?;
2529 crate::feedback::handle_submit(&parsed, &state, &session).await
2530 }
2531 "feedback.status" => {
2532 require_feedback_capability(&session)?;
2533 crate::feedback::handle_status(&parsed, &state).await
2534 }
2535 "feedback.list" => {
2536 require_feedback_capability(&session)?;
2537 crate::feedback::handle_list(&parsed, &state).await
2538 }
2539 "infer" => handle_infer(&parsed, state.clone(), session.clone()).await,
2540 "infer.cancel" => handle_infer_cancel(&parsed, &session).await,
2541 "infer.deadline" => handle_infer_deadline(&parsed, &session).await,
2542 "image.generate" => handle_image_generate(&parsed, &state).await,
2543 "video.generate" => handle_video_generate(&parsed, &state).await,
2544 "embed" => handle_embed(&parsed, &state).await,
2545 "classify" => handle_classify(&parsed, &state).await,
2546 "tokenize" => handle_tokenize(&parsed, &state).await,
2547 "detokenize" => handle_detokenize(&parsed, &state).await,
2548 "rerank" => handle_rerank(&parsed, &state).await,
2549 "transcribe" => handle_transcribe(&parsed, &state).await,
2550 "synthesize" => handle_synthesize(&parsed, &state).await,
2551 "search" => handle_search(&parsed, &state).await,
2552 "web_fetch" => handle_web_fetch(&parsed, &state).await,
2553 "infer_stream" => handle_infer_stream(&parsed, &session, &state).await,
2554 "speech.prepare" => handle_speech_prepare(&state).await,
2555 "models.route" => handle_models_route(&parsed, &state).await,
2556 "models.stats" => handle_models_stats(&state).await,
2557 "outcomes.scoreboard" => handle_outcomes_scoreboard(&state).await,
2558 "outcomes.resolve_pending" => {
2559 handle_outcomes_resolve_pending(&parsed, &state).await
2560 }
2561 "events.count" => handle_events_count(&session).await,
2562 "events.stats" => handle_events_stats(&session).await,
2563 "events.truncate" => handle_events_truncate(&parsed, &session).await,
2564 "events.clear" => handle_events_clear(&session).await,
2565 "events.query" => handle_events_query(&parsed, &session).await,
2566 "events.retention" => handle_events_retention(&parsed, &session).await,
2567 "events.cost_by_agent" => handle_events_cost_by_agent(&session).await,
2568 "events.chain.enable" => handle_events_chain_enable(&session).await,
2569 "events.chain.verify" => handle_events_chain_verify(&session).await,
2570 "metrics.summary" => handle_metrics_summary(&session).await,
2571 "metrics.alerts" => handle_metrics_alerts(&parsed, &session).await,
2572 "nlp.identify_language" => handle_nlp(&parsed, NlpOp::IdentifyLanguage),
2573 "nlp.tokenize" => handle_nlp(&parsed, NlpOp::Tokenize),
2574 "nlp.extract_entities" => handle_nlp(&parsed, NlpOp::ExtractEntities),
2575 "runs.start" => handle_runs_start(&parsed, &session, &state).await,
2582 "runs.resume" => handle_runs_resume(&parsed, &session, &state).await,
2583 "runs.complete" => {
2584 handle_runs_complete(&parsed, &session, &state).await
2585 }
2586 "runs.cancel" => handle_runs_cancel(&parsed, &session, &state).await,
2587 "runs.record_turns" => {
2596 handle_runs_record_turns(&parsed, &session, &state).await
2597 }
2598 "runs.subscribe" => {
2603 handle_runs_subscribe(&parsed, &session, &state).await
2604 }
2605 "runs.unsubscribe" => {
2606 handle_runs_unsubscribe(&parsed, &session, &state).await
2607 }
2608 "runs.list" => handle_runs_list(&parsed, &session, &state).await,
2614 "runs.get_trace" => {
2615 handle_runs_get_trace(&parsed, &session, &state).await
2616 }
2617 "replan.set_config" => {
2618 handle_replan_set_config(&parsed, &session).await
2619 }
2620 "models.list" => handle_models_list(&state),
2621 "models.register" => handle_models_register(&parsed, &state).await,
2622 "models.unregister" => handle_models_unregister(&parsed, &state).await,
2623 "models.list_unified" => {
2624 handle_models_list_unified(&parsed, &state).await
2625 }
2626 "models.catalog_snapshot" => {
2627 handle_models_catalog_snapshot(&session, &state)
2628 }
2629 "models.resource_policy.get" => {
2630 handle_models_resource_policy_get(&parsed, &state)
2631 }
2632 "models.resource_policy.set" => {
2633 handle_models_resource_policy_set(&parsed, &session, &state)
2634 }
2635 "models.preflight" => handle_models_preflight(&parsed, &state),
2636 "models.storage_roots" => {
2637 handle_models_storage_roots(&parsed, &session, &state)
2638 }
2639 "models.remove" => {
2640 handle_models_remove(&parsed, &session, &state).await
2641 }
2642 "models.adopt" => handle_models_adopt(&parsed, &session, &state).await,
2643 "models.route_provenance" => {
2644 handle_models_route_provenance(&parsed, &state).await
2645 }
2646 "models.search" => handle_models_search(&parsed, &state).await,
2647 "models.recommend" => handle_models_recommend(&parsed, &state),
2648 "models.setup_plan" => handle_models_setup_plan(&parsed, &state),
2649 "models.upgrades" => handle_models_upgrades(&state),
2650 "models.detect_upgrades" => handle_models_detect_upgrades(&state).await,
2651 "models.check_upgrade_nudge" => {
2652 handle_models_check_upgrade_nudge(&parsed, &state).await
2653 }
2654 "models.dismiss_upgrade" => {
2655 handle_models_dismiss_upgrade(&parsed, &state)
2656 }
2657 "models.check_concierge" => {
2658 handle_models_check_concierge(&parsed, &state).await
2659 }
2660 "models.dismiss_suggestion" => {
2661 handle_models_dismiss_suggestion(&parsed, &state)
2662 }
2663 "concierge.status" => handle_concierge_status(&parsed, &state).await,
2667 "concierge.dismiss" => handle_concierge_dismiss(&parsed, &state),
2668 "concierge.defaults" => handle_concierge_defaults(&state),
2669 "concierge.set_default" => {
2670 handle_concierge_set_default(&parsed, &state).await
2671 }
2672 "concierge.clear_default" => {
2673 handle_concierge_clear_default(&parsed, &state).await
2674 }
2675 "concierge.apply" => handle_concierge_apply(&parsed, &state).await,
2676 "concierge.rollback" => {
2677 handle_concierge_rollback(&parsed, &state).await
2678 }
2679 "concierge.actions" => handle_concierge_actions(&parsed, &state),
2680 "concierge.refresh_catalog" => {
2681 handle_concierge_refresh_catalog(&state).await
2682 }
2683 "concierge.ask" => handle_concierge_ask(&parsed, &state).await,
2684 "models.update_prefs_get" => handle_models_update_prefs_get(&state),
2685 "models.update_prefs_set" => {
2686 handle_models_update_prefs_set(&parsed, &state)
2687 }
2688 "models.pull" | "models.install" => {
2689 handle_models_pull(&parsed, &session, &state).await
2690 }
2691 "skills.distill" => handle_skills_distill(&parsed, &state).await,
2692 "skills.list" => handle_skills_list(&parsed, &session).await,
2693 "browser.run" => handle_browser_run(&parsed, &session).await,
2694 "browser.close" => handle_browser_close(&session).await,
2695 "browser.view.subscribe" => {
2704 crate::browser_view::handle_subscribe(&parsed, &session, &state)
2705 .await
2706 }
2707 "browser.view.unsubscribe" => {
2708 crate::browser_view::handle_unsubscribe(&parsed, &session, &state)
2709 .await
2710 }
2711 "browser.view.take_control" => {
2712 crate::browser_view::handle_take_control(&parsed, &session, &state)
2713 .await
2714 }
2715 "browser.view.hand_back" => {
2716 crate::browser_view::handle_hand_back(&parsed, &session, &state)
2717 .await
2718 }
2719 "browser.view.navigate" => {
2720 crate::browser_view::handle_input(
2721 crate::browser_view::InputOp::Navigate,
2722 &parsed,
2723 &session,
2724 &state,
2725 )
2726 .await
2727 }
2728 "browser.view.click" => {
2729 crate::browser_view::handle_input(
2730 crate::browser_view::InputOp::Click,
2731 &parsed,
2732 &session,
2733 &state,
2734 )
2735 .await
2736 }
2737 "browser.view.type" => {
2738 crate::browser_view::handle_input(
2739 crate::browser_view::InputOp::Type,
2740 &parsed,
2741 &session,
2742 &state,
2743 )
2744 .await
2745 }
2746 "browser.view.keypress" => {
2747 crate::browser_view::handle_input(
2748 crate::browser_view::InputOp::Keypress,
2749 &parsed,
2750 &session,
2751 &state,
2752 )
2753 .await
2754 }
2755 "browser.view.scroll" => {
2756 crate::browser_view::handle_input(
2757 crate::browser_view::InputOp::Scroll,
2758 &parsed,
2759 &session,
2760 &state,
2761 )
2762 .await
2763 }
2764 "browser.view.paste" => {
2769 crate::browser_view::handle_input(
2770 crate::browser_view::InputOp::Paste,
2771 &parsed,
2772 &session,
2773 &state,
2774 )
2775 .await
2776 }
2777 "browser.view.back" => {
2781 crate::browser_view::handle_input(
2782 crate::browser_view::InputOp::Back,
2783 &parsed,
2784 &session,
2785 &state,
2786 )
2787 .await
2788 }
2789 "browser.view.forward" => {
2790 crate::browser_view::handle_input(
2791 crate::browser_view::InputOp::Forward,
2792 &parsed,
2793 &session,
2794 &state,
2795 )
2796 .await
2797 }
2798 "browser.view.reload" => {
2799 crate::browser_view::handle_input(
2800 crate::browser_view::InputOp::Reload,
2801 &parsed,
2802 &session,
2803 &state,
2804 )
2805 .await
2806 }
2807 "browser.view.tab_open" => {
2808 crate::browser_view::handle_input(
2809 crate::browser_view::InputOp::TabOpen,
2810 &parsed,
2811 &session,
2812 &state,
2813 )
2814 .await
2815 }
2816 "browser.view.tab_close" => {
2817 crate::browser_view::handle_input(
2818 crate::browser_view::InputOp::TabClose,
2819 &parsed,
2820 &session,
2821 &state,
2822 )
2823 .await
2824 }
2825 "browser.view.tab_switch" => {
2826 crate::browser_view::handle_input(
2827 crate::browser_view::InputOp::TabSwitch,
2828 &parsed,
2829 &session,
2830 &state,
2831 )
2832 .await
2833 }
2834 "browser.producer.register" => {
2839 crate::browser_relay::handle_producer_register(
2840 &parsed, &session, &state,
2841 )
2842 .await
2843 }
2844 "secret.put" => handle_secret_put(&parsed).await,
2845 "secret.get" => handle_secret_get(&parsed, &session, &state).await,
2846 "secret.delete" => handle_secret_delete(&parsed).await,
2847 "secret.status" => handle_secret_status(&parsed),
2848 "secret.available" => Ok(car_ffi_common::secrets::is_available()),
2849 "secret.list" => car_ffi_common::secrets::list(),
2850 "permissions.status" => handle_perm_status(&parsed),
2851 "permissions.request" => handle_perm_request(&parsed),
2852 "permissions.explain" => handle_perm_explain(&parsed),
2853 "permissions.domains" => Ok(car_ffi_common::permissions::domains()),
2854 "accounts.list" => car_ffi_common::accounts::list(),
2855 "accounts.open" => {
2856 #[derive(serde::Deserialize, Default)]
2857 struct OpenParams {
2858 #[serde(default)]
2859 account_id: Option<String>,
2860 }
2861 let p: OpenParams = serde_json::from_value(parsed.params.clone())
2862 .unwrap_or_default();
2863 car_ffi_common::accounts::open_settings(p.account_id.as_deref())
2864 }
2865 "calendar.list" => car_ffi_common::integrations::calendar_list(),
2866 "calendar.events" => handle_calendar_events(&parsed),
2867 "calendar.create_event" => handle_calendar_create_event(&parsed),
2868 "calendar.update_event" => handle_calendar_update_event(&parsed),
2869 "calendar.delete_event" => handle_calendar_delete_event(&parsed),
2870 "contacts.containers" => {
2871 car_ffi_common::integrations::contacts_containers()
2872 }
2873 "contacts.find" => handle_contacts_find(&parsed),
2874 "mail.accounts" => car_ffi_common::integrations::mail_accounts(),
2875 "mail.inbox" => handle_mail_inbox(&parsed),
2876 "mail.mailboxes" => handle_mail_mailboxes(&parsed),
2877 "mail.messages" => handle_mail_messages(&parsed),
2878 "mail.message_body" => handle_mail_message_body(&parsed),
2879 "mail.send" => handle_mail_send(&parsed),
2880 "messages.services" => {
2881 car_ffi_common::integrations::messages_services()
2882 }
2883 "messages.chats" => handle_messages_chats(&parsed),
2884 "messages.read" => handle_messages_read(&parsed),
2885 "messages.send" => handle_messages_send(&parsed),
2886 "notes.accounts" => car_ffi_common::integrations::notes_accounts(),
2887 "notes.find" => handle_notes_find(&parsed),
2888 "reminders.lists" => car_ffi_common::integrations::reminders_lists(),
2889 "reminders.items" => handle_reminders_items(&parsed),
2890 "photos.albums" => car_ffi_common::integrations::photos_albums(),
2891 "bookmarks.list" => handle_bookmarks_list(&parsed),
2892 "files.locations" => car_ffi_common::integrations::files_locations(),
2893 "keychain.status" => car_ffi_common::integrations::keychain_status(),
2894 "health.status" => car_ffi_common::health::status(),
2895 "health.sleep" => handle_health_sleep(&parsed),
2896 "health.workouts" => handle_health_workouts(&parsed),
2897 "health.activity" => handle_health_activity(&parsed),
2898 "voice.transcribe_stream.start" => {
2899 handle_voice_transcribe_stream_start(&parsed, &state, &session)
2900 .await
2901 }
2902 "voice.transcribe_stream.stop" => {
2903 handle_voice_transcribe_stream_stop(&parsed, &state).await
2904 }
2905 "voice.transcribe_stream.push" => {
2906 handle_voice_transcribe_stream_push(&parsed, &state).await
2907 }
2908 "voice.tts_stream.start" => {
2909 handle_voice_tts_stream_start(&parsed, &session).await
2910 }
2911 "voice.tts_stream.cancel" => {
2912 handle_voice_tts_stream_cancel(&parsed).await
2913 }
2914 "voice.tts_stream.list" => Ok(handle_voice_tts_stream_list()),
2915 "voice.sessions.list" => Ok(handle_voice_sessions_list(&state)),
2916 "voice.dispatch_turn" => {
2917 handle_voice_dispatch_turn(&parsed, &state, &session).await
2918 }
2919 "voice.cancel_turn" => handle_voice_cancel_turn().await,
2920 "voice.prewarm_turn" => handle_voice_prewarm_turn(&state).await,
2921 "inference.register_runner" => {
2922 handle_inference_register_runner(&session).await
2923 }
2924 "inference.runner.event" => {
2925 handle_inference_runner_event(&parsed).await
2926 }
2927 "inference.runner.complete" => {
2928 handle_inference_runner_complete(&parsed).await
2929 }
2930 "inference.runner.fail" => handle_inference_runner_fail(&parsed).await,
2931 "voice.providers.list" => {
2932 serde_json::from_str::<serde_json::Value>(
2936 &car_voice::list_voice_providers_json(),
2937 )
2938 .map_err(|e| e.to_string())
2939 }
2940 "voice.prepare_parakeet" => car_ffi_common::voice::prepare_parakeet()
2941 .await
2942 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2943 "voice.prepare_diarizer" => car_ffi_common::voice::prepare_diarizer()
2944 .await
2945 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2946 "voice.enroll_speaker" => handle_enroll_speaker(&parsed).await,
2947 "voice.list_enrollments" => car_ffi_common::voice::list_enrollments()
2948 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2949 "voice.remove_enrollment" => handle_remove_enrollment(&parsed),
2950 "workflow.run" => handle_workflow_run(&parsed, &session).await,
2951 "workflow.chain" => handle_workflow_chain(&parsed, &session).await,
2952 "workflow.resume" => handle_workflow_resume(&parsed, &session).await,
2953 "workflow.list_paused" => handle_workflow_list_paused().await,
2954 "builder.build" => {
2955 handle_builder_build(&parsed, &state, &session).await
2956 }
2957 "workflow.verify" => handle_workflow_verify(&parsed),
2958 "workflow.build_automation" => {
2959 handle_workflow_build_automation(&parsed)
2960 }
2961 "meeting.start" => {
2962 handle_meeting_start(&parsed, &state, &session).await
2963 }
2964 "meeting.stop" => handle_meeting_stop(&parsed, &state, &session).await,
2965 "meeting.list" => handle_meeting_list(&parsed),
2966 "meeting.get" => handle_meeting_get(&parsed),
2967 "registry.register" => handle_registry_register(&parsed),
2968 "registry.heartbeat" => handle_registry_heartbeat(&parsed),
2969 "registry.unregister" => handle_registry_unregister(&parsed),
2970 "registry.list" => handle_registry_list(&parsed),
2971 "registry.reap" => handle_registry_reap(&parsed),
2972 "admission.status" => handle_admission_status(&state),
2973 "a2a.start" => handle_a2a_start(&parsed, &state, &session).await,
2974 "a2a.stop" => handle_a2a_stop(),
2975 "a2a.status" => handle_a2a_status(),
2976 "a2a.send" => handle_a2a_send(&parsed, &state).await,
2977 "a2a.peers.add" => handle_a2a_peers_add(&parsed),
2978 "a2a.peers.list" => handle_a2a_peers_list(),
2979 "a2a.peers.remove" => handle_a2a_peers_remove(&parsed),
2980 "a2ui.apply" => handle_a2ui_apply(&parsed, &state).await,
2981 "a2ui.ingest" => handle_a2ui_ingest(&parsed, &state).await,
2982 "a2ui.capabilities" => handle_a2ui_capabilities(&state),
2983 "a2ui.reap" => handle_a2ui_reap(&state).await,
2984 "a2ui.surfaces" => handle_a2ui_surfaces(&state).await,
2985 "a2ui.get" => handle_a2ui_get(&parsed, &state).await,
2986 "a2ui.action" => handle_a2ui_action(&parsed, &state).await,
2987 "a2ui.render_report" => {
2988 handle_a2ui_render_report(&parsed, &state).await
2989 }
2990 "a2ui/subscribe" => handle_a2ui_subscribe(&session, &state).await,
2991 "a2ui/unsubscribe" => handle_a2ui_unsubscribe(&session, &state).await,
2992 "a2ui/replay" => handle_a2ui_replay(&parsed, &state).await,
2993 "automation.run_applescript" => handle_run_applescript(&parsed).await,
2994 "automation.run_powershell" => handle_run_powershell(&parsed).await,
2995 "automation.shortcuts.list" => handle_list_shortcuts(&parsed).await,
2996 "automation.shortcuts.run" => handle_run_shortcut(&parsed).await,
2997 "notifications.local" => handle_local_notification(&parsed).await,
2998 "vision.ocr" => handle_vision_ocr(&parsed).await,
2999 "coder.start" => {
3000 crate::coder::rpc::handle_coder_start(&parsed, &state, &session)
3001 .await
3002 }
3003 "coder.projects.list" => {
3004 crate::coder::rpc::handle_coder_projects_list(&state).await
3005 }
3006 "coder.projects.create" => {
3007 crate::coder::rpc::handle_coder_projects_create(&parsed, &state)
3008 .await
3009 }
3010 "coder.projects.get" => {
3011 crate::coder::rpc::handle_coder_projects_get(&parsed, &state).await
3012 }
3013 "coder.confirm_contract" => {
3014 crate::coder::rpc::handle_coder_confirm_contract(&parsed, &state)
3015 .await
3016 }
3017 "coder.list" => crate::coder::rpc::handle_coder_list(&state).await,
3018 "multiplayer.publish" => {
3022 crate::coder::multiplayer::handle_publish(&parsed, &session).await
3023 }
3024 "multiplayer.start_stage" => {
3025 crate::coder::multiplayer::handle_start_stage(
3026 &parsed, &state, &session,
3027 )
3028 .await
3029 }
3030 "multiplayer.submit_stage" => {
3031 crate::coder::multiplayer::handle_submit_stage(&parsed, &session)
3032 .await
3033 }
3034 "multiplayer.list" => {
3035 crate::coder::multiplayer::handle_list(&parsed, &session).await
3036 }
3037 "multiplayer.get" => {
3038 crate::coder::multiplayer::handle_get(&parsed, &session).await
3039 }
3040 "multiplayer.merge_check" => {
3041 crate::coder::multiplayer::handle_merge_check(&parsed, &session)
3042 .await
3043 }
3044 "coder.get" => {
3045 crate::coder::rpc::handle_coder_get(&parsed, &state).await
3046 }
3047 "coder.subscribe" => {
3048 crate::coder::rpc::handle_coder_subscribe(&parsed, &state, &session)
3049 .await
3050 }
3051 "coder.unsubscribe" => {
3052 crate::coder::rpc::handle_coder_unsubscribe(
3053 &parsed, &state, &session,
3054 )
3055 .await
3056 }
3057 "coder.respond" => {
3058 crate::coder::rpc::handle_coder_respond(&parsed, &state).await
3059 }
3060 "coder.watch" => {
3061 crate::coder::rpc::handle_coder_watch(&parsed, &state, &session)
3062 .await
3063 }
3064 "coder.unwatch" => {
3065 crate::coder::rpc::handle_coder_unwatch(&state, &session).await
3066 }
3067 "coder.revise_contract" => {
3068 crate::coder::rpc::handle_coder_revise_contract(&parsed, &state)
3069 .await
3070 }
3071 "coder.approve_merge" => {
3072 crate::coder::rpc::handle_coder_approve_merge(&parsed, &state).await
3073 }
3074 "coder.cancel" => {
3075 crate::coder::rpc::handle_coder_cancel(&parsed, &state).await
3076 }
3077 "coder.discuss.start" => {
3078 crate::coder::discuss::handle_discuss_start(
3079 &parsed, &state, &session,
3080 )
3081 .await
3082 }
3083 "coder.discuss.send" => {
3084 crate::coder::discuss::handle_discuss_send(
3085 &parsed, &state, &session,
3086 )
3087 .await
3088 }
3089 "coder.discuss.subscribe" => {
3090 crate::coder::discuss::handle_discuss_subscribe(
3091 &parsed, &state, &session,
3092 )
3093 .await
3094 }
3095 "coder.discuss.unsubscribe" => {
3096 crate::coder::discuss::handle_discuss_unsubscribe(
3097 &parsed, &state, &session,
3098 )
3099 .await
3100 }
3101 "coder.discuss.promote" => {
3102 crate::coder::discuss::handle_discuss_promote(
3103 &parsed, &state, &session,
3104 )
3105 .await
3106 }
3107 "coder.discuss.close" => {
3108 crate::coder::discuss::handle_discuss_close(
3109 &parsed, &state, &session,
3110 )
3111 .await
3112 }
3113 "coder.discuss.list" => {
3114 crate::coder::discuss::handle_discuss_list(&state, &session).await
3115 }
3116 "declagents.list" => {
3117 crate::coder::rpc::handle_declagents_list(&state).await
3118 }
3119 "declagents.get" => {
3120 crate::coder::rpc::handle_declagents_get(&parsed, &state).await
3121 }
3122 "declagents.remove" => {
3123 require_host_lifecycle_authority(&session, &state).await?;
3124 crate::coder::rpc::handle_declagents_remove(
3125 &parsed, &state, &session,
3126 )
3127 .await
3128 }
3129 "declagents.set_enabled" => {
3130 require_host_lifecycle_authority(&session, &state).await?;
3131 crate::coder::rpc::handle_declagents_set_enabled(
3132 &parsed, &state, &session,
3133 )
3134 .await
3135 }
3136 "declagents.invoke" => {
3137 crate::coder::rpc::handle_declagents_invoke(
3138 &parsed, &state, &session,
3139 )
3140 .await
3141 }
3142 "declagents.route" => {
3143 crate::coder::rpc::handle_declagents_route(
3144 &parsed, &state, &session,
3145 )
3146 .await
3147 }
3148 "declagents.route_split" => {
3149 crate::coder::rpc::handle_declagents_route_split(
3150 &parsed, &state, &session,
3151 )
3152 .await
3153 }
3154 "declagents.routing_stats" => {
3155 crate::coder::rpc::handle_declagents_routing_stats(&state).await
3156 }
3157 "discovery.resolve" => {
3158 crate::coder::rpc::handle_discovery_resolve(&parsed, &state).await
3159 }
3160 "discovery.route_compose" => {
3161 crate::coder::rpc::handle_discovery_route_compose(&parsed, &state)
3162 .await
3163 }
3164 "discovery.report" => {
3165 crate::coder::rpc::handle_discovery_report(&parsed, &state).await
3166 }
3167 "agents.list" => handle_agents_list(&state).await,
3168 "agents.health" => handle_agents_health(&state).await,
3169 "agents.upsert" => {
3170 handle_agents_upsert(&parsed, &state, &session).await
3171 }
3172 "agents.install" => {
3173 handle_agents_install(&parsed, &state, &session).await
3174 }
3175 "agents.remove" => {
3176 handle_agents_remove(&parsed, &state, &session).await
3177 }
3178 "agents.start" => handle_agents_start(&parsed, &state, &session).await,
3179 "agents.stop" => handle_agents_stop(&parsed, &state, &session).await,
3180 "agents.restart" => {
3181 handle_agents_restart(&parsed, &state, &session).await
3182 }
3183 "agents.wait" => handle_agents_wait(&parsed, &state, bound_agent).await,
3184 "agents.tail_log" => {
3185 handle_agents_tail_log(&parsed, &state, bound_agent).await
3186 }
3187 "agents.list_external" => handle_agents_list_external(&parsed).await,
3188 "agents.detect_external" => {
3189 handle_agents_detect_external(&parsed).await
3190 }
3191 "agents.health_external" => {
3192 handle_agents_health_external(&parsed).await
3193 }
3194 "assistant.identity.get" => handle_assistant_identity_get(&state),
3195 "assistant.identity.set" => {
3196 handle_assistant_identity_set(&parsed, &session, &state).await
3197 }
3198 "assistants.invoke" => {
3199 handle_assistants_invoke(&parsed, &state, &session).await
3200 }
3201 "agents.invoke_external" => {
3202 handle_agents_invoke_external(&parsed, &state, &session).await
3203 }
3204 "agents.chat" => handle_agents_chat(&parsed, &state, &session).await,
3205 "agents.peers" => {
3206 crate::peers::handle_agents_peers(&state, &session).await
3207 }
3208 "agents.message" => {
3209 crate::peers::handle_agents_message(&parsed, &state, &session).await
3210 }
3211 "agents.message.pending" => {
3212 crate::peers::handle_agents_message_pending(&state, &session).await
3213 }
3214 "agents.message.approve" => {
3215 crate::peers::handle_agents_message_approve(
3216 &parsed, &state, &session,
3217 )
3218 .await
3219 }
3220 "agents.chat.cancel" => {
3221 handle_agents_chat_cancel(
3222 &parsed,
3223 &state,
3224 &session.client_id,
3225 session.is_host.load(std::sync::atomic::Ordering::Acquire),
3226 )
3227 .await
3228 }
3229 "agents.chat.approve" => {
3230 handle_agents_chat_approve(
3231 &parsed,
3232 &state,
3233 &session.client_id,
3234 session.is_host.load(std::sync::atomic::Ordering::Acquire),
3235 )
3236 .await
3237 }
3238 "goal.suggest" => handle_goal_suggest(&parsed, &state).await,
3239 "goal.set" => handle_goal_set(&parsed, &state).await,
3240 "goal.status" => handle_goal_status(&parsed, &state).await,
3241 "goal.clear" => handle_goal_clear(&parsed, &state).await,
3242 "foreman.plan" => handle_foreman_plan(&parsed, &state).await,
3247 "foreman.run" => handle_foreman_run(&parsed, &state, &session).await,
3248 "fleet.inventory" => {
3251 crate::fleet::handle_fleet_inventory(&state, &session).await
3252 }
3253 "fleet.composite" => {
3254 crate::fleet::handle_fleet_composite(&parsed, &state, &session)
3255 .await
3256 }
3257 "fleet.worker.get" => crate::fleet::handle_fleet_worker_get().await,
3258 "fleet.worker.set" => {
3259 crate::fleet::handle_fleet_worker_set(&parsed, &session).await
3260 }
3261 "connectors.add" => handle_connectors_add(&parsed, &state).await,
3266 "connectors.add_stdio" => {
3267 handle_connectors_add_stdio(&parsed, &state).await
3268 }
3269 "connectors.authenticate" => {
3270 handle_connectors_authenticate(&parsed, &state).await
3271 }
3272 "connectors.complete_authentication" => {
3273 handle_connectors_complete_authentication(&parsed, &state).await
3274 }
3275 "connectors.list" => handle_connectors_list(&state).await,
3276 "connectors.tools" => handle_connectors_tools(&parsed, &state).await,
3277 "connectors.enable_tools" => {
3278 handle_connectors_enable_tools(&parsed, &state).await
3279 }
3280 "connectors.disable_tools" => {
3281 handle_connectors_disable_tools(&parsed, &state).await
3282 }
3283 "connectors.refresh" => {
3284 handle_connectors_refresh(&parsed, &state).await
3285 }
3286 "connectors.remove" => handle_connectors_remove(&parsed, &state).await,
3287 "message/send"
3294 | "SendMessage"
3295 | "message/stream"
3296 | "SendStreamingMessage"
3297 | "tasks/get"
3298 | "GetTask"
3299 | "tasks/list"
3300 | "ListTasks"
3301 | "tasks/cancel"
3302 | "CancelTask"
3303 | "tasks/resubscribe"
3304 | "SubscribeToTask"
3305 | "tasks/pushNotificationConfig/set"
3306 | "CreateTaskPushNotificationConfig"
3307 | "tasks/pushNotificationConfig/get"
3308 | "GetTaskPushNotificationConfig"
3309 | "tasks/pushNotificationConfig/list"
3310 | "ListTaskPushNotificationConfigs"
3311 | "tasks/pushNotificationConfig/delete"
3312 | "DeleteTaskPushNotificationConfig"
3313 | "agent/getAuthenticatedExtendedCard"
3314 | "GetExtendedAgentCard" => {
3315 handle_a2a_dispatch(method_owned.as_str(), &parsed, &state).await
3316 }
3317 _ => Err(format!("unknown method: {}", method_owned)),
3318 }
3319 };
3320
3321 let result = dispatch_with_server_deadline(
3333 method_owned.as_str(),
3334 &parsed.params,
3335 dispatch,
3336 )
3337 .await;
3338
3339 if method_owned == "session.auth"
3340 && result.is_ok()
3341 && credential_event_is_eligible(
3342 &session,
3343 credential_transport_auth_required,
3344 credential_host_role_required,
3345 )
3346 {
3347 let _ = credential_activation_tx_task.send(true);
3348 let mut ready = credential_ready_rx_task;
3349 while !*ready.borrow_and_update() {
3350 if ready.changed().await.is_err() {
3351 break;
3352 }
3353 }
3354 }
3355
3356 let resp = json_rpc_response_for_handler_result(parsed.id, result);
3357 let _ = send_response(&session.channel, resp).await;
3358 });
3359 }
3360 } else if msg.is_binary() {
3361 let bytes = msg.into_data();
3368 let parsed = match car_ffi_common::voice::binary::parse_frame(&bytes) {
3369 Ok(p) => p,
3370 Err(e) => {
3371 tracing::warn!("binary frame from {} rejected: {}", client_id, e);
3372 continue;
3373 }
3374 };
3375 match parsed.frame_type {
3376 car_ffi_common::voice::binary::FRAME_TYPE_INBOUND_PCM => {
3377 let registry = state.voice_sessions.clone();
3378 let payload_owned = parsed.payload.to_vec();
3379 let session_id_owned = parsed.session_id_hex.clone();
3380 conn_tasks.spawn(async move {
3381 if let Err(e) = car_ffi_common::voice::transcribe_stream_push(
3382 &session_id_owned,
3383 &payload_owned,
3384 registry,
3385 )
3386 .await
3387 {
3388 tracing::warn!(
3389 "binary PCM push to session {} failed: {}",
3390 session_id_owned,
3391 e
3392 );
3393 }
3394 });
3395 }
3396 other => {
3397 tracing::debug!(
3398 "binary frame type {:#04x} from {} not accepted server-side",
3399 other,
3400 client_id
3401 );
3402 }
3403 }
3404 } else if msg.is_close() {
3405 info!("Client {} disconnected", client_id);
3406 break;
3407 }
3408 }
3409
3410 conn_tasks.abort_all();
3415 session.inference_control.abort_all();
3419
3420 session.host.unsubscribe(&client_id).await;
3421 state.supervision.unsubscribe(&client_id).await;
3426 session.host.reap_session_approvals(&client_id).await;
3432 state.a2ui_subscribers.lock().await.remove(&client_id);
3433
3434 let _removed = state.remove_session(&client_id).await;
3446 {
3447 let mut pending = session.channel.pending.lock().await;
3448 pending.clear();
3449 }
3450
3451 Ok(())
3452}
3453
3454async fn send_response(
3455 channel: &WsChannel,
3456 resp: JsonRpcResponse,
3457) -> Result<(), Box<dyn std::error::Error>> {
3458 use futures::SinkExt;
3459 let json = serde_json::to_string(&resp)?;
3460 channel
3461 .write
3462 .lock()
3463 .await
3464 .send(Message::Text(json.into()))
3465 .await?;
3466 Ok(())
3467}
3468
3469async fn handle_host_subscribe(
3472 session: &crate::session::ClientSession,
3473 state: &Arc<ServerState>,
3474) -> Result<Value, String> {
3475 session
3476 .host
3477 .subscribe(&session.client_id, session.channel.clone())
3478 .await;
3479 let event_sequence = session.host.event_sequence();
3483 let snapshot = serde_json::to_value(HostSnapshot {
3484 subscribed: true,
3485 agents: session.host.agents().await,
3486 devices: session.host.devices().await,
3487 approvals: session.host.approvals().await,
3488 events: session.host.events(50).await,
3489 pending_signins: state.browser_views.pending_signins().await,
3490 event_sequence,
3491 identity: Some(daemon_identity(state)),
3492 })
3493 .map_err(|e| e.to_string())?;
3494 Ok(snapshot)
3495}
3496
3497async fn handle_supervision_subscribe(
3506 request: &JsonRpcMessage,
3507 session: &crate::session::ClientSession,
3508 state: &Arc<ServerState>,
3509) -> Result<Value, String> {
3510 let filter: crate::supervision::SupervisionFilter = match request.params.get("filter") {
3511 Some(Value::Null) | None => Default::default(),
3512 Some(v) => serde_json::from_value(v.clone())
3513 .map_err(|e| format!("invalid supervision filter: {e}"))?,
3514 };
3515 state
3516 .supervision
3517 .subscribe(&session.client_id, filter, session.channel.clone())
3518 .await;
3519 Ok(serde_json::json!({
3520 "subscribed": true,
3521 "decision_timeout_ms": state.supervision.timeout().as_millis() as u64,
3522 "supervisors": state.supervision.subscriber_count().await,
3523 }))
3524}
3525
3526async fn handle_supervision_unsubscribe(
3529 session: &crate::session::ClientSession,
3530 state: &Arc<ServerState>,
3531) -> Result<Value, String> {
3532 let was = state.supervision.unsubscribe(&session.client_id).await;
3533 Ok(serde_json::json!({ "subscribed": false, "was_subscribed": was }))
3534}
3535
3536async fn handle_supervision_pending(state: &Arc<ServerState>) -> Result<Value, String> {
3543 Ok(serde_json::json!({ "intents": state.supervision.pending().await }))
3544}
3545
3546async fn handle_supervision_decide(
3554 request: &JsonRpcMessage,
3555 state: &Arc<ServerState>,
3556) -> Result<Value, String> {
3557 let intent_id = request
3558 .params
3559 .get("intent_id")
3560 .and_then(|v| v.as_str())
3561 .ok_or("supervision.decide requires 'intent_id'")?;
3562 let decision_value = request
3563 .params
3564 .get("decision")
3565 .ok_or("supervision.decide requires 'decision'")?;
3566 let decision: crate::supervision::SupervisionDecision =
3567 serde_json::from_value(decision_value.clone())
3568 .map_err(|e| format!("invalid supervision decision: {e}"))?;
3569 state.supervision.decide(intent_id, decision).await?;
3570 Ok(serde_json::json!({ "decided": true }))
3571}
3572
3573fn daemon_identity(state: &Arc<ServerState>) -> car_proto::HostIdentity {
3581 let (manifest_path, manifest_role) = if let Some(p) = state.observer_manifest_path() {
3588 (
3589 Some(p.to_string_lossy().into_owned()),
3590 car_proto::HostManifestRole::Observer,
3591 )
3592 } else if let Some(s) = state.supervisor_if_installed() {
3593 (
3594 Some(s.manifest_path().to_string_lossy().into_owned()),
3595 car_proto::HostManifestRole::Owner,
3596 )
3597 } else {
3598 (None, car_proto::HostManifestRole::None)
3599 };
3600 car_proto::HostIdentity {
3601 version: env!("CARGO_PKG_VERSION").to_string(),
3602 pid: std::process::id(),
3603 manifest_path,
3604 manifest_role,
3605 parslee: state
3606 .parslee_session
3607 .get()
3608 .map(|session| session.identity.clone()),
3609 }
3610}
3611
3612async fn handle_parslee_auth(state: &ServerState) -> Result<Value, String> {
3623 let (session, api_base) = crate::parslee_auth::load_or_refresh_with_authority()
3624 .await?
3625 .ok_or_else(|| "Parslee account not authenticated; run `car auth login`".to_string())?;
3626 activate_parslee_session(state, session.clone(), api_base);
3627 Ok(serde_json::json!({
3628 "authenticated": true,
3629 "token_type": "Bearer",
3630 "access_token": session.access_token,
3631 "authorization_header": format!("Bearer {}", session.access_token),
3632 "identity": session.identity,
3633 }))
3634}
3635
3636async fn dispatch_daemon_owned_auth(
3670 method: &str,
3671 req: &JsonRpcMessage,
3672 state: &ServerState,
3673) -> Result<Value, HandlerFailure> {
3674 match method {
3675 "auth.authority_hint" => serde_json::to_value(car_auth::credential_authority_hint())
3676 .map_err(|error| HandlerFailure::Dispatch(error.to_string())),
3677 "auth.start" => handle_auth_start(req)
3678 .await
3679 .map_err(|error| classified_auth_failure(method, error)),
3680 "auth.complete" => accept_auth_completion(req, state)
3681 .await
3682 .map_err(|error| classified_auth_failure(method, error)),
3683 "auth.completion_status" => handle_auth_completion_status(req, state)
3684 .await
3685 .map_err(|error| classified_auth_failure(method, error)),
3686 "auth.snapshot" => handle_auth_snapshot()
3687 .await
3688 .map_err(HandlerFailure::from_dispatch),
3689 "auth.status" => handle_auth_status(req, state)
3690 .await
3691 .map_err(HandlerFailure::from_dispatch),
3692 "auth.switch_org" => handle_auth_switch_org(req)
3693 .await
3694 .map_err(HandlerFailure::from_dispatch),
3695 "auth.accounts" => handle_auth_accounts(req)
3696 .await
3697 .map_err(HandlerFailure::from_dispatch),
3698 "auth.switch_account" => handle_auth_switch_account(req)
3699 .await
3700 .map_err(HandlerFailure::from_dispatch),
3701 "auth.remove_account" => handle_auth_remove_account(req)
3702 .await
3703 .map_err(HandlerFailure::from_dispatch),
3704 "auth.logout" => handle_auth_logout()
3705 .await
3706 .map_err(HandlerFailure::from_dispatch),
3707 _ => Err(HandlerFailure::Dispatch(format!(
3708 "unknown daemon-owned auth method: {method}"
3709 ))),
3710 }
3711}
3712
3713async fn handle_auth_start(req: &JsonRpcMessage) -> Result<Value, car_auth::AuthOperationError> {
3714 let environment_api_base = std::env::var(car_auth::PARSLEE_API_BASE_KEY).ok();
3717 let api_base = select_auth_start_api_base(&req.params, environment_api_base.as_deref());
3718 let client_id = str_or(&req.params, "client_id", "parslee-car");
3719 let redirect_uri =
3720 require_str(&req.params, "redirect_uri").map_err(car_auth::AuthOperationError::Terminal)?;
3721 let provider = opt_str(&req.params, "provider");
3722 let prompt = opt_str(&req.params, "prompt");
3725 let state = car_auth::new_state();
3726 let attempt_id = uuid::Uuid::new_v4().simple().to_string();
3727 let verifier = car_auth::pkce_verifier();
3728 let challenge = car_auth::pkce_challenge(&verifier);
3729 let url = car_auth::authorize_url(
3730 &api_base,
3731 client_id,
3732 redirect_uri,
3733 &state,
3734 &challenge,
3735 provider,
3736 prompt,
3737 )
3738 .map_err(car_auth::AuthOperationError::Terminal)?;
3739 let lease = car_auth::reserve_login_attempt_classified(&attempt_id).await?;
3743 Ok(serde_json::json!({
3744 "authorize_url": url,
3745 "state": state,
3746 "verifier": verifier,
3747 "attempt_id": attempt_id,
3748 "expires_at_unix_ms": lease.attempt_expires_at_unix_ms,
3749 }))
3750}
3751
3752fn select_auth_start_api_base(params: &Value, environment_api_base: Option<&str>) -> String {
3753 opt_str(params, "api_base")
3754 .map(str::trim)
3755 .filter(|value| !value.is_empty())
3756 .map(str::to_string)
3757 .or_else(|| {
3758 environment_api_base
3759 .map(str::trim)
3760 .filter(|value| !value.is_empty())
3761 .map(str::to_string)
3762 })
3763 .unwrap_or_else(|| car_auth::DEFAULT_API_BASE.to_string())
3764 .trim_end_matches('/')
3765 .to_string()
3766}
3767
3768fn auth_completion_value(record: &car_auth::AuthCompletionRecord) -> Value {
3769 let session = record
3770 .session
3771 .as_deref()
3772 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
3773 serde_json::json!({
3774 "state": "complete",
3775 "attempt_id": record.attempt_id,
3776 "generation": record.generation,
3777 "account_id": record.account_id,
3778 "session": session,
3779 })
3780}
3781
3782#[derive(Clone)]
3783struct AuthCompletionRequest {
3784 api_base: String,
3785 client_id: String,
3786 redirect_uri: String,
3787 code: String,
3788 verifier: String,
3789 attempt_id: String,
3790}
3791
3792fn parse_auth_completion_request(req: &JsonRpcMessage) -> Result<AuthCompletionRequest, String> {
3793 Ok(AuthCompletionRequest {
3794 api_base: car_auth::api_base(opt_str(&req.params, "api_base")),
3795 client_id: str_or(&req.params, "client_id", "parslee-car").to_string(),
3796 redirect_uri: require_str(&req.params, "redirect_uri")?.to_string(),
3797 code: require_str(&req.params, "code")?.to_string(),
3798 verifier: require_str(&req.params, "verifier")?.to_string(),
3799 attempt_id: require_str(&req.params, "attempt_id")?.to_string(),
3800 })
3801}
3802
3803async fn accept_auth_completion(
3804 req: &JsonRpcMessage,
3805 state: &ServerState,
3806) -> Result<Value, car_auth::AuthOperationError> {
3807 let request =
3808 parse_auth_completion_request(req).map_err(car_auth::AuthOperationError::Terminal)?;
3809 let attempt_id = request.attempt_id.clone();
3810 let task_attempt_id = attempt_id.clone();
3811 let lease =
3812 car_auth::claim_login_attempt_classified(&attempt_id, &state.auth_completion_owner_id)
3813 .await?;
3814 state
3815 .spawn_durable_task(format!("auth.complete:{attempt_id}"), async move {
3816 if let Err(error) = complete_auth_completion(request, lease).await {
3817 tracing::warn!(attempt_id = %task_attempt_id, error = %error, "Parslee login completion failed");
3818 }
3819 })
3820 .await;
3821 Ok(serde_json::json!({
3822 "state": "accepted",
3823 "attempt_id": attempt_id,
3824 }))
3825}
3826
3827async fn complete_auth_completion(
3828 request: AuthCompletionRequest,
3829 lease: car_auth::LoginAttemptLease,
3830) -> Result<Value, String> {
3831 let worker_lease = lease.clone();
3835 let result = match tokio::spawn(complete_claimed_auth_completion(request, worker_lease)).await {
3836 Ok(result) => result,
3837 Err(error) => Err(format!(
3838 "Parslee login worker stopped unexpectedly: {error}"
3839 )),
3840 };
3841 if result.is_err() {
3842 match car_auth::fail_login_attempt(
3843 &lease,
3844 car_auth::AuthAttemptFailure::completion_failed(),
3845 )
3846 .await
3847 {
3848 Ok(true) => {}
3849 Ok(false) => {
3850 tracing::debug!(
3851 attempt_id = %lease.attempt_id,
3852 "stale auth worker exit could not replace a newer attempt"
3853 );
3854 }
3855 Err(error) => {
3856 tracing::warn!(
3857 attempt_id = %lease.attempt_id,
3858 error = %error,
3859 "failed to persist terminal auth worker lifecycle"
3860 );
3861 }
3862 }
3863 }
3864 result
3865}
3866
3867async fn complete_claimed_auth_completion(
3868 request: AuthCompletionRequest,
3869 lease: car_auth::LoginAttemptLease,
3870) -> Result<Value, String> {
3871 let (token, completion_session) =
3872 auth_completion_network_with_deadline(car_auth::AUTH_COMPLETION_NETWORK_DEADLINE, async {
3873 let token = car_auth::exchange_code(
3874 &request.api_base,
3875 &request.client_id,
3876 &request.redirect_uri,
3877 &request.code,
3878 &request.verifier,
3879 )
3880 .await?;
3881 let session = fetch_completion_session(&request.api_base, &token.access_token).await?;
3882 Ok((token, session))
3883 })
3884 .await?;
3885
3886 let record =
3889 car_auth::commit_login(&request.api_base, &token, &completion_session, Some(lease)).await?;
3890
3891 Ok(auth_completion_value(&record))
3892}
3893
3894async fn auth_completion_network_with_deadline<T, F>(
3895 deadline: std::time::Duration,
3896 operation: F,
3897) -> Result<T, String>
3898where
3899 F: std::future::Future<Output = Result<T, String>>,
3900{
3901 tokio::time::timeout(deadline, operation)
3902 .await
3903 .map_err(|_| {
3904 format!(
3905 "Parslee login network phase timed out after {}s; no credentials were saved",
3906 deadline.as_secs()
3907 )
3908 })?
3909}
3910
3911async fn fetch_completion_session(api_base: &str, access_token: &str) -> Result<String, String> {
3916 match car_auth::fetch_status_with_access(api_base, access_token).await {
3917 Ok(session) => Ok(session),
3918 Err(first_error) => {
3919 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3920 car_auth::fetch_status_with_access(api_base, access_token)
3921 .await
3922 .map_err(|second_error| {
3923 format!(
3924 "OAuth authorization code was consumed, but CAR could not validate the signed-in account after two bounded session checks. No credentials were saved; check connectivity and sign in again. First check: {first_error}; second check: {second_error}"
3925 )
3926 })
3927 }
3928 }
3929}
3930
3931async fn handle_auth_completion_status(
3936 req: &JsonRpcMessage,
3937 state: &ServerState,
3938) -> Result<Value, car_auth::AuthOperationError> {
3939 let attempt_id =
3940 require_str(&req.params, "attempt_id").map_err(car_auth::AuthOperationError::Terminal)?;
3941 let mut value = serde_json::to_value(
3942 car_auth::auth_completion_status_classified(attempt_id, &state.auth_completion_owner_id)
3943 .await?,
3944 )
3945 .map_err(|error| {
3946 car_auth::AuthOperationError::Terminal(format!("serialize auth completion status: {error}"))
3947 })?;
3948 if let Some(raw_session) = value.get("session").and_then(Value::as_str) {
3949 let decoded = serde_json::from_str::<Value>(raw_session).unwrap_or(Value::Null);
3950 value["session"] = decoded;
3951 }
3952 Ok(value)
3953}
3954
3955async fn handle_auth_snapshot() -> Result<Value, String> {
3958 serde_json::to_value(car_auth::local_auth_snapshot().await?).map_err(|e| e.to_string())
3959}
3960
3961async fn handle_auth_status(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
3962 let mode = if req
3963 .params
3964 .get("retry_keychain_access")
3965 .and_then(Value::as_bool)
3966 == Some(true)
3967 {
3968 car_auth::CredentialReadMode::Retry
3969 } else {
3970 car_auth::CredentialReadMode::Use
3971 };
3972 let Some(mut credential) = car_auth::resolve_credential(mode)
3973 .await
3974 .map_err(|error| error.to_string())?
3975 else {
3976 return Ok(serde_json::json!({ "authenticated": false }));
3977 };
3978
3979 let session_json =
3982 match car_auth::fetch_status_with_access(&credential.api_base, &credential.access_token)
3983 .await
3984 {
3985 Ok(session) => session,
3986 Err(error) if error.contains("HTTP 401") => {
3987 let Some(refreshed) = car_auth::refresh_credential()
3988 .await
3989 .map_err(|refresh_error| refresh_error.to_string())?
3990 else {
3991 return Err(error);
3992 };
3993 let session = car_auth::fetch_status_with_access(
3994 &refreshed.api_base,
3995 &refreshed.access_token,
3996 )
3997 .await?;
3998 credential = refreshed;
3999 session
4000 }
4001 Err(error) => return Err(error),
4002 };
4003 if let Ok(session) =
4004 crate::parslee_auth::session_from_status(credential.access_token, &session_json)
4005 {
4006 activate_parslee_session(state, session, credential.api_base);
4007 }
4008 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4009 Ok(serde_json::json!({ "authenticated": true, "session": session }))
4010}
4011
4012fn activate_parslee_session(
4013 state: &ServerState,
4014 session: crate::parslee_auth::ParsleeSession,
4015 api_base: String,
4016) {
4017 let email = session
4018 .identity
4019 .email
4020 .clone()
4021 .unwrap_or_else(|| "<unknown>".to_string());
4022 let org = session
4023 .identity
4024 .active_organization
4025 .clone()
4026 .unwrap_or_else(|| "<none>".to_string());
4027 let access_token = session.access_token.clone();
4028 if state.install_parslee_session(session).is_err() {
4029 return;
4030 }
4031
4032 info!(
4033 email = %email,
4034 active_org = %org,
4035 "Parslee account auth activated for this CAR daemon"
4036 );
4037 let Some(runtime_url) = state.mobile_registration_url.get().cloned() else {
4038 return;
4039 };
4040 let token = state.auth_token.get().cloned();
4041 tokio::spawn(async move {
4042 let registration =
4043 crate::mobile_runtime::MobileRuntimeRegistration::new(runtime_url, token);
4044 match crate::mobile_runtime::register(&api_base, &access_token, ®istration).await {
4045 Ok(()) => info!("registered this CAR machine for Parslee mobile discovery"),
4046 Err(error) => tracing::warn!(
4047 error = %error,
4048 "failed to register this CAR machine for Parslee mobile discovery; \
4049 local Parslee Core runtime remains available"
4050 ),
4051 }
4052 });
4053}
4054
4055async fn handle_auth_logout() -> Result<Value, String> {
4056 car_auth::logout().await?;
4057 Ok(serde_json::json!({ "ok": true }))
4058}
4059
4060fn handle_assistant_identity_get(state: &ServerState) -> Result<Value, String> {
4111 let identity = state.identity_store.load()?;
4112 Ok(assistant_identity_wire(&identity))
4113}
4114
4115async fn handle_assistant_identity_set(
4123 req: &JsonRpcMessage,
4124 session: &crate::session::ClientSession,
4125 state: &Arc<ServerState>,
4126) -> Result<Value, String> {
4127 require_approval_authority(session, state)?;
4128
4129 let store = &state.identity_store;
4130 let mut identity = store.load()?;
4131
4132 if let Some(name) = req.params.get("name").and_then(Value::as_str) {
4136 identity.set_name(name)?;
4137 }
4138 if let Some(Value::Array(items)) = req.params.get("spellings") {
4139 identity.spellings = car_identity::validate_spellings(
4140 items
4141 .iter()
4142 .filter_map(|v| v.as_str().map(str::to_string))
4143 .collect(),
4144 )?;
4145 }
4146 match req.params.get("user_name") {
4147 Some(Value::Null) => identity = identity.with_user_name(None)?,
4148 Some(Value::String(user)) => {
4149 identity = identity.with_user_name(Some(user.clone()))?;
4150 }
4151 _ => {}
4152 }
4153 match req.params.get("role") {
4154 Some(Value::Null) => identity = identity.with_role(None),
4155 Some(Value::String(role)) => identity = identity.with_role(Some(role.clone())),
4156 Some(_) => return Err("assistant.identity.set role must be a string or null".into()),
4157 None => {}
4158 }
4159 match req.params.get("focus_areas") {
4160 Some(Value::Null) => identity = identity.with_focus_areas(Vec::new()),
4161 Some(value @ Value::Array(_)) => {
4162 let parsed = serde_json::from_value(value.clone());
4163 let focus_areas: Vec<car_identity::FocusArea> = parsed
4164 .map_err(|error| format!("invalid assistant.identity.set focus_areas: {error}"))?;
4165 identity = identity.with_focus_areas(focus_areas);
4166 }
4167 Some(_) => {
4168 return Err("assistant.identity.set focus_areas must be an array or null".into())
4169 }
4170 None => {}
4171 }
4172 match req.params.get("apps") {
4173 Some(Value::Null) => identity = identity.with_apps(Vec::new()),
4174 Some(value @ Value::Array(_)) => {
4175 let apps: Vec<String> = serde_json::from_value(value.clone())
4176 .map_err(|error| format!("invalid assistant.identity.set apps: {error}"))?;
4177 identity = identity.with_apps(apps);
4178 }
4179 Some(_) => return Err("assistant.identity.set apps must be an array or null".into()),
4180 None => {}
4181 }
4182 identity.touch();
4183 store.save(&identity)?;
4184
4185 let effective = session.effective_memgine().await;
4189 {
4190 let mut engine = effective.lock().await;
4191 crate::session::mirror_identity_into_memgine(&mut engine, &identity);
4192 }
4193 if let Some(shared) = state.shared_memgine.as_ref() {
4197 if !Arc::ptr_eq(shared, &effective) {
4198 let mut engine = shared.lock().await;
4199 crate::session::mirror_identity_into_memgine(&mut engine, &identity);
4200 }
4201 }
4202
4203 let wire = assistant_identity_wire(&identity);
4204 session
4205 .host
4206 .record_event(
4207 "assistant.identity.changed",
4208 None,
4209 format!("the assistant now answers to {}", identity.name),
4210 wire.clone(),
4211 )
4212 .await;
4213 Ok(wire)
4214}
4215
4216fn assistant_identity_wire(identity: &car_identity::AssistantIdentity) -> Value {
4223 serde_json::json!({
4224 "name": identity.name,
4225 "spellings": identity.spellings,
4226 "aliases": identity.aliases(),
4227 "user_name": identity.user_name,
4228 "role": identity.role,
4229 "focus_areas": identity.focus_areas,
4230 "apps": identity.apps,
4231 "brand": car_identity::BRAND_NAME,
4232 "updated_at_unix": identity.updated_at_unix,
4233 })
4234}
4235
4236fn handle_openrouter_status(
4237 session: &crate::session::ClientSession,
4238 state: &ServerState,
4239) -> Result<Value, String> {
4240 require_approval_authority(session, state)?;
4241 serde_json::to_value(crate::openrouter_auth::status()).map_err(|e| e.to_string())
4242}
4243
4244async fn handle_openrouter_auth_start(
4245 req: &JsonRpcMessage,
4246 session: &crate::session::ClientSession,
4247 state: &ServerState,
4248) -> Result<Value, String> {
4249 require_approval_authority(session, state)?;
4250 let authorization_base_url = opt_str(&req.params, "authorization_base_url");
4251 let exchange_url = opt_str(&req.params, "exchange_url");
4252 let timeout_seconds = req.params.get("timeout_seconds").and_then(Value::as_u64);
4253 let started =
4254 crate::openrouter_auth::start(authorization_base_url, exchange_url, timeout_seconds)
4255 .await?;
4256 serde_json::to_value(started).map_err(|e| e.to_string())
4257}
4258
4259fn handle_openrouter_auth_cancel(
4260 req: &JsonRpcMessage,
4261 session: &crate::session::ClientSession,
4262 state: &ServerState,
4263) -> Result<Value, String> {
4264 require_approval_authority(session, state)?;
4265 let status = crate::openrouter_auth::cancel(opt_str(&req.params, "flow_id"));
4266 serde_json::to_value(status).map_err(|e| e.to_string())
4267}
4268
4269async fn handle_openrouter_disconnect(
4270 session: &crate::session::ClientSession,
4271 state: &ServerState,
4272) -> Result<Value, String> {
4273 require_approval_authority(session, state)?;
4274 serde_json::to_value(crate::openrouter_auth::disconnect().await?).map_err(|e| e.to_string())
4275}
4276
4277async fn handle_auth_accounts(req: &JsonRpcMessage) -> Result<Value, String> {
4280 let api_base = opt_str(&req.params, "api_base");
4281 let accounts = car_auth::list_accounts(api_base).await?;
4282 Ok(serde_json::json!({ "accounts": accounts }))
4283}
4284
4285async fn handle_auth_switch_account(req: &JsonRpcMessage) -> Result<Value, String> {
4288 let account_id = req
4289 .params
4290 .get("account_id")
4291 .and_then(|v| v.as_str())
4292 .ok_or("invalid params: account_id is required")?;
4293 let api_base = opt_str(&req.params, "api_base");
4294 car_auth::switch_account(account_id).await?;
4295 let _ = car_auth::access_token_refreshing().await;
4297 match car_auth::fetch_status(api_base).await? {
4298 Some(session_json) => {
4299 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4300 Ok(serde_json::json!({ "authenticated": true, "session": session }))
4301 }
4302 None => Ok(serde_json::json!({ "authenticated": false })),
4303 }
4304}
4305
4306async fn handle_auth_remove_account(req: &JsonRpcMessage) -> Result<Value, String> {
4309 let account_id = req
4310 .params
4311 .get("account_id")
4312 .and_then(|v| v.as_str())
4313 .ok_or("invalid params: account_id is required")?;
4314 let accounts = car_auth::remove_account(account_id).await?;
4315 Ok(serde_json::json!({ "ok": true, "accounts": accounts }))
4316}
4317
4318async fn handle_auth_switch_org(req: &JsonRpcMessage) -> Result<Value, String> {
4323 let org_id = req
4324 .params
4325 .get("organization_id")
4326 .and_then(|v| v.as_str())
4327 .ok_or("invalid params: organization_id is required")?;
4328 let api_base = opt_str(&req.params, "api_base");
4329 car_auth::switch_org(api_base, org_id).await?;
4330 match car_auth::fetch_status(api_base).await? {
4331 Some(session_json) => {
4332 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4333 Ok(serde_json::json!({ "authenticated": true, "session": session }))
4334 }
4335 None => Ok(serde_json::json!({ "authenticated": false })),
4336 }
4337}
4338
4339async fn handle_host_agents(
4340 session: &crate::session::ClientSession,
4341 state: &Arc<ServerState>,
4342) -> Result<Value, String> {
4343 let mut agents = session.host.agents().await;
4346
4347 if let Ok(supervisor) = state.supervisor() {
4358 use car_registry::supervisor::AgentStatus;
4359 let have: std::collections::HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
4360 for m in supervisor.list().await {
4361 if m.spec.capabilities.is_empty() || have.contains(&m.spec.id) {
4362 continue;
4363 }
4364 let status = match m.status {
4365 AgentStatus::Running => car_proto::HostAgentStatus::Running,
4366 AgentStatus::Starting => car_proto::HostAgentStatus::Idle,
4367 AgentStatus::Backoff | AgentStatus::Errored => car_proto::HostAgentStatus::Errored,
4368 AgentStatus::Stopped => car_proto::HostAgentStatus::Stopped,
4369 };
4370 agents.push(car_proto::HostAgent {
4371 id: m.spec.id.clone(),
4372 name: m.spec.name.clone(),
4373 kind: "supervised".to_string(),
4374 capabilities: m.spec.capabilities.clone(),
4375 project: None,
4376 session_id: None,
4377 status,
4378 current_task: None,
4379 pid: m.pid,
4380 display: Default::default(),
4381 updated_at: chrono::Utc::now(),
4382 metadata: Value::Null,
4383 });
4384 }
4385 }
4386
4387 serde_json::to_value(agents).map_err(|e| e.to_string())
4388}
4389
4390async fn handle_host_events(
4391 req: &JsonRpcMessage,
4392 session: &crate::session::ClientSession,
4393) -> Result<Value, String> {
4394 let limit = req
4395 .params
4396 .get("limit")
4397 .and_then(|v| v.as_u64())
4398 .unwrap_or(100) as usize;
4399 serde_json::to_value(session.host.events(limit).await).map_err(|e| e.to_string())
4400}
4401
4402async fn handle_host_approvals(session: &crate::session::ClientSession) -> Result<Value, String> {
4403 serde_json::to_value(session.host.approvals().await).map_err(|e| e.to_string())
4404}
4405
4406async fn handle_a2ui_apply(
4407 req: &JsonRpcMessage,
4408 state: &Arc<ServerState>,
4409) -> Result<Value, String> {
4410 #[derive(Deserialize)]
4411 struct Params {
4412 #[serde(default)]
4413 envelope: Option<car_a2ui::A2uiEnvelope>,
4414 #[serde(default)]
4415 message: Option<car_a2ui::A2uiEnvelope>,
4416 }
4417
4418 let envelope = if req.params.get("createSurface").is_some()
4419 || req.params.get("updateComponents").is_some()
4420 || req.params.get("updateDataModel").is_some()
4421 || req.params.get("deleteSurface").is_some()
4422 {
4423 serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4424 .map_err(|e| e.to_string())?
4425 } else {
4426 match serde_json::from_value::<Params>(req.params.clone()) {
4427 Ok(params) => params
4428 .envelope
4429 .or(params.message)
4430 .ok_or_else(|| "`a2ui.apply` requires an A2UI envelope".to_string())?,
4431 Err(_) => serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4432 .map_err(|e| e.to_string())?,
4433 }
4434 };
4435
4436 apply_a2ui_envelope(state, envelope, None, None).await
4437}
4438
4439async fn handle_a2ui_ingest(
4440 req: &JsonRpcMessage,
4441 state: &Arc<ServerState>,
4442) -> Result<Value, String> {
4443 #[derive(Deserialize)]
4444 #[serde(rename_all = "camelCase")]
4445 struct Params {
4446 #[serde(default)]
4447 endpoint: Option<String>,
4448 #[serde(default)]
4449 a2a_endpoint: Option<String>,
4450 #[serde(default)]
4451 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4452 #[serde(default)]
4453 route_auth: Option<A2aRouteAuth>,
4454 #[serde(default)]
4455 allow_untrusted_endpoint: bool,
4456 }
4457
4458 let params = serde_json::from_value::<Params>(req.params.clone()).unwrap_or(Params {
4459 endpoint: None,
4460 a2a_endpoint: None,
4461 owner: None,
4462 route_auth: None,
4463 allow_untrusted_endpoint: false,
4464 });
4465 let payload = req.params.get("payload").unwrap_or(&req.params);
4466 state
4467 .a2ui
4468 .validate_payload(payload)
4469 .map_err(|e| e.to_string())?;
4470 let envelopes = car_a2ui::envelopes_from_value(payload).map_err(|e| e.to_string())?;
4471 if envelopes.is_empty() {
4472 return Err("no A2UI envelopes found in payload".into());
4473 }
4474 let endpoint = params.endpoint.or(params.a2a_endpoint);
4475 let endpoint = trusted_route_endpoint(endpoint, params.allow_untrusted_endpoint);
4476 let owner = params
4477 .owner
4478 .or_else(|| car_a2ui::owner_from_value(payload))
4479 .map(|owner| match endpoint.clone() {
4480 Some(endpoint) => owner.with_endpoint(Some(endpoint)),
4481 None => owner,
4482 });
4483
4484 let mut results = Vec::new();
4485 for envelope in envelopes {
4486 let value =
4487 apply_a2ui_envelope(state, envelope, owner.clone(), params.route_auth.clone()).await?;
4488 results.push(value);
4489 }
4490 Ok(serde_json::json!({ "applied": results }))
4491}
4492
4493async fn apply_a2ui_envelope(
4494 state: &Arc<ServerState>,
4495 envelope: car_a2ui::A2uiEnvelope,
4496 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4497 route_auth: Option<A2aRouteAuth>,
4498) -> Result<Value, String> {
4499 let result = state
4500 .a2ui
4501 .apply_with_owner(envelope, owner)
4502 .await
4503 .map_err(|e| e.to_string())?;
4504 update_a2ui_route_auth(state, &result, route_auth).await;
4505 let kind = if result.deleted {
4506 "a2ui.surface_deleted"
4507 } else {
4508 "a2ui.surface_updated"
4509 };
4510 let message = if result.deleted {
4511 format!("A2UI surface {} deleted", result.surface_id)
4512 } else {
4513 format!("A2UI surface {} updated", result.surface_id)
4514 };
4515 let payload = serde_json::to_value(&result).map_err(|e| e.to_string())?;
4516 state
4517 .host
4518 .record_event(kind, None, message, payload.clone())
4519 .await;
4520 broadcast_a2ui_event(state, kind, &payload).await;
4524 serde_json::to_value(result).map_err(|e| e.to_string())
4525}
4526
4527async fn broadcast_a2ui_event(state: &Arc<ServerState>, kind: &str, result: &Value) {
4528 use futures::SinkExt;
4529 use tokio_tungstenite::tungstenite::Message;
4530 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
4531 .a2ui_subscribers
4532 .lock()
4533 .await
4534 .values()
4535 .cloned()
4536 .collect();
4537 if subscribers.is_empty() {
4538 return;
4539 }
4540 let Ok(json) = serde_json::to_string(&serde_json::json!({
4541 "jsonrpc": "2.0",
4542 "method": "a2ui.event",
4543 "params": {
4544 "kind": kind,
4545 "result": result,
4546 },
4547 })) else {
4548 return;
4549 };
4550 for channel in subscribers {
4551 let _ = channel
4552 .write
4553 .lock()
4554 .await
4555 .send(Message::Text(json.clone().into()))
4556 .await;
4557 }
4558}
4559
4560async fn update_a2ui_route_auth(
4561 state: &Arc<ServerState>,
4562 result: &car_a2ui::A2uiApplyResult,
4563 route_auth: Option<A2aRouteAuth>,
4564) {
4565 let mut auth = state.a2ui_route_auth.lock().await;
4566 if result.deleted {
4567 auth.remove(&result.surface_id);
4568 return;
4569 }
4570
4571 let has_route_endpoint = result
4572 .surface
4573 .as_ref()
4574 .and_then(|surface| surface.owner.as_ref())
4575 .and_then(|owner| owner.endpoint.as_ref())
4576 .is_some();
4577 match (has_route_endpoint, route_auth) {
4578 (true, Some(route_auth)) => {
4579 auth.insert(result.surface_id.clone(), route_auth);
4580 }
4581 _ => {
4582 auth.remove(&result.surface_id);
4583 }
4584 }
4585}
4586
4587fn handle_a2ui_capabilities(state: &Arc<ServerState>) -> Result<Value, String> {
4588 serde_json::to_value(state.a2ui.capabilities()).map_err(|e| e.to_string())
4589}
4590
4591async fn handle_a2ui_reap(state: &Arc<ServerState>) -> Result<Value, String> {
4592 let removed = state.a2ui.reap_expired(chrono::Utc::now()).await;
4593 if !removed.is_empty() {
4594 let mut auth = state.a2ui_route_auth.lock().await;
4595 for surface_id in &removed {
4596 auth.remove(surface_id);
4597 }
4598 }
4599 Ok(serde_json::json!({ "removed": removed }))
4600}
4601
4602async fn handle_a2ui_surfaces(state: &Arc<ServerState>) -> Result<Value, String> {
4603 serde_json::to_value(state.a2ui.list().await).map_err(|e| e.to_string())
4604}
4605
4606async fn handle_a2ui_get(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
4607 let surface_id = req
4608 .params
4609 .get("surface_id")
4610 .or_else(|| req.params.get("surfaceId"))
4611 .and_then(Value::as_str)
4612 .ok_or_else(|| "`a2ui.get` requires surface_id".to_string())?;
4613 serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4614}
4615
4616async fn handle_a2ui_subscribe(
4622 session: &crate::session::ClientSession,
4623 state: &Arc<ServerState>,
4624) -> Result<Value, String> {
4625 state
4626 .a2ui_subscribers
4627 .lock()
4628 .await
4629 .insert(session.client_id.clone(), session.channel.clone());
4630 Ok(serde_json::json!({ "subscribed": true }))
4631}
4632
4633async fn handle_a2ui_unsubscribe(
4637 session: &crate::session::ClientSession,
4638 state: &Arc<ServerState>,
4639) -> Result<Value, String> {
4640 state
4641 .a2ui_subscribers
4642 .lock()
4643 .await
4644 .remove(&session.client_id);
4645 Ok(serde_json::json!({ "subscribed": false }))
4646}
4647
4648async fn handle_a2ui_replay(
4655 req: &JsonRpcMessage,
4656 state: &Arc<ServerState>,
4657) -> Result<Value, String> {
4658 let surface_id = req
4659 .params
4660 .get("surface_id")
4661 .or_else(|| req.params.get("surfaceId"))
4662 .and_then(Value::as_str)
4663 .ok_or_else(|| "`a2ui/replay` requires surface_id".to_string())?;
4664 serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4665}
4666
4667async fn handle_a2ui_action(
4668 req: &JsonRpcMessage,
4669 state: &Arc<ServerState>,
4670) -> Result<Value, String> {
4671 let action: car_a2ui::ClientAction =
4672 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4673 let owner = state.a2ui.owner(&action.surface_id).await;
4674
4675 let action_result = serde_json::json!({
4685 "surfaceId": action.surface_id,
4686 "action": action,
4687 "owner": owner,
4688 });
4689 broadcast_a2ui_event(state, "a2ui.action", &action_result).await;
4690
4691 let route = route_a2ui_action(state, &action, owner.clone()).await;
4692 let payload = serde_json::json!({
4693 "action": action,
4694 "owner": owner,
4695 "route": route,
4696 });
4697 let event = state
4698 .host
4699 .record_event(
4700 "a2ui.action",
4701 None,
4702 format!(
4703 "A2UI action {} from {}",
4704 action.name, action.source_component_id
4705 ),
4706 payload,
4707 )
4708 .await;
4709 Ok(serde_json::json!({
4710 "event": event,
4711 "route": route,
4712 }))
4713}
4714
4715async fn handle_a2ui_render_report(
4722 req: &JsonRpcMessage,
4723 state: &Arc<ServerState>,
4724) -> Result<Value, String> {
4725 let report: car_a2ui::RenderReport =
4729 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4730 let payload = serde_json::to_value(&report).map_err(|e| e.to_string())?;
4731 let kind = "a2ui.render_report";
4732 let message = format!("A2UI render report for surface {}", report.surface_id);
4733 let event = state
4734 .host
4735 .record_event(kind, None, message, payload.clone())
4736 .await;
4737 broadcast_a2ui_event(state, kind, &payload).await;
4738
4739 if let Some(surface) = state.a2ui.get(&report.surface_id).await {
4747 if !state.ui_agent_budget.try_consume(&report.surface_id) {
4753 tracing::warn!(
4754 surface_id = %report.surface_id,
4755 count = state.ui_agent_budget.count(&report.surface_id),
4756 max = state.ui_agent_budget.max(),
4757 "ui-agent iteration budget exhausted; skipping agent invocation"
4758 );
4759 return Ok(serde_json::json!({ "event": event }));
4760 }
4761 match state.ui_agent.on_render_report(&report, &surface) {
4765 car_ui_agent::Decision::Patch {
4766 envelope,
4767 strategy_id,
4768 patch_hash,
4769 elapsed_ns,
4770 } => {
4771 if !state
4779 .ui_agent_oscillation
4780 .check_and_record(&report.surface_id, patch_hash)
4781 {
4782 tracing::warn!(
4783 surface_id = %report.surface_id,
4784 strategy = %strategy_id,
4785 patch_hash,
4786 "ui-agent oscillation detected; suppressing patch"
4787 );
4788 state.ui_agent_budget.refund(&report.surface_id);
4791 return Ok(serde_json::json!({ "event": event }));
4792 }
4793 let a2ui_envelope = car_a2ui::A2uiEnvelope {
4794 patch_components: Some(envelope),
4795 ..Default::default()
4796 };
4797 if let Err(e) = apply_a2ui_envelope(state, a2ui_envelope, None, None).await {
4798 tracing::warn!(
4799 surface_id = %report.surface_id,
4800 strategy = %strategy_id,
4801 patch_hash,
4802 elapsed_ns,
4803 error = %e,
4804 "ui-agent patch apply failed",
4805 );
4806 state.ui_agent_budget.refund(&report.surface_id);
4808 } else {
4809 tracing::debug!(
4810 surface_id = %report.surface_id,
4811 strategy = %strategy_id,
4812 patch_hash,
4813 elapsed_ns,
4814 iteration = state.ui_agent_budget.count(&report.surface_id),
4815 "ui-agent patch applied",
4816 );
4817 if let Some(memgine) = state.shared_memgine.clone() {
4827 let speaker = format!("ui-agent/{}", report.surface_id);
4828 let text = format!("strategy applied: {}", strategy_id);
4829 tokio::spawn(async move {
4830 let mut guard = memgine.lock().await;
4831 guard.ingest_conversation(&speaker, &text, chrono::Utc::now());
4832 });
4833 }
4834 }
4835 }
4836 car_ui_agent::Decision::StableNoChange => {
4837 state.ui_agent_budget.refund(&report.surface_id);
4839 }
4840 car_ui_agent::Decision::HardStop { reason } => {
4841 state.ui_agent_budget.refund(&report.surface_id);
4842 tracing::error!(
4848 surface_id = %report.surface_id,
4849 reason = %reason,
4850 "ui-agent hard-stopped improvement loop",
4851 );
4852 }
4853 }
4854 } else {
4855 tracing::debug!(
4856 surface_id = %report.surface_id,
4857 "ui-agent skipped — surface not found in store",
4858 );
4859 }
4860
4861 Ok(serde_json::json!({ "event": event }))
4862}
4863
4864async fn route_a2ui_action(
4865 state: &Arc<ServerState>,
4866 action: &car_a2ui::ClientAction,
4867 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4868) -> Value {
4869 let Some(owner) = owner else {
4870 return serde_json::json!({ "delivered": false, "reason": "surface has no owner" });
4871 };
4872 if owner.kind != "a2a" {
4873 return serde_json::json!({ "delivered": false, "reason": "unsupported owner kind", "owner": owner });
4874 }
4875 let Some(endpoint) = owner.endpoint.clone() else {
4876 return serde_json::json!({
4877 "delivered": false,
4878 "reason": "surface owner has no endpoint",
4879 "owner": owner
4880 });
4881 };
4882
4883 let message = car_a2a::Message {
4884 message_id: format!("a2ui-action-{}", uuid::Uuid::new_v4().simple()),
4885 role: car_a2a::MessageRole::User,
4886 parts: vec![car_a2a::Part::Data(car_a2a::types::DataPart {
4887 data: serde_json::json!({
4888 "a2uiAction": action,
4889 }),
4890 metadata: Default::default(),
4891 })],
4892 task_id: owner.task_id.clone(),
4893 context_id: owner.context_id.clone(),
4894 metadata: Default::default(),
4895 };
4896
4897 let auth = state
4898 .a2ui_route_auth
4899 .lock()
4900 .await
4901 .get(&action.surface_id)
4902 .cloned()
4903 .map(client_auth_from_route_auth)
4904 .unwrap_or(car_a2a::ClientAuth::None);
4905
4906 match car_a2a::A2aClient::new(endpoint.clone())
4907 .with_auth(auth)
4908 .send_message(message, false)
4909 .await
4910 {
4911 Ok(result) => serde_json::json!({
4912 "delivered": true,
4913 "owner": owner,
4914 "endpoint": endpoint,
4915 "result": result,
4916 }),
4917 Err(error) => serde_json::json!({
4918 "delivered": false,
4919 "owner": owner,
4920 "endpoint": endpoint,
4921 "error": error.to_string(),
4922 }),
4923 }
4924}
4925
4926fn client_auth_from_route_auth(auth: A2aRouteAuth) -> car_a2a::ClientAuth {
4927 match auth {
4928 A2aRouteAuth::None => car_a2a::ClientAuth::None,
4929 A2aRouteAuth::Bearer { token } => car_a2a::ClientAuth::Bearer(token),
4930 A2aRouteAuth::Header { name, value } => car_a2a::ClientAuth::Header { name, value },
4931 }
4932}
4933
4934fn trusted_route_endpoint(endpoint: Option<String>, allow_untrusted: bool) -> Option<String> {
4935 let endpoint = endpoint?;
4936 if allow_untrusted || is_loopback_http_endpoint(&endpoint) {
4937 Some(endpoint)
4938 } else {
4939 None
4940 }
4941}
4942
4943fn is_loopback_http_endpoint(endpoint: &str) -> bool {
4944 endpoint == "http://localhost"
4945 || endpoint.starts_with("http://localhost:")
4946 || endpoint.starts_with("http://localhost/")
4947 || endpoint == "http://127.0.0.1"
4948 || endpoint.starts_with("http://127.0.0.1:")
4949 || endpoint.starts_with("http://127.0.0.1/")
4950 || endpoint == "http://[::1]"
4951 || endpoint.starts_with("http://[::1]:")
4952 || endpoint.starts_with("http://[::1]/")
4953}
4954
4955async fn handle_host_register_agent(
4956 req: &JsonRpcMessage,
4957 session: &crate::session::ClientSession,
4958) -> Result<Value, String> {
4959 let request: RegisterHostAgentRequest =
4960 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4961 serde_json::to_value(
4962 session
4963 .host
4964 .register_agent(&session.client_id, request)
4965 .await?,
4966 )
4967 .map_err(|e| e.to_string())
4968}
4969
4970async fn handle_host_unregister_agent(
4971 req: &JsonRpcMessage,
4972 session: &crate::session::ClientSession,
4973) -> Result<Value, String> {
4974 let agent_id = req
4975 .params
4976 .get("agent_id")
4977 .and_then(|v| v.as_str())
4978 .ok_or("missing agent_id")?;
4979 session
4980 .host
4981 .unregister_agent(&session.client_id, agent_id)
4982 .await?;
4983 Ok(serde_json::json!({"ok": true}))
4984}
4985
4986async fn handle_host_set_status(
4987 req: &JsonRpcMessage,
4988 session: &crate::session::ClientSession,
4989) -> Result<Value, String> {
4990 let request: SetHostAgentStatusRequest =
4991 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4992 serde_json::to_value(session.host.set_status(&session.client_id, request).await?)
4993 .map_err(|e| e.to_string())
4994}
4995
4996async fn handle_host_register_device(
4997 req: &JsonRpcMessage,
4998 session: &crate::session::ClientSession,
4999) -> Result<Value, String> {
5000 let request: RegisterHostDeviceRequest =
5001 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5002 serde_json::to_value(
5003 session
5004 .host
5005 .register_device(&session.client_id, request)
5006 .await?,
5007 )
5008 .map_err(|e| e.to_string())
5009}
5010
5011async fn handle_host_update_device(
5012 req: &JsonRpcMessage,
5013 session: &crate::session::ClientSession,
5014) -> Result<Value, String> {
5015 let request: UpdateHostDeviceRequest =
5016 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5017 serde_json::to_value(
5018 session
5019 .host
5020 .update_device(&session.client_id, request)
5021 .await?,
5022 )
5023 .map_err(|e| e.to_string())
5024}
5025
5026async fn handle_host_devices(session: &crate::session::ClientSession) -> Result<Value, String> {
5027 serde_json::to_value(session.host.devices().await).map_err(|e| e.to_string())
5028}
5029
5030async fn handle_mobile_runtime(state: &Arc<ServerState>) -> Result<Value, String> {
5031 let url = state
5032 .mobile_runtime_url
5033 .get()
5034 .cloned()
5035 .ok_or_else(|| "mobile Parslee Core connection is not configured".to_string())?;
5036 let token = state.auth_token.get().cloned();
5037 Ok(serde_json::json!({
5038 "name": "Parslee Core",
5039 "source": "car",
5040 "url": url,
5041 "token": token,
5042 }))
5043}
5044
5045async fn handle_host_notify(
5046 req: &JsonRpcMessage,
5047 session: &crate::session::ClientSession,
5048) -> Result<Value, String> {
5049 let kind = req
5050 .params
5051 .get("kind")
5052 .and_then(|v| v.as_str())
5053 .unwrap_or("host.notification");
5054 let agent_id = req
5055 .params
5056 .get("agent_id")
5057 .and_then(|v| v.as_str())
5058 .map(str::to_string);
5059 let message = req
5060 .params
5061 .get("message")
5062 .and_then(|v| v.as_str())
5063 .unwrap_or("");
5064 let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
5065 serde_json::to_value(
5066 session
5067 .host
5068 .record_event(kind, agent_id, message, payload)
5069 .await,
5070 )
5071 .map_err(|e| e.to_string())
5072}
5073
5074async fn handle_host_request_approval(
5075 req: &JsonRpcMessage,
5076 session: &crate::session::ClientSession,
5077) -> Result<Value, String> {
5078 let mut request: CreateHostApprovalRequest =
5079 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5080 request.agent_id = stamped_requester(
5091 authenticated_bound_agent_id(session).await,
5092 request.agent_id.take(),
5093 );
5094 if let Some(agent_id) = &request.agent_id {
5095 let _ = session
5100 .host
5101 .set_status(
5102 &session.client_id,
5103 SetHostAgentStatusRequest {
5104 agent_id: agent_id.clone(),
5105 status: HostAgentStatus::WaitingForApproval,
5106 current_task: None,
5107 message: Some("Waiting for approval".to_string()),
5108 payload: Value::Null,
5109 },
5110 )
5111 .await;
5112 }
5113 let owner_client_id = if request.system_level {
5120 None
5121 } else {
5122 Some(session.client_id.as_str())
5123 };
5124 serde_json::to_value(
5125 session
5126 .host
5127 .create_approval(owner_client_id, request)
5128 .await?,
5129 )
5130 .map_err(|e| e.to_string())
5131}
5132
5133async fn handle_host_resolve_approval(
5134 req: &JsonRpcMessage,
5135 session: &crate::session::ClientSession,
5136) -> Result<Value, String> {
5137 let request: ResolveHostApprovalRequest =
5138 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5139 let bound_agent = authenticated_bound_agent_id(session).await;
5143 let resolver =
5144 car_server_types::host::Resolver::agent_session(&session.client_id, bound_agent.as_deref());
5145 serde_json::to_value(session.host.resolve_approval(resolver, request).await?)
5146 .map_err(|e| e.to_string())
5147}
5148
5149const AGENT_METHOD_NOT_ALLOWED_ERROR_CODE: i32 = -32601;
5154const AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX: &str =
5155 "agent_method_not_allowed: supervised agent token does not allow daemon method ";
5156
5157fn agent_scope_implicitly_allows(method: &str) -> bool {
5162 matches!(
5163 method,
5164 "session.auth" | "server.handshake" | "server.schema"
5165 )
5166}
5167
5168fn agent_method_scope_allows(session: &crate::session::ClientSession, method: &str) -> bool {
5169 if agent_scope_implicitly_allows(method)
5170 || session.is_host.load(std::sync::atomic::Ordering::Acquire)
5171 {
5172 return true;
5173 }
5174 match session.agent_method_allowlist.read() {
5175 Ok(scope) => scope
5176 .as_ref()
5177 .is_none_or(|methods| methods.contains(method)),
5178 Err(_) => false,
5181 }
5182}
5183
5184async fn handle_session_auth(
5195 req: &JsonRpcMessage,
5196 session: &crate::session::ClientSession,
5197 state: &Arc<ServerState>,
5198) -> Result<Value, String> {
5199 let tenant_binding = match req.params.get("tenant_id").and_then(Value::as_str) {
5204 Some(t) if !t.is_empty() => {
5205 validate_tenant_id(t)?;
5206 Some(t.to_string())
5207 }
5208 _ => None,
5209 };
5210
5211 let memory_namespace = req
5220 .params
5221 .get("memory_namespace")
5222 .and_then(Value::as_str)
5223 .filter(|s| !s.trim().is_empty())
5224 .map(str::to_string);
5225
5226 if let Some(host_supplied) = req.params.get("host_token").and_then(Value::as_str) {
5236 let expected = state.host_token.get().ok_or_else(|| {
5237 "host auth unavailable: this daemon has no host token (started with --no-auth?)"
5238 .to_string()
5239 })?;
5240 if !constant_time_eq(host_supplied.as_bytes(), expected.as_bytes()) {
5241 return Err("auth failed: host token mismatch".to_string());
5242 }
5243 session
5244 .authenticated
5245 .store(true, std::sync::atomic::Ordering::Release);
5246 session
5247 .is_host
5248 .store(true, std::sync::atomic::Ordering::Release);
5249 state.browser_views.broadcast_host_connected(true).await;
5255 *session.tenant.lock().await = tenant_binding;
5256 if let Some(ns) = memory_namespace.as_deref() {
5257 bind_memory_namespace(state, session, ns).await?;
5258 }
5259 return Ok(serde_json::json!({
5260 "ok": true,
5261 "auth_enabled": true,
5262 "role": "host",
5263 "memory_namespace": memory_namespace,
5264 }));
5265 }
5266
5267 let supplied = req
5268 .params
5269 .get("token")
5270 .and_then(Value::as_str)
5271 .ok_or_else(|| "session.auth requires { token: string }".to_string())?;
5272 let agent_id = req
5279 .params
5280 .get("agent_id")
5281 .and_then(Value::as_str)
5282 .map(str::to_string);
5283
5284 if let Some(id) = agent_id {
5285 let supervisor = state.supervisor()?;
5286 let binding = supervisor
5287 .authenticate_agent_token(&id, supplied)
5288 .await
5289 .map_err(|error| format!("auth failed for agent_id `{id}`: {error}"))?
5290 .ok_or_else(|| {
5291 format!("auth failed: agent_id `{id}` is not supervised, or token mismatch")
5292 })?;
5293 *session
5299 .agent_method_allowlist
5300 .write()
5301 .map_err(|_| "auth failed: agent method allowlist lock poisoned".to_string())? =
5302 binding
5303 .method_allowlist
5304 .clone()
5305 .map(|methods| methods.into_iter().collect());
5306 *session.agent_id.lock().await = Some(id.clone());
5317 {
5321 let mut attached = state.attached_agents.lock().await;
5322 if let Some(prior) = attached.get(&id) {
5323 if prior != &session.client_id {
5324 tracing::warn!(
5341 agent_id = %id,
5342 prior_client = %prior,
5343 new_client = %session.client_id,
5344 "superseding stale agent attach claim (respawn took over)"
5345 );
5346 }
5347 }
5348 attached.insert(id.clone(), session.client_id.clone());
5349 }
5350 let agent_eng = get_or_load_agent_memgine(state, &id).await?;
5355 let bound = match memory_namespace.as_deref() {
5360 Some(ns) => {
5361 *session.memory_namespace.lock().await = Some(ns.to_string());
5365 get_or_load_namespace_memgine(state, ns).await?
5366 }
5367 None => agent_eng,
5368 };
5369 *session.bound_memgine.lock().await = Some(bound);
5370 session
5371 .authenticated
5372 .store(true, std::sync::atomic::Ordering::Release);
5373 *session.tenant.lock().await = tenant_binding;
5374 let mut response = serde_json::json!({
5375 "ok": true,
5376 "auth_enabled": true,
5377 "agent_id": id,
5378 });
5379 if let Some(methods) = binding.method_allowlist {
5380 response["method_allowlist"] = serde_json::json!(methods);
5381 }
5382 return Ok(response);
5383 }
5384
5385 let expected = match state.auth_token.get() {
5386 Some(t) => t,
5387 None => {
5388 if let Some(ns) = memory_namespace.as_deref() {
5394 bind_memory_namespace(state, session, ns).await?;
5395 }
5396 session
5397 .authenticated
5398 .store(true, std::sync::atomic::Ordering::Release);
5399 *session.tenant.lock().await = tenant_binding;
5400 return Ok(serde_json::json!({
5401 "ok": true,
5402 "auth_enabled": false,
5403 "memory_namespace": memory_namespace,
5404 }));
5405 }
5406 };
5407 if !constant_time_eq(supplied.as_bytes(), expected.as_bytes()) {
5408 return Err("auth failed: token mismatch".to_string());
5409 }
5410 if let Some(ns) = memory_namespace.as_deref() {
5411 bind_memory_namespace(state, session, ns).await?;
5412 }
5413 session
5414 .authenticated
5415 .store(true, std::sync::atomic::Ordering::Release);
5416 *session.tenant.lock().await = tenant_binding;
5417 Ok(serde_json::json!({
5418 "ok": true,
5419 "auth_enabled": true,
5420 "memory_namespace": memory_namespace,
5421 "parslee": state.parslee_session.get().map(|session| session.identity.clone()),
5422 }))
5423}
5424
5425fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5429 if a.len() != b.len() {
5430 return false;
5431 }
5432 let mut diff: u8 = 0;
5433 for (x, y) in a.iter().zip(b.iter()) {
5434 diff |= x ^ y;
5435 }
5436 diff == 0
5437}
5438
5439async fn gate_high_risk_method(
5449 method: &str,
5450 params: &Value,
5451 state: &Arc<ServerState>,
5452) -> Result<(), String> {
5453 let timeout = state.approval_gate.timeout;
5454 let req = CreateHostApprovalRequest {
5455 agent_id: None,
5456 action: format!("ws.method:{method}"),
5457 details: serde_json::json!({
5458 "method": method,
5459 "params_preview": preview_params(params, 2_000),
5463 }),
5464 options: vec!["approve".to_string(), "deny".to_string()],
5465 system_level: true,
5469 };
5470 match state
5471 .host
5472 .request_and_wait_approval(req, "approve", timeout)
5473 .await
5474 {
5475 Ok(crate::host::ApprovalOutcome::Approved) => Ok(()),
5476 Ok(crate::host::ApprovalOutcome::Denied) => Err(format!(
5477 "{method} denied by user (approval gate, audit 2026-05). \
5478 To call this method without an interactive prompt, start \
5479 car-server with --no-approvals on a trusted machine."
5480 )),
5481 Ok(crate::host::ApprovalOutcome::TimedOut) => Err(format!(
5482 "{method} approval timed out after {}s with no resolution. \
5483 The approval is still visible in `host.approvals` for \
5484 forensics; resubmit the request to retry.",
5485 timeout.as_secs()
5486 )),
5487 Err(e) => Err(format!("approval gate error: {e}")),
5488 }
5489}
5490
5491fn preview_params(value: &Value, max_chars: usize) -> Value {
5492 let s = value.to_string();
5493 if s.len() <= max_chars {
5494 value.clone()
5495 } else {
5496 Value::String(format!("{}… (truncated)", &s[..max_chars]))
5497 }
5498}
5499
5500async fn handle_session_init(
5501 req: &JsonRpcMessage,
5502 session: &crate::session::ClientSession,
5503) -> Result<Value, String> {
5504 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5505 let init: SessionInitRequest =
5506 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5507
5508 for tool in &init.tools {
5509 register_callback_definition(session, tool).await?;
5510 }
5511
5512 let mut policy_count = 0;
5513 {
5514 let mut policies = session.runtime.policies.write().await;
5515 for policy_def in &init.policies {
5516 if let Some(check) = build_policy_check(policy_def) {
5517 match blanket_denied_tool(policy_def) {
5518 Some(tool) => policies.register_tool_deny(&policy_def.name, &tool, check, ""),
5519 None => policies.register(&policy_def.name, check, ""),
5520 }
5521 policy_count += 1;
5522 }
5523 }
5524 }
5525
5526 serde_json::to_value(SessionInitResponse {
5527 session_id: session.client_id.clone(),
5528 tools_registered: init.tools.len(),
5529 policies_registered: policy_count,
5530 })
5531 .map_err(|e| e.to_string())
5532}
5533
5534async fn handle_session_clear_halt(
5541 session: &crate::session::ClientSession,
5542) -> Result<Value, String> {
5543 if !session.is_host.load(Ordering::Acquire) {
5544 return Err("session.clear_halt requires the host-management role".to_string());
5545 }
5546 let cleared = session.halted.swap(false, Ordering::AcqRel);
5547 Ok(serde_json::json!({ "cleared": cleared, "halted": false }))
5548}
5549
5550async fn handle_session_bind_substrate(
5562 req: &JsonRpcMessage,
5563 session: &Arc<crate::session::ClientSession>,
5564 state: &Arc<ServerState>,
5565) -> Result<Value, String> {
5566 let connector = req
5567 .params
5568 .get("connector")
5569 .and_then(|v| v.as_str())
5570 .ok_or_else(|| "missing `connector` (MCP connector slug)".to_string())?;
5571
5572 let name = state
5573 .bind_substrate_to_connector(session, connector)
5574 .await?;
5575 Ok(serde_json::json!({ "bound": true, "substrate": name }))
5576}
5577
5578async fn handle_session_bind_sandbox(
5592 req: &JsonRpcMessage,
5593 session: &Arc<crate::session::ClientSession>,
5594 state: &Arc<ServerState>,
5595) -> Result<Value, String> {
5596 if let Some(agent_id) = session.agent_id.lock().await.clone() {
5615 return Err(format!(
5616 "session.bindSandbox is not available to supervised agents \
5617 (agent '{agent_id}'): it would let a confined agent bind-mount any \
5618 host directory into a container it controls. See Parslee-ai/car#480."
5619 ));
5620 }
5621
5622 let working_dir = req
5623 .params
5624 .get("working_dir")
5625 .and_then(|v| v.as_str())
5626 .ok_or_else(|| "missing `working_dir`".to_string())?;
5627 let wd = std::path::Path::new(working_dir);
5628 if !wd.is_dir() {
5629 return Err(format!("working_dir '{working_dir}' is not a directory"));
5630 }
5631
5632 let mut config = car_sandbox::SandboxConfig {
5633 working_dir: wd.to_path_buf(),
5634 ..Default::default()
5635 };
5636 if let Some(image) = opt_str(&req.params, "image") {
5637 config.image = image.to_string();
5638 }
5639 if let Some(net) = opt_str(&req.params, "network") {
5641 config.network = Some(net.to_string());
5642 }
5643 if let Some(mem) = opt_str(&req.params, "memory") {
5644 config.memory = Some(mem.to_string());
5645 }
5646 if let Some(cpus) = opt_str(&req.params, "cpus") {
5647 config.cpus = Some(cpus.to_string());
5648 }
5649 if let Some(pids) = req.params.get("pids_limit").and_then(|v| v.as_u64()) {
5650 config.pids_limit = Some(pids);
5651 }
5652 if let Some(t) = req
5656 .params
5657 .get("command_timeout_secs")
5658 .and_then(|v| v.as_u64())
5659 {
5660 if t == 0 {
5661 return Err("command_timeout_secs must be >= 1".to_string());
5662 }
5663 config.command_timeout_secs = t;
5664 }
5665
5666 let pf = car_sandbox::preflight(&config.image).await;
5670 if !pf.is_ok() {
5671 return Err(format!("sandbox preflight failed: {}", pf.message()));
5672 }
5673
5674 let executor = std::sync::Arc::new(car_sandbox::SandboxExecutor::new(config));
5675 let substrate: std::sync::Arc<dyn car_engine::Substrate> = executor;
5676 let name = substrate.name().to_string();
5677
5678 session.runtime.set_substrate(substrate).await;
5686 session.runtime.register_agent_basics().await;
5687 let ws_executor = std::sync::Arc::new(crate::session::WsToolExecutor::new(
5694 session.channel.clone(),
5695 session.negotiated_capabilities.clone(),
5696 session.halted.clone(),
5697 ));
5698 let composed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5699 std::sync::Arc::new(state.mcp_executor.share_with_fallback(ws_executor));
5700 let shadowed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5701 std::sync::Arc::new(crate::session::SubstrateShadowExecutor::new(composed));
5702 session.runtime.set_executor(shadowed).await;
5703
5704 Ok(serde_json::json!({ "bound": true, "substrate": name }))
5705}
5706
5707fn blanket_denied_tool(def: &PolicyDefinition) -> Option<String> {
5720 (def.rule.as_str() == "deny_tool").then(|| def.target.clone())
5721}
5722
5723fn build_policy_check(def: &PolicyDefinition) -> Option<car_policy::PolicyCheck> {
5724 match def.rule.as_str() {
5725 "deny_tool" => {
5726 let target = def.target.clone();
5727 Some(Box::new(
5728 move |action: &car_ir::Action, _: &car_state::StateStore| {
5729 if action.tool.as_deref() == Some(&target) {
5730 Some(format!("tool '{}' denied", target))
5731 } else {
5732 None
5733 }
5734 },
5735 ))
5736 }
5737 "deny_connector" => {
5742 let slug = def.target.clone();
5743 let prefix = format!("mcp_{slug}_");
5744 Some(Box::new(
5745 move |action: &car_ir::Action, _: &car_state::StateStore| match action
5746 .tool
5747 .as_deref()
5748 {
5749 Some(tool) if tool.starts_with(&prefix) => {
5750 Some(format!("connector '{}' denied", slug))
5751 }
5752 _ => None,
5753 },
5754 ))
5755 }
5756 "require_state" => {
5757 let key = def.key.clone();
5758 let value = def.value.clone();
5759 Some(Box::new(
5760 move |_: &car_ir::Action, state: &car_state::StateStore| {
5761 if state.get(&key).as_ref() != Some(&value) {
5762 Some(format!("state['{}'] must be {:?}", key, value))
5763 } else {
5764 None
5765 }
5766 },
5767 ))
5768 }
5769 "deny_tool_param" => {
5770 let target = def.target.clone();
5771 let param = def.key.clone();
5772 let pattern = def.pattern.clone();
5773 Some(Box::new(
5774 move |action: &car_ir::Action, _: &car_state::StateStore| {
5775 if action.tool.as_deref() != Some(&target) {
5776 return None;
5777 }
5778 if let Some(val) = action.parameters.get(¶m) {
5779 let s = val.as_str().unwrap_or(&val.to_string()).to_string();
5780 if s.contains(&pattern) {
5781 return Some(format!("param '{}' matches '{}'", param, pattern));
5782 }
5783 }
5784 None
5785 },
5786 ))
5787 }
5788 _ => None,
5789 }
5790}
5791
5792async fn handle_tools_register(
5793 req: &JsonRpcMessage,
5794 session: &crate::session::ClientSession,
5795) -> Result<Value, String> {
5796 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5797 let tools: Vec<ToolDefinition> =
5798 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5799 for tool in &tools {
5800 register_callback_definition(session, tool).await?;
5801 }
5802 serde_json::to_value(crate::wire_schema::ToolsRegisterResult(tools.len()))
5803 .map_err(|e| format!("serialize tools.register result: {e}"))
5804}
5805
5806fn callback_standing_authority_eligible(def: &ToolDefinition) -> bool {
5814 if def.name != "newsroom.publish"
5819 || def.idempotent
5820 || def.cache_ttl_secs.is_some()
5821 || def.rate_limit.is_some()
5822 {
5823 return false;
5824 }
5825 def.parameters
5826 == serde_json::json!({
5827 "type": "object",
5828 "properties": {
5829 "edition_id": {
5830 "type": "string",
5831 "minLength": 1
5832 }
5833 },
5834 "required": ["edition_id"],
5835 "additionalProperties": false
5836 })
5837}
5838
5839async fn register_callback_definition(
5840 session: &crate::session::ClientSession,
5841 def: &ToolDefinition,
5842) -> Result<(), String> {
5843 if session.runtime.registry.get(&def.name).await.is_some() {
5844 return Err(format!(
5845 "tool '{}' is server-owned and cannot be shadowed by tools.register",
5846 def.name
5847 ));
5848 }
5849 let digest = car_proto::canonical_sha256(def)?;
5850 {
5851 let mut callbacks = session.callback_tool_schema_digests.write().await;
5852 if callback_standing_authority_eligible(def) {
5853 callbacks.insert(def.name.clone(), digest);
5854 } else {
5855 callbacks.remove(&def.name);
5856 }
5857 }
5858 session
5859 .runtime
5860 .register_tool_schema(car_ir::ToolSchema {
5861 name: def.name.clone(),
5862 source: car_ir::ToolSourceKind::UserDefined,
5863 description: def.description.clone(),
5864 parameters: def.parameters.clone(),
5865 returns: def.returns.clone(),
5866 idempotent: def.idempotent,
5867 cache_ttl_secs: def.cache_ttl_secs,
5868 rate_limit: def.rate_limit.as_ref().map(|rl| car_ir::ToolRateLimit {
5869 max_calls: rl.max_calls,
5870 interval_secs: rl.interval_secs,
5871 }),
5872 })
5873 .await;
5874 Ok(())
5875}
5876
5877async fn handle_tools_list(session: &crate::session::ClientSession) -> Result<Value, String> {
5915 let mut schemas = session.runtime.tool_schemas().await;
5916 schemas.sort_by(|a, b| a.name.cmp(&b.name));
5917 let count = schemas.len();
5918 serde_json::to_value(crate::wire_schema::ToolsListResult {
5921 tools: schemas,
5922 count,
5923 })
5924 .map_err(|e| format!("serialize tool schema: {e}"))
5925}
5926
5927async fn handle_tools_unregister(
5943 req: &JsonRpcMessage,
5944 session: &crate::session::ClientSession,
5945) -> Result<Value, String> {
5946 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5947 let name = req
5948 .params
5949 .get("name")
5950 .and_then(|v| v.as_str())
5951 .ok_or("missing 'name'")?;
5952 let removed = session.runtime.unregister_tool(name).await;
5953 session
5954 .callback_tool_schema_digests
5955 .write()
5956 .await
5957 .remove(name);
5958 serde_json::to_value(crate::wire_schema::ToolsUnregisterResult {
5959 unregistered: name.to_string(),
5960 removed: u32::from(removed),
5961 })
5962 .map_err(|e| format!("serialize tools.unregister result: {e}"))
5963}
5964
5965async fn handle_tools_poll(
5979 req: &JsonRpcMessage,
5980 session: &crate::session::ClientSession,
5981) -> Result<Value, String> {
5982 let handle = req
5983 .params
5984 .get("handle")
5985 .and_then(|v| v.as_str())
5986 .ok_or_else(|| "missing `handle`".to_string())?;
5987 match session.runtime.tool_poll(handle).await {
5988 Some(res) => serde_json::to_value(&res).map_err(|e| e.to_string()),
5989 None => Ok(Value::Null),
5990 }
5991}
5992
5993async fn handle_tools_cancel(
5999 req: &JsonRpcMessage,
6000 session: &crate::session::ClientSession,
6001) -> Result<Value, String> {
6002 let handle = req
6003 .params
6004 .get("handle")
6005 .and_then(|v| v.as_str())
6006 .ok_or_else(|| "missing `handle`".to_string())?;
6007 let cancelled = session.runtime.tool_cancel(handle).await;
6008 serde_json::to_value(crate::wire_schema::ToolsCancelResult { cancelled })
6009 .map_err(|e| format!("serialize tools.cancel result: {e}"))
6010}
6011
6012async fn handle_tools_stream_subscribe(
6023 session: &crate::session::ClientSession,
6024) -> Result<Value, String> {
6025 use std::sync::atomic::Ordering;
6026 if session
6027 .tool_stream_subscribed
6028 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
6029 .is_err()
6030 {
6031 return serde_json::to_value(crate::wire_schema::ToolsStreamSubscribeResult {
6033 subscribed: true,
6034 })
6035 .map_err(|e| format!("serialize tools.stream.subscribe result: {e}"));
6036 }
6037 let mut rx = session.runtime.subscribe_tool_events();
6038 let channel = session.channel.clone();
6039 let subscribed = session.tool_stream_subscribed.clone();
6044 tokio::spawn(async move {
6045 use futures::SinkExt;
6046 struct ResetOnExit(std::sync::Arc<std::sync::atomic::AtomicBool>);
6047 impl Drop for ResetOnExit {
6048 fn drop(&mut self) {
6049 self.0.store(false, std::sync::atomic::Ordering::SeqCst);
6050 }
6051 }
6052 let _reset_on_exit = ResetOnExit(subscribed);
6053 loop {
6054 match rx.recv().await {
6055 Ok(ev) => {
6056 let Ok(json) = serde_json::to_string(&serde_json::json!({
6057 "jsonrpc": "2.0",
6058 "method": "tools.stream.event",
6059 "params": { "handle": ev.handle.id, "chunk": ev.chunk },
6060 })) else {
6061 continue;
6062 };
6063 let mut guard = channel.write.lock().await;
6068 let send = tokio::time::timeout(
6069 std::time::Duration::from_secs(10),
6070 guard.send(Message::Text(json.into())),
6071 )
6072 .await;
6073 drop(guard);
6074 match send {
6075 Ok(Ok(())) => {}
6076 _ => break,
6077 }
6078 }
6079 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
6083 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
6084 }
6085 }
6086 });
6087 serde_json::to_value(crate::wire_schema::ToolsStreamSubscribeResult { subscribed: true })
6088 .map_err(|e| format!("serialize tools.stream.subscribe result: {e}"))
6089}
6090
6091async fn handle_connectors_add(
6105 req: &JsonRpcMessage,
6106 state: &Arc<ServerState>,
6107) -> Result<Value, String> {
6108 state.ensure_connectors_loaded().await;
6109 let name = req
6110 .params
6111 .get("name")
6112 .and_then(|v| v.as_str())
6113 .ok_or_else(|| "missing `name`".to_string())?;
6114 let url = req
6115 .params
6116 .get("url")
6117 .and_then(|v| v.as_str())
6118 .ok_or_else(|| "missing `url`".to_string())?;
6119 let headers: Vec<(String, String)> = req
6120 .params
6121 .get("headers")
6122 .and_then(|v| v.as_object())
6123 .map(|m| {
6124 m.iter()
6125 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
6126 .collect()
6127 })
6128 .unwrap_or_default();
6129
6130 let status = state
6131 .connectors()
6132 .add(name, url, headers)
6133 .await
6134 .map_err(|e| e.to_string())?;
6135 serde_json::to_value(status).map_err(|e| e.to_string())
6136}
6137
6138async fn handle_connectors_add_stdio(
6142 req: &JsonRpcMessage,
6143 state: &Arc<ServerState>,
6144) -> Result<Value, String> {
6145 state.ensure_connectors_loaded().await;
6146 let name = req
6147 .params
6148 .get("name")
6149 .and_then(|v| v.as_str())
6150 .ok_or_else(|| "missing `name`".to_string())?;
6151 let command = req
6152 .params
6153 .get("command")
6154 .and_then(|v| v.as_str())
6155 .ok_or_else(|| "missing `command`".to_string())?;
6156 let args: Vec<String> = req
6157 .params
6158 .get("args")
6159 .and_then(|v| v.as_array())
6160 .map(|a| {
6161 a.iter()
6162 .filter_map(|v| v.as_str().map(str::to_string))
6163 .collect()
6164 })
6165 .unwrap_or_default();
6166 let env: std::collections::BTreeMap<String, String> = req
6167 .params
6168 .get("env")
6169 .and_then(|v| v.as_object())
6170 .map(|m| {
6171 m.iter()
6172 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
6173 .collect()
6174 })
6175 .unwrap_or_default();
6176
6177 let status = state
6178 .connectors()
6179 .add_stdio(name, command, args, env)
6180 .await
6181 .map_err(|e| e.to_string())?;
6182 serde_json::to_value(status).map_err(|e| e.to_string())
6183}
6184
6185async fn handle_connectors_authenticate(
6191 req: &JsonRpcMessage,
6192 state: &Arc<ServerState>,
6193) -> Result<Value, String> {
6194 state.ensure_connectors_loaded().await;
6195 let name = req
6196 .params
6197 .get("name")
6198 .and_then(|v| v.as_str())
6199 .ok_or_else(|| "missing `name`".to_string())?;
6200 let url = req
6201 .params
6202 .get("url")
6203 .and_then(|v| v.as_str())
6204 .ok_or_else(|| "missing `url`".to_string())?;
6205 let redirect_uri = req
6206 .params
6207 .get("redirect_uri")
6208 .and_then(|v| v.as_str())
6209 .ok_or_else(|| "missing `redirect_uri`".to_string())?;
6210
6211 let (authorize_url, oauth_state) = state
6212 .connectors()
6213 .authenticate(name, url, redirect_uri)
6214 .await
6215 .map_err(|e| e.to_string())?;
6216 Ok(serde_json::json!({ "authorize_url": authorize_url, "state": oauth_state }))
6217}
6218
6219async fn handle_connectors_complete_authentication(
6223 req: &JsonRpcMessage,
6224 state: &Arc<ServerState>,
6225) -> Result<Value, String> {
6226 state.ensure_connectors_loaded().await;
6227 let oauth_state = req
6228 .params
6229 .get("state")
6230 .and_then(|v| v.as_str())
6231 .ok_or_else(|| "missing `state`".to_string())?;
6232 let code = req
6233 .params
6234 .get("code")
6235 .and_then(|v| v.as_str())
6236 .ok_or_else(|| "missing `code`".to_string())?;
6237
6238 let status = state
6239 .connectors()
6240 .complete_authentication(oauth_state, code)
6241 .await
6242 .map_err(|e| e.to_string())?;
6243 serde_json::to_value(status).map_err(|e| e.to_string())
6244}
6245
6246async fn handle_connectors_list(state: &Arc<ServerState>) -> Result<Value, String> {
6248 state.ensure_connectors_loaded().await;
6249 let list = state.connectors().list().await;
6250 serde_json::to_value(list).map_err(|e| e.to_string())
6251}
6252
6253async fn handle_connectors_tools(
6256 req: &JsonRpcMessage,
6257 state: &Arc<ServerState>,
6258) -> Result<Value, String> {
6259 state.ensure_connectors_loaded().await;
6260 let slug = req
6261 .params
6262 .get("slug")
6263 .and_then(|v| v.as_str())
6264 .ok_or_else(|| "missing `slug`".to_string())?;
6265 let tools = state
6266 .connectors()
6267 .tools(slug)
6268 .await
6269 .map_err(|e| e.to_string())?;
6270 serde_json::to_value(tools).map_err(|e| e.to_string())
6271}
6272
6273async fn handle_connectors_enable_tools(
6277 req: &JsonRpcMessage,
6278 state: &Arc<ServerState>,
6279) -> Result<Value, String> {
6280 state.ensure_connectors_loaded().await;
6281 let slug = req
6282 .params
6283 .get("slug")
6284 .and_then(|v| v.as_str())
6285 .ok_or_else(|| "missing `slug`".to_string())?;
6286 let tools: Vec<String> = req
6287 .params
6288 .get("tools")
6289 .and_then(|v| v.as_array())
6290 .map(|a| {
6291 a.iter()
6292 .filter_map(|v| v.as_str().map(str::to_string))
6293 .collect()
6294 })
6295 .ok_or_else(|| "missing `tools` array".to_string())?;
6296
6297 let entries = state
6298 .connectors()
6299 .enable_tools(slug, &tools)
6300 .await
6301 .map_err(|e| e.to_string())?;
6302 let enabled = entries.len();
6303 state.register_connector_entries(&entries).await;
6304 Ok(serde_json::json!({ "enabled": enabled }))
6305}
6306
6307async fn handle_connectors_refresh(
6310 req: &JsonRpcMessage,
6311 state: &Arc<ServerState>,
6312) -> Result<Value, String> {
6313 state.ensure_connectors_loaded().await;
6314 let slug = req
6315 .params
6316 .get("slug")
6317 .and_then(|v| v.as_str())
6318 .ok_or_else(|| "missing `slug`".to_string())?;
6319 let tools = state
6320 .connectors()
6321 .refresh(slug)
6322 .await
6323 .map_err(|e| e.to_string())?;
6324 let entries = state.connectors().enabled_tool_entries().await;
6327 state.register_connector_entries(&entries).await;
6328 serde_json::to_value(tools).map_err(|e| e.to_string())
6329}
6330
6331async fn handle_connectors_remove(
6334 req: &JsonRpcMessage,
6335 state: &Arc<ServerState>,
6336) -> Result<Value, String> {
6337 state.ensure_connectors_loaded().await;
6338 let slug = req
6339 .params
6340 .get("slug")
6341 .and_then(|v| v.as_str())
6342 .ok_or_else(|| "missing `slug`".to_string())?;
6343 let canonicals = state
6344 .connectors()
6345 .remove(slug)
6346 .await
6347 .map_err(|e| e.to_string())?;
6348 state.unregister_connector_tools(&canonicals).await;
6349 Ok(serde_json::json!({ "removed": slug }))
6350}
6351
6352async fn run_store_blocking<T, F>(context: &'static str, operation: F) -> Result<T, String>
6353where
6354 T: Send + 'static,
6355 F: FnOnce() -> Result<T, String> + Send + 'static,
6356{
6357 tokio::task::spawn_blocking(operation)
6358 .await
6359 .map_err(|error| format!("{context} blocking task failed: {error}"))?
6360}
6361
6362async fn strict_run_trace_read_blocking<T, F>(
6367 context: &'static str,
6368 state: &Arc<ServerState>,
6369 run_id: String,
6370 operation: F,
6371) -> Result<T, String>
6372where
6373 T: Send + 'static,
6374 F: FnOnce() -> std::io::Result<T> + Send + 'static,
6375{
6376 let result = tokio::task::spawn_blocking(operation)
6377 .await
6378 .map_err(|error| format!("{context} blocking task failed: {error}"))?;
6379 match result {
6380 Ok(value) => Ok(value),
6381 Err(error) if crate::run_store::is_trace_corruption_error(&error) => Err(state
6382 .quarantine_run_trace_from_read(&run_id, error.to_string())
6383 .await),
6384 Err(error) => Err(run_trace_read_error(&run_id, error)),
6385 }
6386}
6387
6388async fn run_lifecycle_durability_blocking<T, F>(
6395 guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6396 context: &'static str,
6397 operation: F,
6398) -> Result<T, String>
6399where
6400 T: Send + 'static,
6401 F: FnOnce() -> Result<T, String> + Send + 'static,
6402{
6403 let owned = guard
6404 .take()
6405 .expect("proposal durability requires the owned lifecycle guard");
6406 match tokio::task::spawn_blocking(move || {
6407 let result = operation();
6408 (owned, result)
6409 })
6410 .await
6411 {
6412 Ok((owned, result)) => {
6413 *guard = Some(owned);
6414 result
6415 }
6416 Err(error) => Err(format!("{context} blocking task failed: {error}")),
6417 }
6418}
6419
6420async fn handle_connectors_disable_tools(
6424 req: &JsonRpcMessage,
6425 state: &Arc<ServerState>,
6426) -> Result<Value, String> {
6427 state.ensure_connectors_loaded().await;
6428 let slug = req
6429 .params
6430 .get("slug")
6431 .and_then(|v| v.as_str())
6432 .ok_or_else(|| "missing `slug`".to_string())?;
6433 let tools: Vec<String> = req
6434 .params
6435 .get("tools")
6436 .and_then(|v| v.as_array())
6437 .map(|a| {
6438 a.iter()
6439 .filter_map(|v| v.as_str().map(str::to_string))
6440 .collect()
6441 })
6442 .ok_or_else(|| "missing `tools` array".to_string())?;
6443
6444 let canonicals = state
6445 .connectors()
6446 .disable_tools(slug, &tools)
6447 .await
6448 .map_err(|e| e.to_string())?;
6449 let disabled = canonicals.len();
6450 state.unregister_connector_tools(&canonicals).await;
6451 Ok(serde_json::json!({ "disabled": disabled }))
6452}
6453
6454async fn handle_proposal_submit(
6455 req: &JsonRpcMessage,
6456 session: &crate::session::ClientSession,
6457 state: &Arc<ServerState>,
6458) -> Result<Value, String> {
6459 let submit: ProposalSubmitRequest =
6460 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
6461 let original_submission = req
6462 .params
6463 .get("proposal")
6464 .cloned()
6465 .ok_or_else(|| "proposal.submit is missing `proposal`".to_string())?;
6466 car_engine::validate_proposal_action_ids(&submit.proposal)
6472 .map_err(|error| format!("invalid proposal: {error}"))?;
6473 let submitted_proposal_digest = canonical_sha256(&submit.proposal)?;
6474 let submitted_proposal_value =
6475 serde_json::to_value(&submit.proposal).map_err(|e| e.to_string())?;
6476 let session_id = match req.params.get("session_id") {
6482 None | Some(Value::Null) => None,
6483 Some(Value::String(value)) if !value.is_empty() => Some(value.clone()),
6484 Some(_) => return Err("invalid `session_id`: expected a non-empty string".to_string()),
6485 };
6486
6487 let scope: Option<car_engine::RuntimeScope> = match req.params.get("scope") {
6494 Some(v) if !v.is_null() => {
6495 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid scope: {e}"))?)
6496 }
6497 _ => None,
6498 };
6499 if session_id.is_some() && scope.is_some() {
6500 return Err(
6501 "proposal.submit cannot combine `session_id` and `scope`: CAR cannot yet enforce both contexts"
6502 .to_string(),
6503 );
6504 }
6505
6506 let mut run_guard = Some(session.run_lifecycle_guard.clone().lock_owned().await);
6511 let current_run = session.current_run_id.lock().await.clone();
6512
6513 let receipt_owner = {
6518 let run_store = state.run_store.clone();
6519 let requested_policy_session_id = session_id.clone();
6520 let original_submission = original_submission.clone();
6521 tokio::task::spawn_blocking(move || {
6522 run_store.completed_proposal_retry_owner(
6523 requested_policy_session_id.as_deref(),
6524 &original_submission,
6525 )
6526 })
6527 .await
6528 .map_err(|error| format!("completed proposal response owner lookup failed: {error}"))?
6529 .map_err(|error| format!("completed proposal response registry is unreadable: {error}"))?
6530 };
6531 if let Some((receipt_run, receipt_client)) = receipt_owner.as_ref() {
6532 let owner_matches = if current_run.as_deref() == Some(receipt_run.as_str()) {
6533 state
6534 .run_owner_binding(receipt_run)
6535 .await
6536 .is_some_and(|(durable, active)| {
6537 durable == *receipt_client && active == session.client_id
6538 })
6539 } else {
6540 false
6541 };
6542 if !owner_matches {
6543 return Err(format!(
6544 "{} completed proposal response belongs to run `{receipt_run}` / durable client `{receipt_client}`; this socket is not the active authenticated owner",
6545 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6546 ));
6547 }
6548 }
6549 if let Some(run_id) = current_run.as_deref() {
6550 let (durable_client, active_client) = state
6551 .run_owner_binding(run_id)
6552 .await
6553 .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6554 let (owner_client, terminal, start_committed, completion_pending, trace_corruption) = state
6555 .run_lifecycle_state_with_corruption(run_id)
6556 .await
6557 .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6558 if let Some(error) = trace_corruption {
6559 return Err(error);
6560 }
6561 if terminal
6562 || !start_committed
6563 || completion_pending
6564 || owner_client != session.client_id
6565 || active_client != session.client_id
6566 {
6567 return Err(format!(
6568 "active run is not writable for run_id `{run_id}` / client_id `{}`",
6569 session.client_id
6570 ));
6571 }
6572 session.require_run_journal_binding(run_id).await?;
6573
6574 let (completed, resumed_policy_rotation) = {
6575 let run_store = state.run_store.clone();
6576 let run_id = run_id.to_string();
6577 let client_id = durable_client.clone();
6578 let requested_policy_session_id = session_id.clone();
6579 let original_submission = original_submission.clone();
6580 run_store_blocking("completed proposal response lookup", move || {
6581 let exact = run_store
6582 .completed_proposal(
6583 &run_id,
6584 &client_id,
6585 requested_policy_session_id.as_deref(),
6586 &original_submission,
6587 )
6588 .map_err(|error| {
6589 proposal_durability_quarantine(&run_id, "completed response", &error)
6590 })?;
6591 let resumed_owner = client_id != active_client;
6592 if exact.is_some() || !resumed_owner {
6593 return Ok((exact, resumed_owner));
6594 }
6595 run_store
6596 .completed_proposal_for_resumed_owner(&run_id, &client_id, &original_submission)
6597 .map(|receipt| (receipt, true))
6598 .map_err(|error| {
6599 proposal_durability_quarantine(
6600 &run_id,
6601 "resumed completed response",
6602 &error,
6603 )
6604 })
6605 })
6606 .await?
6607 };
6608 if let Some(receipt) = completed {
6609 if resumed_policy_rotation {
6610 let original_policy = &receipt.finalization.requested_policy_session_id;
6611 let authenticated_original_policy = &receipt.finalization.policy_session_id;
6612 let replacement_policy_is_live = match session_id.as_deref() {
6613 Some(policy_session_id) => {
6614 session.runtime.session_exists(policy_session_id).await
6615 }
6616 None => false,
6617 };
6618 match (original_policy, authenticated_original_policy, &session_id) {
6619 (None, None, None) => {}
6620 (Some(requested), Some(authenticated), Some(_))
6621 if requested == authenticated && replacement_policy_is_live => {}
6622 _ => {
6623 return Err(
6624 "resumed proposal recovery requires the original authenticated policy provenance and a live replacement policy session"
6625 .to_string(),
6626 );
6627 }
6628 }
6629 }
6630 let run_store = state.run_store.clone();
6631 let (value, cleanup_error) = run_lifecycle_durability_blocking(
6632 &mut run_guard,
6633 "completed proposal guard cleanup",
6634 move || {
6635 let cleanup_error = run_store
6636 .cleanup_completed_proposal_guards(&receipt)
6637 .err()
6638 .map(|error| error.to_string());
6639 let value = run_store
6640 .completed_proposal_response_value(&receipt)
6641 .map_err(|error| {
6642 format!("completed proposal response serialization failed: {error}")
6643 })?;
6644 Ok((value, cleanup_error))
6645 },
6646 )
6647 .await?;
6648 if let Some(error) = cleanup_error {
6649 tracing::warn!(run_id, %error, "completed proposal response remains recoverable while guard cleanup is pending");
6650 }
6651 return Ok(value);
6652 }
6653
6654 let pending = {
6655 let run_store = state.run_store.clone();
6656 let run_id = run_id.to_string();
6657 run_store_blocking("pending proposal lookup", move || {
6658 run_store.pending_proposal(&run_id).map_err(|error| {
6659 proposal_durability_quarantine(&run_id, "finalization", &error)
6660 })
6661 })
6662 .await?
6663 };
6664 if let Some(pending) = pending {
6665 if pending.client_id != durable_client
6666 || pending.original_submission != original_submission
6667 || pending.requested_policy_session_id.as_deref() != session_id.as_deref()
6668 {
6669 return Err(format!(
6670 "proposal finalization pending for run_id `{run_id}`; only an exact retry of original submission `{}` may reconcile it",
6671 pending.original_proposal_id
6672 ));
6673 }
6674 return finish_pending_proposal(
6675 session,
6676 state,
6677 &pending,
6678 &durable_client,
6679 &mut run_guard,
6680 )
6681 .await;
6682 }
6683 let marker = {
6684 let run_store = state.run_store.clone();
6685 let run_id = run_id.to_string();
6686 run_store_blocking("proposal execution marker lookup", move || {
6687 run_store
6688 .execution_marker(&run_id)
6689 .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))
6690 })
6691 .await?
6692 };
6693 if let Some(marker) = marker {
6694 return Err(format!(
6695 "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not redispatch automatically",
6696 marker.original_proposal_id
6697 ));
6698 }
6699 if durable_client != session.client_id {
6700 return Err(format!(
6701 "resumed run `{run_id}` accepts only an exact recovery submission; CAR will not dispatch new actions"
6702 ));
6703 }
6704 }
6705
6706 let authenticated_policy_session = match session_id.as_deref() {
6710 Some(policy_session_id) if session.runtime.session_exists(policy_session_id).await => {
6711 Some(policy_session_id.to_string())
6712 }
6713 _ => None,
6714 };
6715 let execution_marker =
6716 current_run
6717 .as_deref()
6718 .map(|run_id| crate::run_store::ProposalExecutionMarker {
6719 run_id: run_id.to_string(),
6720 client_id: session.client_id.clone(),
6721 requested_policy_session_id: session_id.clone(),
6722 policy_session_id: authenticated_policy_session.clone(),
6723 original_proposal_id: submit.proposal.id.clone(),
6724 original_submission: original_submission.clone(),
6725 original_proposal: submitted_proposal_value.clone(),
6726 proposal_digest: submitted_proposal_digest.clone(),
6727 });
6728 let retry_rollback =
6729 execution_marker
6730 .as_ref()
6731 .map(|marker| crate::run_store::ProposalRetryRollback {
6732 run_id: marker.run_id.clone(),
6733 client_id: marker.client_id.clone(),
6734 requested_policy_session_id: marker.requested_policy_session_id.clone(),
6735 original_submission: original_submission.clone(),
6736 });
6737 if let (Some(marker), Some(rollback)) = (execution_marker.as_ref(), retry_rollback.as_ref()) {
6738 let run_store = state.run_store.clone();
6739 let marker = marker.clone();
6740 let rollback = rollback.clone();
6741 let expected_run_id = marker.run_id.clone();
6742 let reservation = run_lifecycle_durability_blocking(
6743 &mut run_guard,
6744 "proposal pre-execution reservation durability",
6745 move || {
6746 run_store
6747 .claim_proposal_id(
6748 &marker.run_id,
6749 &marker.client_id,
6750 &marker.original_proposal_id,
6751 &marker.original_submission,
6752 )
6753 .map_err(|error| {
6754 format!("proposal id could not be durably claimed: {error}")
6755 })?;
6756 run_store
6757 .write_proposal_retry_rollback(&rollback)
6758 .map_err(|error| {
6759 format!("proposal retry rollback could not be durably prepared: {error}")
6760 })?;
6761 run_store
6762 .reserve_proposal_retry_owner(
6763 &marker.run_id,
6764 &marker.client_id,
6765 marker.requested_policy_session_id.as_deref(),
6766 &marker.original_submission,
6767 )
6768 .map_err(|error| {
6769 format!("proposal retry reservation could not be durably prepared: {error}")
6770 })
6771 },
6772 )
6773 .await?;
6774 if let crate::run_store::ProposalRetryReservation::Existing {
6775 run_id: reserved_run,
6776 client_id: reserved_client,
6777 } = reservation
6778 {
6779 if reserved_run != expected_run_id || reserved_client != session.client_id {
6780 let run_store = state.run_store.clone();
6781 let rollback = retry_rollback
6782 .as_ref()
6783 .expect("active reservation has rollback authority")
6784 .clone();
6785 run_lifecycle_durability_blocking(
6786 &mut run_guard,
6787 "losing proposal retry rollback cleanup",
6788 move || {
6789 run_store
6790 .clear_proposal_retry_rollback(&rollback)
6791 .map_err(|error| {
6792 format!("losing retry rollback cleanup failed: {error}")
6793 })
6794 },
6795 )
6796 .await?;
6797 return Err(format!(
6798 "{} proposal retry tuple belongs to run `{reserved_run}` / client `{reserved_client}`; concurrent submission cannot dispatch it",
6799 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6800 ));
6801 }
6802 }
6803 }
6804 if let Some(marker) = execution_marker.as_ref() {
6805 let run_store = state.run_store.clone();
6806 let durable_marker = marker.clone();
6807 let write_result = run_lifecycle_durability_blocking(
6808 &mut run_guard,
6809 "proposal execution marker durability",
6810 move || {
6811 run_store
6812 .write_execution_marker(&durable_marker)
6813 .map_err(|error| {
6814 format!("proposal execution could not be durably prepared: {error}")
6815 })
6816 },
6817 )
6818 .await;
6819 if let Err(error) = write_result {
6820 rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6821 .await
6822 .map_err(|rollback| format!("{error}; exact guard rollback failed: {rollback}"))?;
6823 return Err(error);
6824 }
6825 }
6826 if current_run.is_some() {
6827 if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6828 if let Err(error) = session
6829 .runtime
6830 .event_log_handle()
6831 .lock()
6832 .await
6833 .bind_policy_session(policy_session_id)
6834 {
6835 if let Some(marker) = execution_marker.as_ref() {
6836 rollback_pre_execution_guards(
6837 state,
6838 &mut run_guard,
6839 marker,
6840 &original_submission,
6841 )
6842 .await
6843 .map_err(|rollback| {
6844 format!("{error}; exact guard rollback failed: {rollback}")
6845 })?;
6846 }
6847 return Err(error);
6848 }
6849 }
6850 }
6851
6852 if let Some(rollback) = retry_rollback.as_ref() {
6853 let run_store = state.run_store.clone();
6854 let rollback = rollback.clone();
6855 let clear_result = run_lifecycle_durability_blocking(
6856 &mut run_guard,
6857 "proposal retry rollback intent cleanup",
6858 move || {
6859 run_store
6860 .clear_proposal_retry_rollback(&rollback)
6861 .map_err(|error| format!("proposal retry rollback cleanup failed: {error}"))
6862 },
6863 )
6864 .await;
6865 if let Err(error) = clear_result {
6866 if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6867 session
6868 .runtime
6869 .event_log_handle()
6870 .lock()
6871 .await
6872 .clear_policy_session(policy_session_id)
6873 .map_err(|unbind| {
6874 format!("{error}; policy rollback failed before dispatch: {unbind}")
6875 })?;
6876 }
6877 if let Some(marker) = execution_marker.as_ref() {
6878 rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6879 .await
6880 .map_err(|rollback| {
6881 format!("{error}; exact guard rollback failed: {rollback}")
6882 })?;
6883 }
6884 return Err(error);
6885 }
6886 }
6887
6888 let proposal_event_start = if current_run.is_some() {
6889 Some(session.runtime.event_log_handle().lock().await.len())
6890 } else {
6891 None
6892 };
6893
6894 let result = match (session_id.as_deref(), scope) {
6895 (Some(sid), None) => {
6896 if current_run.is_some() {
6897 session
6898 .runtime
6899 .execute_with_session_and_stable_replan_id(&submit.proposal, sid)
6900 .await
6901 } else {
6902 session
6903 .runtime
6904 .execute_with_session(&submit.proposal, sid)
6905 .await
6906 }
6907 }
6908 (None, Some(s)) => {
6909 if current_run.is_some() {
6910 session
6911 .runtime
6912 .execute_scoped_with_stable_replan_id(&submit.proposal, &s)
6913 .await
6914 } else {
6915 session.runtime.execute_scoped(&submit.proposal, &s).await
6916 }
6917 }
6918 (None, None) => {
6919 if current_run.is_some() {
6920 session
6921 .runtime
6922 .execute_with_stable_replan_id(&submit.proposal)
6923 .await
6924 } else {
6925 session.runtime.execute(&submit.proposal).await
6926 }
6927 }
6928 (Some(_), Some(_)) => unreachable!("combined execution context rejected before binding"),
6929 };
6930
6931 if result.results.iter().any(|action| action.terminal) {
6937 session.halted.store(true, Ordering::Release);
6938 }
6939
6940 if current_run.is_some() {
6941 let proposal_result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
6942 let result_digest = canonical_sha256(&proposal_result_value)?;
6943 let final_proposal = result
6944 .final_proposal
6945 .clone()
6946 .ok_or_else(|| "active v3 runtime result is missing final_proposal".to_string())?;
6947 let event_start = proposal_event_start
6948 .expect("active run captured its proposal event boundary before dispatch");
6949 let accepted_proposal_preimages = {
6950 let log = session.runtime.event_log_handle();
6951 let log = log.lock().await;
6952 collect_accepted_proposal_preimages(
6953 &submit.proposal,
6954 &result,
6955 &log.events()[event_start..],
6956 )?
6957 };
6958 let run_id = current_run
6959 .as_deref()
6960 .expect("current run checked before proposal finalization");
6961 let pending = crate::run_store::PendingProposalFinalization {
6962 run_id: run_id.to_string(),
6963 client_id: session.client_id.clone(),
6964 requested_policy_session_id: session_id,
6965 policy_session_id: authenticated_policy_session.clone(),
6966 original_proposal_id: result.original_proposal_id.clone(),
6967 final_proposal_id: result.proposal_id.clone(),
6968 original_submission,
6969 original_proposal: submit.proposal.clone(),
6970 final_proposal,
6971 accepted_proposal_preimages,
6972 proposal_result: result.clone(),
6973 result_digest,
6974 };
6975 let run_store = state.run_store.clone();
6976 let durable_pending = pending.clone();
6977 run_lifecycle_durability_blocking(
6978 &mut run_guard,
6979 "proposal finalization outbox durability",
6980 move || {
6981 run_store
6982 .write_pending_proposal(&durable_pending)
6983 .map_err(|error| {
6984 format!("proposal finalization could not be durably prepared: {error}")
6985 })
6986 },
6987 )
6988 .await?;
6989 return finish_pending_proposal(
6990 session,
6991 state,
6992 &pending,
6993 &session.client_id,
6994 &mut run_guard,
6995 )
6996 .await;
6997 }
6998
6999 serde_json::to_value(result).map_err(|e| e.to_string())
7000}
7001
7002async fn rollback_pre_execution_guards(
7003 state: &Arc<ServerState>,
7004 run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
7005 marker: &crate::run_store::ProposalExecutionMarker,
7006 original_submission: &Value,
7007) -> Result<(), String> {
7008 let run_store = state.run_store.clone();
7009 let marker = marker.clone();
7010 let submission = original_submission.clone();
7011 let rollback = crate::run_store::ProposalRetryRollback {
7012 run_id: marker.run_id.clone(),
7013 client_id: marker.client_id.clone(),
7014 requested_policy_session_id: marker.requested_policy_session_id.clone(),
7015 original_submission: submission.clone(),
7016 };
7017 run_lifecycle_durability_blocking(
7018 run_guard,
7019 "proposal pre-execution guard rollback",
7020 move || {
7021 run_store
7022 .clear_execution_marker(&marker)
7023 .map_err(|error| format!("execution marker rollback failed: {error}"))?;
7024 run_store
7025 .release_proposal_retry_owner(
7026 &marker.run_id,
7027 &marker.client_id,
7028 marker.requested_policy_session_id.as_deref(),
7029 &submission,
7030 )
7031 .map_err(|error| format!("retry owner rollback failed: {error}"))?;
7032 run_store
7033 .clear_proposal_retry_rollback(&rollback)
7034 .map_err(|error| format!("retry rollback intent cleanup failed: {error}"))
7035 },
7036 )
7037 .await
7038}
7039
7040async fn finish_pending_proposal(
7041 session: &crate::session::ClientSession,
7042 state: &Arc<ServerState>,
7043 pending: &crate::run_store::PendingProposalFinalization,
7044 durable_client_id: &str,
7045 run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
7046) -> Result<Value, String> {
7047 let (started, _marker) = {
7048 let run_store = state.run_store.clone();
7049 let pending = pending.clone();
7050 run_store_blocking("proposal finalization provenance lookup", move || {
7051 run_store.pending_provenance(&pending).map_err(|error| {
7052 format!("proposal finalization provenance validation failed: {error}")
7053 })
7054 })
7055 .await?
7056 };
7057 let durable_client = started
7058 .client_id
7059 .as_deref()
7060 .ok_or_else(|| "durable RunStarted is missing its client identity".to_string())?;
7061 let active_owner = state
7062 .run_owner_binding(&pending.run_id)
7063 .await
7064 .ok_or_else(|| {
7065 format!(
7066 "active run `{}` is absent from CAR's run registry",
7067 pending.run_id
7068 )
7069 })?;
7070 if durable_client != durable_client_id
7071 || active_owner.0 != durable_client_id
7072 || active_owner.1 != session.client_id
7073 {
7074 return Err(
7075 "proposal finalization owner does not match the authenticated run binding".into(),
7076 );
7077 }
7078 session.require_run_journal_binding(&pending.run_id).await?;
7079 let event_log = session.runtime.event_log_handle();
7080 let mut log = event_log.lock().await;
7081 let active_policy = log
7082 .active_run_binding()
7083 .and_then(|(_, _, policy_session_id)| policy_session_id.map(str::to_string));
7084 match (
7085 active_policy.as_deref(),
7086 pending.policy_session_id.as_deref(),
7087 ) {
7088 (Some(active), Some(expected)) if active == expected => {}
7089 (None, Some(expected)) => log.bind_policy_session(expected)?,
7090 (None, None) => {}
7091 _ => {
7092 return Err(
7093 "proposal finalization policy identity does not match the active journal binding"
7094 .into(),
7095 );
7096 }
7097 }
7098 state.ensure_proposal_run_turns(pending).await?;
7099 match log
7100 .append_critical_async(
7101 car_eventlog::EventKind::ProposalCompleted,
7102 None,
7103 Some(&pending.final_proposal_id),
7104 pending.event_data(),
7105 PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT,
7106 )
7107 .await
7108 {
7109 Ok(_) => {}
7110 Err(car_eventlog::CriticalAppendError::DurabilityUnknown { reason }) => {
7111 return Err(format!(
7112 "proposal finalization durability is unknown; retry the exact proposal.submit safely: {reason}"
7113 ));
7114 }
7115 Err(car_eventlog::CriticalAppendError::Rejected { reason }) => {
7116 return Err(format!(
7117 "proposal finalization critical journal append rejected: {reason}"
7118 ));
7119 }
7120 }
7121 if let Some(policy_session_id) = pending.policy_session_id.as_deref() {
7122 log.clear_policy_session(policy_session_id)?;
7123 }
7124 drop(log);
7125 let run_store = state.run_store.clone();
7126 let pending = pending.clone();
7127 let pending_run_id = pending.run_id.clone();
7128 let (value, cleanup_error) = run_lifecycle_durability_blocking(
7129 run_guard,
7130 "completed proposal response durability",
7131 move || {
7132 let receipt = run_store
7133 .write_completed_proposal(&pending)
7134 .map_err(|error| {
7135 format!("completed proposal response persistence failed: {error}")
7136 })?;
7137 let cleanup_error = run_store
7138 .cleanup_completed_proposal_guards(&receipt)
7139 .err()
7140 .map(|error| error.to_string());
7141 let value = run_store
7142 .completed_proposal_response_value(&receipt)
7143 .map_err(|error| {
7144 format!("completed proposal response serialization failed: {error}")
7145 })?;
7146 Ok((value, cleanup_error))
7147 },
7148 )
7149 .await?;
7150 if let Some(error) = cleanup_error {
7151 tracing::warn!(run_id = %pending_run_id, %error, "completed proposal response is durable; guard cleanup remains pending");
7152 }
7153 Ok(value)
7154}
7155
7156fn collect_accepted_proposal_preimages(
7157 original: &car_ir::ActionProposal,
7158 result: &car_ir::ProposalResult,
7159 proposal_events: &[car_eventlog::Event],
7160) -> Result<Vec<crate::run_store::AcceptedProposalPreimage>, String> {
7161 let mut accepted = Vec::new();
7162 for lineage in &result.replan_lineage {
7163 if lineage.status != car_ir::ProposalLineageStatus::Accepted {
7164 continue;
7165 }
7166 if lineage.generation == 0 {
7167 accepted.push(crate::run_store::AcceptedProposalPreimage {
7168 generation: 0,
7169 proposal: original.clone(),
7170 });
7171 continue;
7172 }
7173 let matches: Vec<_> = proposal_events
7174 .iter()
7175 .filter(|event| {
7176 event.kind == car_eventlog::EventKind::ReplanProposalReceived
7177 && event.proposal_id.as_deref() == Some(result.original_proposal_id.as_str())
7178 && event.data.get("attempt").and_then(Value::as_u64)
7179 == Some(u64::from(lineage.generation))
7180 && event.data.get("proposal_id").and_then(Value::as_str)
7181 == Some(lineage.proposal_id.as_str())
7182 && event.data.get("proposal_digest").and_then(Value::as_str)
7183 == lineage.proposal_digest.as_deref()
7184 })
7185 .collect();
7186 if matches.len() != 1 {
7187 return Err(format!(
7188 "accepted replan generation {} has {} exact proposal preimage events",
7189 lineage.generation,
7190 matches.len()
7191 ));
7192 }
7193 let proposal = matches[0].data.get("proposal").cloned().ok_or_else(|| {
7194 format!(
7195 "accepted replan generation {} is missing its proposal preimage",
7196 lineage.generation
7197 )
7198 })?;
7199 accepted.push(crate::run_store::AcceptedProposalPreimage {
7200 generation: lineage.generation,
7201 proposal: serde_json::from_value(proposal).map_err(|error| {
7202 format!(
7203 "accepted replan generation {} has invalid proposal preimage: {error}",
7204 lineage.generation
7205 )
7206 })?,
7207 });
7208 }
7209 Ok(accepted)
7210}
7211
7212#[cfg(test)]
7213mod proposal_finalization_tests {
7214 use super::*;
7215
7216 #[test]
7217 fn accepted_replan_uses_exact_authenticated_event_preimage() {
7218 let timestamp = chrono::Utc::now();
7219 let original: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7220 "id": "original",
7221 "source": "caller",
7222 "actions": [],
7223 "timestamp": timestamp,
7224 "context": {}
7225 }))
7226 .unwrap();
7227 let final_proposal: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7228 "id": "accepted-replan",
7229 "source": "replanner",
7230 "actions": [],
7231 "timestamp": timestamp,
7232 "context": {"generation": 1}
7233 }))
7234 .unwrap();
7235 let original_digest = canonical_sha256(&original).unwrap();
7236 let final_digest = canonical_sha256(&final_proposal).unwrap();
7237 let result = car_ir::ProposalResult {
7238 proposal_id: final_proposal.id.clone(),
7239 original_proposal_id: original.id.clone(),
7240 final_proposal: Some(final_proposal.clone()),
7241 accepted_proposal_preimages: vec![],
7242 replan_lineage: vec![
7243 car_ir::ProposalLineageEntry {
7244 generation: 0,
7245 proposal_id: original.id.clone(),
7246 proposal_digest: Some(original_digest),
7247 status: car_ir::ProposalLineageStatus::Accepted,
7248 rejection_reason: None,
7249 },
7250 car_ir::ProposalLineageEntry {
7251 generation: 1,
7252 proposal_id: final_proposal.id.clone(),
7253 proposal_digest: Some(final_digest.clone()),
7254 status: car_ir::ProposalLineageStatus::Accepted,
7255 rejection_reason: None,
7256 },
7257 ],
7258 results: vec![],
7259 cost: car_ir::CostSummary::default(),
7260 };
7261 let event = car_eventlog::Event {
7262 kind: car_eventlog::EventKind::ReplanProposalReceived,
7263 run_id: Some("run".to_string()),
7264 client_id: Some("client".to_string()),
7265 policy_session_id: None,
7266 action_id: None,
7267 proposal_id: Some(original.id.clone()),
7268 data: HashMap::from([
7269 ("attempt".to_string(), Value::from(1)),
7270 (
7271 "proposal_id".to_string(),
7272 Value::from(final_proposal.id.clone()),
7273 ),
7274 ("proposal_digest".to_string(), Value::from(final_digest)),
7275 (
7276 "proposal".to_string(),
7277 serde_json::to_value(&final_proposal).unwrap(),
7278 ),
7279 ]),
7280 timestamp,
7281 prev_hash: None,
7282 hash: None,
7283 };
7284
7285 let accepted = collect_accepted_proposal_preimages(&original, &result, &[event]).unwrap();
7286 assert_eq!(accepted.len(), 2);
7287 assert_eq!(accepted[0].generation, 0);
7288 assert_eq!(accepted[0].proposal, original);
7289 assert_eq!(accepted[1].generation, 1);
7290 assert_eq!(accepted[1].proposal, final_proposal);
7291 }
7292}
7293
7294fn canonical_sha256<T: serde::Serialize + ?Sized>(value: &T) -> Result<String, String> {
7298 let canonical = car_inference::catalog_identity::canonical_json(value)?;
7299 Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
7300}
7301
7302fn proposal_durability_quarantine(
7303 run_id: &str,
7304 state_kind: &str,
7305 error: &std::io::Error,
7306) -> String {
7307 format!(
7308 "proposal {state_kind} durability state is unreadable for run_id `{run_id}`; outcome unknown and CAR will not redispatch, complete, replace, or fabricate a terminal: {error}"
7309 )
7310}
7311
7312const PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT: std::time::Duration =
7315 std::time::Duration::from_secs(5);
7316
7317async fn handle_session_policy_open(
7318 session: &crate::session::ClientSession,
7319) -> Result<Value, String> {
7320 let id = session.runtime.open_session().await;
7321 Ok(serde_json::json!({ "session_id": id }))
7322}
7323
7324async fn handle_session_policy_close(
7325 req: &JsonRpcMessage,
7326 session: &crate::session::ClientSession,
7327) -> Result<Value, String> {
7328 let sid = req
7329 .params
7330 .get("session_id")
7331 .and_then(|v| v.as_str())
7332 .ok_or("missing 'session_id'")?;
7333 let closed = session.runtime.close_session(sid).await;
7334 Ok(serde_json::json!({ "closed": closed }))
7335}
7336
7337async fn handle_policy_register(
7343 req: &JsonRpcMessage,
7344 session: &crate::session::ClientSession,
7345) -> Result<Value, String> {
7346 let def: PolicyDefinition = serde_json::from_value(req.params.clone())
7347 .map_err(|e| format!("invalid policy params: {e}"))?;
7348 let session_id = req
7349 .params
7350 .get("session_id")
7351 .and_then(|v| v.as_str())
7352 .map(str::to_string);
7353 let check = build_policy_check(&def)
7354 .ok_or_else(|| format!("unsupported policy rule '{}'", def.rule))?;
7355 let denies_tool = blanket_denied_tool(&def);
7356 match session_id {
7357 Some(sid) => {
7358 let registered = match &denies_tool {
7359 Some(tool) => {
7360 session
7361 .runtime
7362 .register_tool_deny_in_session(&sid, &def.name, tool, check, "")
7363 .await
7364 }
7365 None => {
7366 session
7367 .runtime
7368 .register_policy_in_session(&sid, &def.name, check, "")
7369 .await
7370 }
7371 };
7372 registered.map(
7373 |_| serde_json::json!({ "registered": def.name, "scope": { "session_id": sid } }),
7374 )
7375 }
7376 None => {
7377 let mut policies = session.runtime.policies.write().await;
7378 match &denies_tool {
7379 Some(tool) => policies.register_tool_deny(&def.name, tool, check, ""),
7380 None => policies.register(&def.name, check, ""),
7381 }
7382 Ok(serde_json::json!({ "registered": def.name, "scope": "global" }))
7383 }
7384 }
7385}
7386
7387async fn handle_policy_unregister(
7400 req: &JsonRpcMessage,
7401 session: &crate::session::ClientSession,
7402) -> Result<Value, String> {
7403 let name = req
7404 .params
7405 .get("name")
7406 .and_then(|v| v.as_str())
7407 .ok_or("missing 'name'")?;
7408 let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7409 let removed = session
7410 .runtime
7411 .unregister_policy(name, session_id)
7412 .await
7413 .map_err(|e| e.to_string())?;
7414 Ok(serde_json::json!({
7415 "unregistered": name,
7416 "removed": removed,
7417 "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7418 .unwrap_or_else(|| Value::String("global".to_string())),
7419 }))
7420}
7421
7422async fn handle_policy_list(
7428 req: &JsonRpcMessage,
7429 session: &crate::session::ClientSession,
7430) -> Result<Value, String> {
7431 let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7432 let policies = session
7433 .runtime
7434 .list_policies(session_id)
7435 .await
7436 .map_err(|e| e.to_string())?;
7437 Ok(serde_json::json!({
7438 "policies": policies
7439 .into_iter()
7440 .map(|(name, description)| serde_json::json!({
7441 "name": name,
7442 "description": description,
7443 }))
7444 .collect::<Vec<_>>(),
7445 "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7446 .unwrap_or_else(|| Value::String("global".to_string())),
7447 }))
7448}
7449
7450async fn handle_verify(
7451 req: &JsonRpcMessage,
7452 session: &crate::session::ClientSession,
7453) -> Result<Value, String> {
7454 let vr: VerifyRequest =
7455 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7456 let tools_guard = session.runtime.tools.read().await;
7462 let result = car_verify::verify_with_schemas(
7463 &vr.proposal,
7464 Some(&vr.initial_state),
7465 Some(&tools_guard),
7466 30,
7467 );
7468 drop(tools_guard);
7469 let evidence = serde_json::to_value(&result.evidence).unwrap_or(Value::Null);
7470 serde_json::to_value(VerifyResponse {
7471 valid: result.valid,
7472 issues: result
7473 .issues
7474 .iter()
7475 .map(|i| VerifyIssueProto {
7476 action_id: i.action_id.clone(),
7477 severity: i.severity.clone(),
7478 message: i.message.clone(),
7479 tier: i.tier.as_str().to_string(),
7480 })
7481 .collect(),
7482 simulated_state: result.simulated_state,
7483 execution_levels: result.execution_levels,
7484 conflicts: result.conflicts,
7485 evidence,
7486 })
7487 .map_err(|e| e.to_string())
7488}
7489
7490const MONTE_CARLO_RATE_WINDOW_DAYS: u32 = 30;
7497
7498#[derive(Debug, Deserialize)]
7507struct MonteCarloParams {
7508 proposal: car_ir::ActionProposal,
7509 #[serde(default)]
7510 initial_state: HashMap<String, Value>,
7511 #[serde(default)]
7512 tool_success_rates: HashMap<String, f64>,
7513 #[serde(default)]
7514 goal: Option<car_verify::GoalCondition>,
7515 #[serde(default)]
7516 config: car_verify::MonteCarloConfig,
7517 #[serde(default)]
7521 rate_window_days: Option<u32>,
7522}
7523
7524async fn handle_verify_monte_carlo(
7525 req: &JsonRpcMessage,
7526 session: &crate::session::ClientSession,
7527) -> Result<Value, String> {
7528 let p: MonteCarloParams =
7529 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7530
7531 let window = p.rate_window_days.unwrap_or(MONTE_CARLO_RATE_WINDOW_DAYS);
7532 let derived = if window > 0 {
7533 session.runtime.tool_feedback(window)
7534 } else {
7535 None
7536 };
7537
7538 let mut rates = derived
7541 .as_ref()
7542 .map(|f| f.tool_success_rates.clone())
7543 .unwrap_or_default();
7544 let overridden: Vec<String> = p
7545 .tool_success_rates
7546 .keys()
7547 .filter(|t| rates.contains_key(*t))
7548 .cloned()
7549 .collect();
7550 rates.extend(p.tool_success_rates.iter().map(|(k, v)| (k.clone(), *v)));
7551
7552 let result = car_verify::simulate_monte_carlo(
7553 &p.proposal,
7554 Some(&p.initial_state),
7555 &rates,
7556 p.goal.as_ref(),
7557 &p.config,
7558 );
7559
7560 let counts = derived
7565 .as_ref()
7566 .map(|f| f.tool_dispatch_counts.clone())
7567 .unwrap_or_default();
7568 let mut tools: Vec<Value> = p
7569 .proposal
7570 .actions
7571 .iter()
7572 .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
7573 .filter_map(|a| a.tool.clone())
7574 .collect::<std::collections::BTreeSet<_>>()
7575 .into_iter()
7576 .map(|tool| {
7577 let caller_set = p.tool_success_rates.contains_key(&tool);
7578 let observed = counts.get(&tool).copied();
7579 let source = if caller_set {
7580 "caller"
7581 } else if observed.is_some() {
7582 "trajectories"
7583 } else {
7584 "default"
7585 };
7586 serde_json::json!({
7587 "tool": tool,
7588 "rate": rates.get(&tool).copied()
7589 .unwrap_or(p.config.default_success_rate),
7590 "source": source,
7591 "succeeded": observed.map(|(s, _)| s),
7592 "dispatched": observed.map(|(_, d)| d),
7593 })
7594 })
7595 .collect();
7596 tools.sort_by(|a, b| a["tool"].as_str().cmp(&b["tool"].as_str()));
7597
7598 let mut out = serde_json::to_value(&result).map_err(|e| e.to_string())?;
7599 if let Some(obj) = out.as_object_mut() {
7600 obj.insert(
7601 "rate_provenance".to_string(),
7602 serde_json::json!({
7603 "window_days": window,
7604 "trajectories_available": derived.is_some(),
7605 "overridden_by_caller": overridden,
7606 "tools": tools,
7607 }),
7608 );
7609 }
7610 Ok(out)
7611}
7612
7613async fn handle_sync_status(state: &ServerState) -> Result<Value, String> {
7621 let sub = state.sync_subsystem()?;
7622 let mut s = sub.lock().await;
7623 s.status()
7624}
7625
7626async fn handle_sync_knowledge(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7632 let pump = req
7633 .params
7634 .get("pump")
7635 .and_then(Value::as_bool)
7636 .unwrap_or(false);
7637 let personal = {
7639 let sub = state.sync_subsystem()?;
7640 let mut s = sub.lock().await;
7641 if pump {
7642 let _ = s.pump();
7643 }
7644 s.knowledge()
7645 };
7646 let org = match state.org_subsystem() {
7649 Some(org_sub) => {
7650 let mut s = org_sub.lock().await;
7651 if pump {
7652 let _ = s.pump();
7653 }
7654 Some(s.knowledge())
7655 }
7656 None => None,
7657 };
7658 Ok(serde_json::json!({ "facts": merge_knowledge(personal, org) }))
7659}
7660
7661fn merge_knowledge(personal: Vec<Value>, org: Option<Vec<Value>>) -> Vec<Value> {
7676 match org {
7677 Some(mut org) => {
7678 org.extend(personal);
7679 org
7680 }
7681 None => personal,
7682 }
7683}
7684
7685async fn handle_sync_append(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7686 let surface_str = req
7687 .params
7688 .get("surface")
7689 .and_then(Value::as_str)
7690 .ok_or("missing 'surface'")?;
7691 let surface = crate::sync::parse_surface(surface_str)?;
7692 let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
7693 let scope = crate::sync::parse_scope(&req.params);
7694 let sub = state.subsystem_for_scope(&scope)?;
7695 let mut s = sub.lock().await;
7696 s.append(scope, surface, payload)
7697}
7698
7699async fn handle_sync_record_turn(
7700 req: &JsonRpcMessage,
7701 state: &ServerState,
7702) -> Result<Value, String> {
7703 let conversation_id = req
7704 .params
7705 .get("conversation_id")
7706 .and_then(Value::as_str)
7707 .unwrap_or("");
7708 let role = req
7709 .params
7710 .get("role")
7711 .and_then(Value::as_str)
7712 .unwrap_or("user");
7713 let content = req
7714 .params
7715 .get("content")
7716 .and_then(Value::as_str)
7717 .unwrap_or("");
7718 let tool_calls = req
7719 .params
7720 .get("tool_calls")
7721 .and_then(Value::as_array)
7722 .cloned()
7723 .unwrap_or_default();
7724 let tool_use_id = req.params.get("tool_use_id").and_then(Value::as_str);
7725 let timestamp = req
7726 .params
7727 .get("timestamp")
7728 .and_then(Value::as_u64)
7729 .unwrap_or(0);
7730 let scope = crate::sync::parse_scope(&req.params);
7731 let sub = state.subsystem_for_scope(&scope)?;
7732 let mut s = sub.lock().await;
7733 s.record_turn(
7734 scope,
7735 conversation_id,
7736 role,
7737 content,
7738 tool_calls,
7739 tool_use_id,
7740 timestamp,
7741 )
7742}
7743
7744async fn handle_sync_record_intent(
7745 req: &JsonRpcMessage,
7746 state: &ServerState,
7747 session: &crate::session::ClientSession,
7748) -> Result<Value, String> {
7749 let agent_id = req
7750 .params
7751 .get("agent_id")
7752 .and_then(Value::as_str)
7753 .ok_or("missing 'agent_id'")?;
7754 require_sync_agent_or_host(session, state, "sync.record_intent", agent_id).await?;
7755 record_sync_intent(req, state, agent_id).await
7756}
7757
7758async fn record_sync_intent(
7759 req: &JsonRpcMessage,
7760 state: &ServerState,
7761 agent_id: &str,
7762) -> Result<Value, String> {
7763 let run_id = req
7764 .params
7765 .get("run_id")
7766 .and_then(Value::as_str)
7767 .ok_or("missing 'run_id'")?;
7768 let epoch = req
7769 .params
7770 .get("epoch")
7771 .and_then(Value::as_u64)
7772 .ok_or("missing 'epoch'")?;
7773 let status_str = req
7774 .params
7775 .get("status")
7776 .and_then(Value::as_str)
7777 .ok_or("missing 'status'")?;
7778 let status = crate::sync::parse_intent_status(status_str)?;
7779 let intent = car_sync::Intent::new(agent_id, run_id, epoch, status);
7780 let scope = crate::sync::parse_scope(&req.params);
7781 let sub = state.subsystem_for_scope(&scope)?;
7782 let mut s = sub.lock().await;
7783 s.record_intent(scope, &intent)
7784}
7785
7786async fn handle_sync_pump(state: &ServerState) -> Result<Value, String> {
7787 let user_result = {
7791 let sub = state.sync_subsystem()?;
7792 let mut s = sub.lock().await;
7793 s.pump()?
7794 };
7795 let Some(org_sub) = state.org_subsystem() else {
7799 return Ok(user_result);
7800 };
7801 let org_result = {
7802 let mut s = org_sub.lock().await;
7803 s.pump()
7804 };
7805 match org_result {
7806 Ok(org) => Ok(serde_json::json!({ "user": user_result, "org": org })),
7807 Err(e) => Ok(serde_json::json!({ "user": user_result, "org_error": e })),
7808 }
7809}
7810
7811async fn handle_sync_checkpoint(state: &ServerState) -> Result<Value, String> {
7812 let sub = state.sync_subsystem()?;
7813 let mut s = sub.lock().await;
7814 s.checkpoint()
7815}
7816
7817async fn handle_sync_rebase(state: &ServerState) -> Result<Value, String> {
7818 let sub = state.sync_subsystem()?;
7819 let mut s = sub.lock().await;
7820 s.rebase()
7821}
7822
7823async fn handle_sync_transcript(
7824 req: &JsonRpcMessage,
7825 state: &ServerState,
7826) -> Result<Value, String> {
7827 let conversation_id = req
7828 .params
7829 .get("conversation_id")
7830 .and_then(Value::as_str)
7831 .unwrap_or("");
7832 let sub = state.sync_subsystem()?;
7833 let s = sub.lock().await;
7834 Ok(s.transcript(conversation_id))
7835}
7836
7837async fn handle_sync_resume(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7838 let conversation_id = req
7839 .params
7840 .get("conversation_id")
7841 .and_then(Value::as_str)
7842 .unwrap_or("");
7843 let sub = state.sync_subsystem()?;
7844 let s = sub.lock().await;
7845 s.resume(conversation_id)
7846}
7847
7848async fn handle_sync_assistant_checkpoint_put(
7849 req: &JsonRpcMessage,
7850 state: &ServerState,
7851) -> Result<Value, String> {
7852 let raw = req
7853 .params
7854 .get("checkpoint")
7855 .cloned()
7856 .ok_or("missing 'checkpoint'")?;
7857 let checkpoint =
7858 serde_json::from_value(raw).map_err(|e| format!("invalid assistant checkpoint: {e}"))?;
7859 let sub = state.sync_subsystem()?;
7860 let mut s = sub.lock().await;
7861 s.assistant_checkpoint_put(checkpoint)
7862}
7863
7864async fn handle_sync_assistant_checkpoint_get(
7865 req: &JsonRpcMessage,
7866 state: &ServerState,
7867) -> Result<Value, String> {
7868 let session_id = req
7869 .params
7870 .get("session_id")
7871 .and_then(Value::as_str)
7872 .ok_or("missing 'session_id'")?;
7873 let sub = state.sync_subsystem()?;
7874 let s = sub.lock().await;
7875 serde_json::to_value(s.assistant_checkpoint_get(session_id)?)
7876 .map_err(|e| format!("serialize assistant checkpoint: {e}"))
7877}
7878
7879async fn handle_sync_assistant_action_put(
7880 req: &JsonRpcMessage,
7881 state: &ServerState,
7882) -> Result<Value, String> {
7883 let raw = req
7884 .params
7885 .get("record")
7886 .cloned()
7887 .ok_or("missing 'record'")?;
7888 let record = serde_json::from_value(raw)
7889 .map_err(|e| format!("invalid supervised action record: {e}"))?;
7890 let sub = state.sync_subsystem()?;
7891 let mut s = sub.lock().await;
7892 s.assistant_action_put(record)
7893}
7894
7895async fn handle_sync_assistant_action_get(
7896 req: &JsonRpcMessage,
7897 state: &ServerState,
7898) -> Result<Value, String> {
7899 let action_id = req
7900 .params
7901 .get("action_id")
7902 .and_then(Value::as_str)
7903 .ok_or("missing 'action_id'")?;
7904 let sub = state.sync_subsystem()?;
7905 let s = sub.lock().await;
7906 serde_json::to_value(s.assistant_action_get(action_id)?)
7907 .map_err(|e| format!("serialize supervised action record: {e}"))
7908}
7909
7910async fn handle_sync_fence_check(
7911 req: &JsonRpcMessage,
7912 state: &ServerState,
7913 session: &crate::session::ClientSession,
7914) -> Result<Value, String> {
7915 let agent_id = req
7916 .params
7917 .get("agent_id")
7918 .and_then(Value::as_str)
7919 .ok_or("missing 'agent_id'")?;
7920 require_sync_agent_or_host(session, state, "sync.fence_check", agent_id).await?;
7921 check_sync_fence(req, state, agent_id).await
7922}
7923
7924async fn check_sync_fence(
7925 req: &JsonRpcMessage,
7926 state: &ServerState,
7927 agent_id: &str,
7928) -> Result<Value, String> {
7929 let run_id = req
7930 .params
7931 .get("run_id")
7932 .and_then(Value::as_str)
7933 .ok_or("missing 'run_id'")?;
7934 let epoch = req
7935 .params
7936 .get("epoch")
7937 .and_then(Value::as_u64)
7938 .ok_or("missing 'epoch'")?;
7939 let sub = state.sync_subsystem()?;
7940 let mut s = sub.lock().await;
7941 s.fence_check(agent_id, run_id, epoch)
7942}
7943
7944async fn handle_lease_acquire(
7945 req: &JsonRpcMessage,
7946 state: &ServerState,
7947 session: &crate::session::ClientSession,
7948) -> Result<Value, String> {
7949 let agent_id = req
7950 .params
7951 .get("agent_id")
7952 .and_then(Value::as_str)
7953 .ok_or("missing 'agent_id'")?;
7954 require_sync_agent_or_host(session, state, "lease.acquire", agent_id).await?;
7955 acquire_lease(req, state, agent_id).await
7956}
7957
7958async fn acquire_lease(
7959 req: &JsonRpcMessage,
7960 state: &ServerState,
7961 agent_id: &str,
7962) -> Result<Value, String> {
7963 let ttl_ms = req
7964 .params
7965 .get("ttl_ms")
7966 .and_then(Value::as_u64)
7967 .unwrap_or(30_000);
7968 let sub = state.sync_subsystem()?;
7969 let mut s = sub.lock().await;
7970 s.lease_acquire(agent_id, ttl_ms)
7971}
7972
7973async fn handle_lease_renew(
7974 req: &JsonRpcMessage,
7975 state: &ServerState,
7976 session: &crate::session::ClientSession,
7977) -> Result<Value, String> {
7978 let agent_id = req
7979 .params
7980 .get("agent_id")
7981 .and_then(Value::as_str)
7982 .ok_or("missing 'agent_id'")?;
7983 require_sync_agent_or_host(session, state, "lease.renew", agent_id).await?;
7984 renew_lease(req, state, agent_id).await
7985}
7986
7987async fn renew_lease(
7988 req: &JsonRpcMessage,
7989 state: &ServerState,
7990 agent_id: &str,
7991) -> Result<Value, String> {
7992 let epoch = req
7993 .params
7994 .get("epoch")
7995 .and_then(Value::as_u64)
7996 .ok_or("missing 'epoch'")?;
7997 let ttl_ms = req
7998 .params
7999 .get("ttl_ms")
8000 .and_then(Value::as_u64)
8001 .unwrap_or(30_000);
8002 let sub = state.sync_subsystem()?;
8003 let mut s = sub.lock().await;
8004 s.lease_renew(agent_id, epoch, ttl_ms)
8005}
8006
8007async fn handle_lease_release(
8008 req: &JsonRpcMessage,
8009 state: &ServerState,
8010 session: &crate::session::ClientSession,
8011) -> Result<Value, String> {
8012 let agent_id = req
8013 .params
8014 .get("agent_id")
8015 .and_then(Value::as_str)
8016 .ok_or("missing 'agent_id'")?;
8017 require_sync_agent_or_host(session, state, "lease.release", agent_id).await?;
8018 release_lease(req, state, agent_id).await
8019}
8020
8021async fn release_lease(
8022 req: &JsonRpcMessage,
8023 state: &ServerState,
8024 agent_id: &str,
8025) -> Result<Value, String> {
8026 let epoch = req
8027 .params
8028 .get("epoch")
8029 .and_then(Value::as_u64)
8030 .ok_or("missing 'epoch'")?;
8031 let sub = state.sync_subsystem()?;
8032 let mut s = sub.lock().await;
8033 s.lease_release(agent_id, epoch)
8034}
8035
8036async fn handle_lease_status(
8037 req: &JsonRpcMessage,
8038 state: &ServerState,
8039 bound_agent: Option<&str>,
8040) -> Result<Value, String> {
8041 let agent_id = req
8042 .params
8043 .get("agent_id")
8044 .and_then(Value::as_str)
8045 .ok_or("missing 'agent_id'")?;
8046 require_own_agent(bound_agent, "lease.status", agent_id)?;
8050 let sub = state.sync_subsystem()?;
8051 let mut s = sub.lock().await;
8052 s.lease_status(agent_id)
8053}
8054
8055fn handle_secret_store_activity(
8060 session: &crate::session::ClientSession,
8061 state: &ServerState,
8062) -> Result<Value, String> {
8063 require_approval_authority(session, state)?;
8064 serde_json::to_value(car_secrets::secret_store_activity()).map_err(|error| error.to_string())
8065}
8066
8067pub use car_proto::HOST_MANAGEMENT_METHODS;
8074
8075fn require_approval_authority(
8084 session: &crate::session::ClientSession,
8085 state: &ServerState,
8086) -> Result<(), String> {
8087 if state.host_token.get().is_some()
8088 && !session.is_host.load(std::sync::atomic::Ordering::Acquire)
8089 {
8090 return Err("this operation requires the host-management role".into());
8091 }
8092 Ok(())
8093}
8094
8095fn require_own_agent(bound: Option<&str>, method: &str, target: &str) -> Result<(), String> {
8118 match bound {
8119 Some(b) if b != target => Err(format!(
8120 "{method} `{target}` does not match this session's bound agent: a supervised \
8121 agent may only act on itself"
8122 )),
8123 _ => Ok(()),
8124 }
8125}
8126
8127async fn require_own_agent_or_host(
8132 session: &crate::session::ClientSession,
8133 state: &ServerState,
8134 method: &str,
8135 target: &str,
8136) -> Result<(), String> {
8137 if session.is_host.load(Ordering::Acquire) {
8138 return Ok(());
8139 }
8140 let bound = session.agent_id.lock().await.clone();
8141 match bound.as_deref() {
8142 Some(_) => require_own_agent(bound.as_deref(), method, target),
8143 None if state.host_token.get().is_some() => {
8144 Err("this operation requires the host-management role".into())
8145 }
8146 None => Ok(()),
8147 }
8148}
8149
8150async fn require_sync_agent_or_host(
8153 session: &crate::session::ClientSession,
8154 state: &ServerState,
8155 method: &str,
8156 target: &str,
8157) -> Result<(), String> {
8158 require_own_agent_or_host(session, state, method, target).await
8159}
8160
8161pub(crate) async fn require_host_lifecycle_authority(
8165 session: &crate::session::ClientSession,
8166 state: &ServerState,
8167) -> Result<(), String> {
8168 require_approval_authority(session, state)?;
8169 if session.agent_id.lock().await.is_some() && !session.is_host.load(Ordering::Acquire) {
8170 return Err("this operation requires the host-management role".into());
8171 }
8172 Ok(())
8173}
8174
8175async fn require_agent_permissions_authority(
8195 session: &crate::session::ClientSession,
8196 state: &ServerState,
8197) -> Result<(), String> {
8198 require_approval_authority(session, state)?;
8199 if session.agent_id.lock().await.is_some() {
8200 return Err("this operation requires the host-management role".into());
8201 }
8202 Ok(())
8203}
8204
8205async fn live_agent_callback_registration(
8210 state: &ServerState,
8211 agent_id: &str,
8212 tool: &str,
8213) -> Result<
8214 (
8215 Arc<crate::session::ClientSession>,
8216 tokio::sync::OwnedMutexGuard<()>,
8217 String,
8218 ),
8219 String,
8220> {
8221 let client_id = state
8222 .attached_agents
8223 .lock()
8224 .await
8225 .get(agent_id)
8226 .cloned()
8227 .ok_or_else(|| format!("agent '{agent_id}' is not attached"))?;
8228 let target = state
8229 .sessions
8230 .lock()
8231 .await
8232 .get(&client_id)
8233 .cloned()
8234 .ok_or_else(|| format!("attached agent '{agent_id}' has no live session"))?;
8235 let guard = target.run_lifecycle_guard.clone().lock_owned().await;
8236
8237 let still_attached = state
8238 .attached_agents
8239 .lock()
8240 .await
8241 .get(agent_id)
8242 .is_some_and(|current| current == &target.client_id);
8243 let bound_agent = target.agent_id.lock().await.clone();
8244 if !still_attached
8245 || !target
8246 .authenticated
8247 .load(std::sync::atomic::Ordering::Acquire)
8248 || bound_agent.as_deref() != Some(agent_id)
8249 {
8250 return Err(format!(
8251 "agent '{agent_id}' callback session changed while authorizing tool '{tool}'"
8252 ));
8253 }
8254 if target.runtime.registry.get(tool).await.is_some() {
8255 return Err(format!(
8256 "tool '{tool}' is server-owned and is ineligible for agent standing authority"
8257 ));
8258 }
8259 let digest = target
8260 .callback_tool_schema_digests
8261 .read()
8262 .await
8263 .get(tool)
8264 .cloned()
8265 .ok_or_else(|| {
8266 format!(
8267 "tool '{tool}' has no eligible exact reverse-callback registration for agent '{agent_id}'"
8268 )
8269 })?;
8270 Ok((target, guard, digest))
8271}
8272
8273async fn handle_agent_permission_set_tool(
8274 req: &JsonRpcMessage,
8275 state: &ServerState,
8276) -> Result<Value, String> {
8277 let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8278 let mode = crate::agent_permissions::requested_tool_mode(&req.params)?;
8279 if mode == car_policy::ApprovalMode::AlwaysAllow {
8280 let (_target, _guard, digest) =
8281 live_agent_callback_registration(state, agent_id, tool).await?;
8282 crate::agent_permissions::handle_set_tool(req, Some(digest))
8283 } else {
8284 crate::agent_permissions::handle_set_tool(req, None)
8285 }
8286}
8287
8288async fn handle_agent_permission_evaluate_tool(
8289 req: &JsonRpcMessage,
8290 state: &ServerState,
8291) -> Result<Value, String> {
8292 let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8293 let mut value = crate::agent_permissions::handle_evaluate_tool(req)?;
8294 let has_override = value
8295 .get("has_tool_override")
8296 .and_then(Value::as_bool)
8297 .unwrap_or(false);
8298 let mode = value.get("mode").and_then(Value::as_str);
8299 let stored_digest = value.get("schema_digest").and_then(Value::as_str);
8300 let live_digest = if mode == Some("always_allow") && has_override {
8301 live_agent_callback_registration(state, agent_id, tool)
8302 .await
8303 .ok()
8304 .map(|(_target, _guard, digest)| digest)
8305 } else {
8306 None
8307 };
8308 let schema_bound = stored_digest.is_some();
8309 let active = if mode == Some("always_allow") {
8310 stored_digest
8311 .zip(live_digest.as_deref())
8312 .is_some_and(|(stored, live)| stored == live)
8313 } else {
8314 has_override
8315 };
8316 let object = value
8317 .as_object_mut()
8318 .ok_or_else(|| "agent permission evaluation returned a non-object".to_string())?;
8319 object.insert("active".into(), active.into());
8320 object.insert("schema_bound".into(), schema_bound.into());
8321 object.insert("schema_digest_present".into(), schema_bound.into());
8322 Ok(value)
8323}
8324
8325pub async fn session_principal_for_peers(session: &crate::session::ClientSession) -> String {
8334 session_principal(session).await
8335}
8336
8337async fn session_principal(session: &crate::session::ClientSession) -> String {
8338 if let Some(agent) = session.agent_id.lock().await.clone() {
8339 format!("agent:{agent}")
8340 } else {
8341 format!("conn:{}", session.client_id)
8342 }
8343}
8344
8345async fn handle_permission_get_tier(
8347 session: &crate::session::ClientSession,
8348) -> Result<Value, String> {
8349 let tier = session.permission_gate.read().await.granted_tier();
8350 Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8351}
8352
8353async fn handle_permission_set_tier(
8355 req: &JsonRpcMessage,
8356 session: &crate::session::ClientSession,
8357 state: &ServerState,
8358) -> Result<Value, String> {
8359 require_approval_authority(session, state)?;
8360 let tier_str = req
8361 .params
8362 .get("tier")
8363 .and_then(|v| v.as_str())
8364 .ok_or("missing 'tier'")?;
8365 let tier = car_policy::PermissionTier::from_str_opt(tier_str)
8366 .ok_or_else(|| format!("invalid tier '{tier_str}'"))?;
8367 session.permission_gate.write().await.set_granted_tier(tier);
8368 Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8369}
8370
8371fn messaging_store() -> crate::messaging_config::MessagingConfigStore {
8402 crate::messaging_config::MessagingConfigStore::from_home()
8403}
8404
8405fn messaging_channel_from_params(
8412 params: &Value,
8413) -> Result<crate::messaging_config::ChannelId, String> {
8414 match params.get("channel") {
8415 None | Some(Value::Null) => Ok(crate::messaging_config::ChannelId::IMessage),
8416 Some(Value::String(s)) => crate::messaging_config::ChannelId::from_str_opt(s)
8417 .ok_or_else(|| format!("unknown messaging channel '{s}'")),
8418 Some(other) => Err(format!("messaging channel must be a string, got {other}")),
8419 }
8420}
8421
8422fn messaging_config_view(
8428 store: &crate::messaging_config::MessagingConfigStore,
8429 channel: crate::messaging_config::ChannelId,
8430) -> Result<car_proto::MessagingConfigView, String> {
8431 let cfg = store.load()?.channel(channel);
8432 Ok(car_proto::MessagingConfigView {
8433 channel: channel.as_str().to_string(),
8434 enabled: cfg.enabled,
8435 allowlisted_handles: cfg.allowlisted_handles,
8436 pairing_active: cfg.active_pairing_code.is_some(),
8437 })
8438}
8439
8440async fn handle_messaging_config_get(
8444 req: &JsonRpcMessage,
8445 session: &crate::session::ClientSession,
8446 state: &ServerState,
8447) -> Result<Value, String> {
8448 require_approval_authority(session, state)?;
8449 let channel = messaging_channel_from_params(&req.params)?;
8450 let view = messaging_config_view(&messaging_store(), channel)?;
8451 serde_json::to_value(view).map_err(|e| e.to_string())
8452}
8453
8454async fn handle_messaging_config_set(
8459 req: &JsonRpcMessage,
8460 session: &crate::session::ClientSession,
8461 state: &ServerState,
8462) -> Result<Value, String> {
8463 require_approval_authority(session, state)?;
8464 let set: car_proto::MessagingConfigSetRequest = serde_json::from_value(req.params.clone())
8465 .map_err(|e| format!("invalid messaging.config.set params: {e}"))?;
8466 let channel = messaging_channel_from_params(&req.params)?;
8473 let store = messaging_store();
8474 if let (Some(bot), Some(app)) = (set.bot_token.as_deref(), set.app_token.as_deref()) {
8484 if channel == crate::messaging_config::ChannelId::Slack {
8485 let secrets = car_secrets::SecretStore::new();
8486 let refs = crate::slack_adapter::provision_slack_tokens(&secrets, bot, app)
8487 .map_err(|e| format!("provision slack tokens: {e}"))?;
8488 store.set_slack_token_ref_for(
8489 channel,
8490 crate::messaging_config::SlackTokenRef {
8491 bot_token_key: refs.bot_token_key,
8492 app_token_key: refs.app_token_key,
8493 },
8494 )?;
8495 }
8496 }
8497 if let Some(slack_channel) = set.slack_channel.as_deref() {
8503 if channel == crate::messaging_config::ChannelId::Slack && !slack_channel.is_empty() {
8504 store.set_slack_channel_id_for(channel, slack_channel)?;
8505 }
8506 }
8507 if let Some(enabled) = set.enabled {
8508 let was_enabled = store.is_enabled_for(channel).unwrap_or(false);
8513 store.set_enabled_for(channel, enabled)?;
8514 if enabled && !was_enabled {
8515 if let Some(sup) = state.channel_supervisor.get() {
8516 if let Err(e) = sup.ensure_spawned(channel) {
8528 tracing::warn!(
8529 channel = channel.as_str(),
8530 error = %e,
8531 "channel enabled in config but its runtime watcher could not start; \
8532 messaging.status will report watcher_running:false"
8533 );
8534 }
8535 }
8536 }
8537 }
8538 if let Some(handles) = set.allowlisted_handles {
8539 store.set_allowlist_for(channel, handles)?;
8540 }
8541 for handle in &set.add_handles {
8542 store.add_handle_for(channel, handle)?;
8543 }
8544 for handle in &set.remove_handles {
8545 store.remove_handle_for(channel, handle)?;
8546 }
8547 let view = messaging_config_view(&store, channel)?;
8548 serde_json::to_value(view).map_err(|e| e.to_string())
8549}
8550
8551async fn handle_messaging_pairing_start(
8556 req: &JsonRpcMessage,
8557 session: &crate::session::ClientSession,
8558 state: &ServerState,
8559) -> Result<Value, String> {
8560 require_approval_authority(session, state)?;
8561 let channel = messaging_channel_from_params(&req.params)?;
8562 let store = messaging_store();
8563 let code = store.mint_pairing_code_for(channel)?;
8564 let view = messaging_config_view(&store, channel)?;
8565 let resp = car_proto::MessagingPairingStartResponse {
8566 pairing_code: code,
8567 config: view,
8568 };
8569 serde_json::to_value(resp).map_err(|e| e.to_string())
8570}
8571
8572async fn handle_messaging_pairing_status(
8577 req: &JsonRpcMessage,
8578 session: &crate::session::ClientSession,
8579 state: &ServerState,
8580) -> Result<Value, String> {
8581 require_approval_authority(session, state)?;
8582 let channel = messaging_channel_from_params(&req.params)?;
8583 let code = messaging_store().active_pairing_code_for(channel)?;
8584 let resp = car_proto::MessagingPairingStatusResponse {
8585 pairing_active: code.is_some(),
8586 pairing_code: code,
8587 };
8588 serde_json::to_value(resp).map_err(|e| e.to_string())
8589}
8590
8591#[allow(unused_variables)]
8598fn messaging_fda_readable(channel: crate::messaging_config::ChannelId) -> bool {
8599 match channel {
8600 crate::messaging_config::ChannelId::IMessage => {
8601 #[cfg(target_os = "macos")]
8602 {
8603 car_ffi_common::integrations::messages_fda_readable()
8604 }
8605 #[cfg(not(target_os = "macos"))]
8606 {
8607 true
8608 }
8609 }
8610 crate::messaging_config::ChannelId::Slack => true,
8611 }
8612}
8613
8614async fn handle_messaging_status(
8626 req: &JsonRpcMessage,
8627 session: &crate::session::ClientSession,
8628 state: &ServerState,
8629) -> Result<Value, String> {
8630 require_approval_authority(session, state)?;
8631 let channel = messaging_channel_from_params(&req.params)?;
8632 let store = messaging_store();
8633 let enabled = store.is_enabled_for(channel).unwrap_or(false);
8634 let paired = !store.allowlist_for(channel).unwrap_or_default().is_empty();
8635
8636 let (watcher_running, liveness) = match state.channel_supervisor.get() {
8637 Some(sup) => (sup.is_spawned(channel), sup.liveness_snapshot(channel)),
8638 None => (false, crate::channel_supervisor::ChannelLiveness::default()),
8639 };
8640
8641 let view = car_proto::MessagingStatusView {
8642 channel: channel.as_str().to_string(),
8643 enabled,
8644 paired,
8645 watcher_running,
8646 fda_readable: messaging_fda_readable(channel),
8647 last_send_at_ms: liveness.last_send_at_ms,
8648 last_send_ok: liveness.last_send_ok,
8649 last_error: liveness.last_error,
8650 };
8651 serde_json::to_value(view).map_err(|e| e.to_string())
8652}
8653
8654async fn handle_messaging_test_send(
8667 req: &JsonRpcMessage,
8668 session: &crate::session::ClientSession,
8669 state: &ServerState,
8670) -> Result<Value, String> {
8671 require_approval_authority(session, state)?;
8672 let channel = messaging_channel_from_params(&req.params)?;
8673
8674 let result = match channel {
8675 crate::messaging_config::ChannelId::IMessage => {
8676 let liveness = state.channel_supervisor.get().map(|sup| sup.liveness());
8679 let base_dir = car_home::root_or_relative();
8682 let orch = match liveness {
8683 Some(liveness) => {
8684 crate::messaging_orchestrator::MessagingOrchestrator::with_liveness(
8685 state.host.clone(),
8686 messaging_store(),
8687 std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8688 base_dir,
8689 liveness,
8690 )
8691 }
8692 None => crate::messaging_orchestrator::MessagingOrchestrator::new(
8693 state.host.clone(),
8694 messaging_store(),
8695 std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8696 base_dir,
8697 ),
8698 };
8699 orch.send_test().await
8700 }
8701 crate::messaging_config::ChannelId::Slack => {
8702 Err("Test send is not supported on the Slack channel yet.".to_string())
8703 }
8704 };
8705
8706 let resp = match result {
8707 Ok(()) => car_proto::MessagingTestSendResponse {
8708 ok: true,
8709 error: None,
8710 },
8711 Err(e) => car_proto::MessagingTestSendResponse {
8712 ok: false,
8713 error: Some(e),
8714 },
8715 };
8716 serde_json::to_value(resp).map_err(|e| e.to_string())
8717}
8718
8719fn proposal_from_params(req: &JsonRpcMessage) -> Result<car_ir::ActionProposal, String> {
8721 let p = req.params.get("proposal").ok_or("missing 'proposal'")?;
8722 serde_json::from_value(p.clone()).map_err(|e| format!("invalid proposal: {e}"))
8723}
8724
8725async fn handle_permission_classify(
8757 req: &JsonRpcMessage,
8758 session: &crate::session::ClientSession,
8759) -> Result<Value, String> {
8760 let proposal = proposal_from_params(req)?;
8761 let gate = session.permission_gate.read().await;
8762 let rows: Vec<Value> = proposal
8763 .actions
8764 .iter()
8765 .map(|a| car_ffi_common::permgate::classification_row(gate.classifier(), a))
8769 .collect();
8770 Ok(serde_json::json!({
8771 "classifications": rows,
8772 "declared_rollback_contract": proposal.rollback_contract().as_str(),
8773 }))
8774}
8775
8776async fn handle_permission_evaluate(
8786 req: &JsonRpcMessage,
8787 session: &crate::session::ClientSession,
8788 state: &ServerState,
8789) -> Result<Value, String> {
8790 let proposal = proposal_from_params(req)?;
8791 let ceiling = resolve_skill_ceiling(req, session).await;
8792 let agent_id = authenticated_bound_agent_id(session).await;
8793 let policy = crate::agent_permissions::load_policy();
8794 let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8795 &session.callback_tool_schema_digests,
8796 &session.runtime.registry,
8797 )
8798 .await;
8799 let gate = session.permission_gate.read().await;
8800 let ledger = state.approval_ledger.read().await;
8803 let rows = evaluate_actions(
8804 &gate,
8805 &policy,
8806 agent_id.as_deref(),
8807 &callback_tools,
8808 &proposal,
8809 ceiling,
8810 &ledger,
8811 );
8812 let mut resp = serde_json::json!({ "decisions": rows });
8813 if let Some(c) = ceiling {
8814 resp.as_object_mut()
8815 .unwrap()
8816 .insert("skill_ceiling".into(), serde_json::json!(c.as_str()));
8817 }
8818 Ok(resp)
8819}
8820
8821async fn handle_permission_pending(
8827 req: &JsonRpcMessage,
8828 session: &crate::session::ClientSession,
8829 state: &ServerState,
8830) -> Result<Value, String> {
8831 let proposal = proposal_from_params(req)?;
8832 let ceiling = resolve_skill_ceiling(req, session).await;
8833 let agent_id = authenticated_bound_agent_id(session).await;
8834 let policy = crate::agent_permissions::load_policy();
8835 let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8836 &session.callback_tool_schema_digests,
8837 &session.runtime.registry,
8838 )
8839 .await;
8840 let gate = session.permission_gate.read().await;
8841 let ledger = state.approval_ledger.read().await;
8842 let pending: Vec<Value> = evaluate_actions(
8843 &gate,
8844 &policy,
8845 agent_id.as_deref(),
8846 &callback_tools,
8847 &proposal,
8848 ceiling,
8849 &ledger,
8850 )
8851 .into_iter()
8852 .filter(|r| r.get("decision").and_then(|v| v.as_str()) == Some("needs_approval"))
8853 .collect();
8854 Ok(serde_json::json!({ "pending": pending }))
8855}
8856
8857async fn resolve_skill_ceiling(
8863 req: &JsonRpcMessage,
8864 session: &crate::session::ClientSession,
8865) -> Option<car_policy::PermissionTier> {
8866 let skill = req.params.get("skill").and_then(|v| v.as_str())?;
8867 let engine = session.memgine.lock().await;
8868 engine.skill_meta(skill).and_then(|m| m.deployment_tier)
8869}
8870
8871fn evaluate_actions(
8872 gate: &car_policy::PermissionGate,
8873 policy: &car_policy::AgentPermissionPolicy,
8874 agent_id: Option<&str>,
8875 callback_tools: &HashMap<String, String>,
8876 proposal: &car_ir::ActionProposal,
8877 ceiling: Option<car_policy::PermissionTier>,
8878 ledger: &car_policy::ApprovalLedger,
8879) -> Vec<Value> {
8880 proposal
8881 .actions
8882 .iter()
8883 .map(|a| {
8884 let evaluation = crate::permission_gate::evaluate_action(
8887 gate,
8888 policy,
8889 agent_id,
8890 a,
8891 a.tool
8892 .as_deref()
8893 .and_then(|tool| callback_tools.get(tool))
8894 .map(String::as_str),
8895 ceiling,
8896 ledger,
8897 );
8898 let mut obj = serde_json::to_value(&evaluation.axes.decision).unwrap_or(Value::Null);
8899 if let Some(map) = obj.as_object_mut() {
8900 map.insert("action_id".into(), serde_json::json!(a.id));
8901 map.insert(
8902 "fingerprint".into(),
8903 serde_json::json!(car_policy::action_fingerprint(a)),
8904 );
8905 if let Some(source) = evaluation.authorization_source {
8906 map.insert("authorization_source".into(), source.into());
8907 }
8908 if let Some(digest) = evaluation.schema_digest {
8909 map.insert("authorization_schema_digest".into(), digest.into());
8910 }
8911 }
8912 car_ffi_common::permgate::stamp_reversibility(&mut obj, evaluation.axes.reversibility);
8917 obj
8918 })
8919 .collect()
8920}
8921
8922fn stamped_requester(bound: Option<String>, claimed: Option<String>) -> Option<String> {
8932 bound.or(claimed)
8933}
8934
8935async fn authenticated_bound_agent_id(session: &crate::session::ClientSession) -> Option<String> {
8936 if !session
8937 .authenticated
8938 .load(std::sync::atomic::Ordering::Acquire)
8939 {
8940 return None;
8941 }
8942 session.agent_id.lock().await.clone()
8943}
8944
8945async fn handle_permission_decision(
8950 req: &JsonRpcMessage,
8951 session: &crate::session::ClientSession,
8952 state: &ServerState,
8953 approve: bool,
8954) -> Result<Value, String> {
8955 require_approval_authority(session, state)?;
8956 let reviewer = session_principal(session).await;
8961 let reason = req
8962 .params
8963 .get("reason")
8964 .and_then(|v| v.as_str())
8965 .unwrap_or("");
8966 let evidence = req
8967 .params
8968 .get("evidence")
8969 .and_then(|v| v.as_str())
8970 .map(str::to_string);
8971
8972 let decision = if approve {
8978 car_policy::ApprovalDecision::Approved
8979 } else {
8980 car_policy::ApprovalDecision::Rejected
8981 };
8982 let record = if let Some(action_val) = req.params.get("action") {
8983 let action: car_ir::Action = serde_json::from_value(action_val.clone())
8984 .map_err(|e| format!("invalid action: {e}"))?;
8985 let rec = {
8986 let gate = session.permission_gate.read().await;
8987 gate.decision_record(&action, decision, &reviewer, reason, evidence)
8988 };
8989 state
8990 .approval_ledger
8991 .write()
8992 .await
8993 .record(rec.clone())
8994 .map_err(|e| format!("failed to persist approval decision: {e}"))?;
8997 rec
8998 } else {
8999 let fingerprint = req
9000 .params
9001 .get("fingerprint")
9002 .and_then(|v| v.as_str())
9003 .ok_or("missing 'fingerprint' (or 'action')")?;
9004 let required_tier = req
9005 .params
9006 .get("required_tier")
9007 .and_then(|v| v.as_str())
9008 .and_then(car_policy::PermissionTier::from_str_opt)
9009 .unwrap_or(car_policy::PermissionTier::FullAccess);
9010 state
9011 .approval_ledger
9012 .write()
9013 .await
9014 .record_decision(
9015 fingerprint,
9016 required_tier,
9017 decision,
9018 &reviewer,
9019 reason,
9020 evidence,
9021 )
9022 .map_err(|e| format!("failed to persist approval decision: {e}"))?
9023 };
9024
9025 let mut data = std::collections::HashMap::new();
9029 data.insert(
9030 "fingerprint".to_string(),
9031 Value::from(record.fingerprint.clone()),
9032 );
9033 data.insert(
9034 "approval".to_string(),
9035 Value::from(match record.decision {
9036 car_policy::ApprovalDecision::Approved => "approved",
9037 car_policy::ApprovalDecision::Rejected => "rejected",
9038 }),
9039 );
9040 data.insert(
9041 "required_tier".to_string(),
9042 Value::from(record.required_tier.as_str()),
9043 );
9044 data.insert("reviewer".to_string(), Value::from(record.reviewer.clone()));
9045 data.insert("reason".to_string(), Value::from(record.reason.clone()));
9046 if let Some(ev) = &record.evidence {
9047 data.insert("evidence".to_string(), Value::from(ev.clone()));
9048 }
9049 session.runtime.log.lock().await.append(
9050 car_eventlog::EventKind::ApprovalRecorded,
9051 None,
9052 None,
9053 data,
9054 );
9055
9056 serde_json::to_value(&record).map_err(|e| e.to_string())
9057}
9058
9059fn validate_tenant_id(id: &str) -> Result<(), String> {
9065 if id.contains(':') {
9066 return Err(format!(
9067 "invalid tenant_id '{id}': ':' is reserved as the namespace separator"
9068 ));
9069 }
9070 Ok(())
9071}
9072
9073async fn effective_tenant(
9082 req: &JsonRpcMessage,
9083 session: &crate::session::ClientSession,
9084) -> Result<Option<String>, String> {
9085 let param = req
9086 .params
9087 .get("tenant_id")
9088 .and_then(|v| v.as_str())
9089 .filter(|s| !s.is_empty())
9090 .map(str::to_string);
9091 if let Some(p) = ¶m {
9092 validate_tenant_id(p)?;
9093 }
9094 let bound = session.tenant.lock().await.clone();
9095 match (bound, param) {
9096 (Some(b), Some(p)) if b != p => Err(format!(
9097 "tenant_id '{p}' conflicts with this session's bound tenant '{b}'"
9098 )),
9099 (Some(b), _) => Ok(Some(b)),
9100 (None, p) => Ok(p),
9101 }
9102}
9103
9104fn reject_unscoped_tenant_key(tenant: &Option<String>, key: &str) -> Result<(), String> {
9109 if tenant.is_none() && key.starts_with("tenant:") {
9110 return Err(format!(
9111 "unscoped access to tenant-namespaced key '{key}' is not permitted; \
9112 authenticate with the owning tenant_id"
9113 ));
9114 }
9115 Ok(())
9116}
9117
9118fn handle_capabilities_list(bound_agent: Option<&str>, is_host: bool) -> Value {
9119 let caller_role = if is_host {
9120 crate::wire_schema::CapabilityRole::Host
9121 } else if bound_agent.is_some() {
9122 crate::wire_schema::CapabilityRole::Agent
9123 } else {
9124 crate::wire_schema::CapabilityRole::Operator
9125 };
9126 let methods: Vec<crate::wire_schema::CapabilityMethodRow> =
9127 crate::generated_rpc_capabilities::RPC_CAPABILITIES
9128 .iter()
9129 .filter(|(_, role)| match *role {
9130 "operator" => true,
9132 "owner" => is_host || bound_agent.is_some(),
9134 "agent" => bound_agent.is_some(),
9135 "host" => is_host,
9136 _ => false,
9137 })
9138 .map(|(method, role)| crate::wire_schema::CapabilityMethodRow {
9139 method: (*method).to_string(),
9140 role: crate::wire_schema::CapabilityRole::from_manifest(role),
9141 })
9142 .collect();
9143 serde_json::to_value(crate::wire_schema::CapabilitiesListResult {
9144 caller_role,
9145 count: methods.len(),
9146 methods,
9147 })
9148 .expect("capabilities.list result serializes")
9149}
9150
9151#[cfg(test)]
9152mod capability_discovery_tests {
9153 use super::handle_capabilities_list;
9154
9155 #[test]
9156 fn agent_discovery_excludes_host_methods_and_preserves_roles() {
9157 let result = handle_capabilities_list(Some("agent-a"), false);
9158 let methods = result["methods"].as_array().expect("method rows");
9159 assert_eq!(result["caller_role"], "agent");
9160 assert!(methods
9161 .iter()
9162 .any(|row| row["method"] == "capabilities.list"));
9163 assert!(methods.iter().any(|row| row["role"] == "agent"));
9164 assert!(methods.iter().any(|row| row["role"] == "owner"));
9165 assert!(methods.iter().any(|row| row["role"] == "operator"));
9166 assert!(!methods.iter().any(|row| row["role"] == "host"));
9167 }
9168
9169 #[test]
9170 fn unbound_discovery_contains_only_ungated_methods() {
9171 let result = handle_capabilities_list(None, false);
9172 let methods = result["methods"].as_array().expect("method rows");
9173 assert_eq!(result["caller_role"], "operator");
9174 assert!(methods.iter().all(|row| row["role"] == "operator"));
9175 }
9176}
9177
9178async fn handle_state_get(
9179 req: &JsonRpcMessage,
9180 session: &crate::session::ClientSession,
9181) -> Result<Value, String> {
9182 let key = require_str(&req.params, "key")?;
9183 let tenant = effective_tenant(req, session).await?;
9184 reject_unscoped_tenant_key(&tenant, key)?;
9185 Ok(session
9186 .runtime
9187 .state
9188 .scoped(tenant.as_deref())
9189 .get(key)
9190 .unwrap_or(Value::Null))
9191}
9192
9193async fn handle_state_set(
9194 req: &JsonRpcMessage,
9195 session: &crate::session::ClientSession,
9196) -> Result<Value, String> {
9197 let key = require_str(&req.params, "key")?;
9198 let value = req.params.get("value").cloned().unwrap_or(Value::Null);
9199 let tenant = effective_tenant(req, session).await?;
9200 reject_unscoped_tenant_key(&tenant, key)?;
9201 session
9202 .runtime
9203 .state
9204 .scoped(tenant.as_deref())
9205 .set(key, value, "client");
9206 serde_json::to_value(crate::wire_schema::StateSetResult::Ok)
9207 .map_err(|e| format!("serialize state.set result: {e}"))
9208}
9209
9210async fn handle_state_exists(
9214 req: &JsonRpcMessage,
9215 session: &crate::session::ClientSession,
9216) -> Result<Value, String> {
9217 let key = require_str(&req.params, "key")?;
9218 let tenant = effective_tenant(req, session).await?;
9219 reject_unscoped_tenant_key(&tenant, key)?;
9220 serde_json::to_value(crate::wire_schema::StateExistsResult(
9221 session.runtime.state.scoped(tenant.as_deref()).exists(key),
9222 ))
9223 .map_err(|e| format!("serialize state.exists result: {e}"))
9224}
9225
9226async fn handle_state_keys(
9229 req: &JsonRpcMessage,
9230 session: &crate::session::ClientSession,
9231) -> Result<Value, String> {
9232 let tenant = effective_tenant(req, session).await?;
9233 serde_json::to_value(crate::wire_schema::StateKeysResult(
9234 session.runtime.state.scoped(tenant.as_deref()).keys(),
9235 ))
9236 .map_err(|e| format!("serialize state.keys result: {e}"))
9237}
9238
9239async fn handle_state_snapshot(
9250 req: &JsonRpcMessage,
9251 session: &crate::session::ClientSession,
9252) -> Result<Value, String> {
9253 let tenant = effective_tenant(req, session).await?;
9254 let map: serde_json::Map<String, Value> = session
9259 .runtime
9260 .state
9261 .scoped(tenant.as_deref())
9262 .snapshot_stripped()
9263 .into_iter()
9264 .collect();
9265 serde_json::to_value(crate::wire_schema::StateSnapshotResult(map))
9266 .map_err(|e| format!("serialize state.snapshot result: {e}"))
9267}
9268
9269fn agent_memgine_snapshot_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
9275 let base = car_ffi_common::memory_path::ensure_base()
9276 .map_err(|e| format!("memory base unavailable: {e}"))?;
9277 let dir = base.join("agents");
9278 std::fs::create_dir_all(&dir).map_err(|e| format!("create agents dir: {e}"))?;
9279 Ok(dir.join(format!("{agent_id}.json")))
9280}
9281
9282pub fn seed_memgine_config() -> car_memgine::MemgineConfig {
9300 let anchor = std::env::var_os("CAR_PROJECT_DIR")
9301 .map(std::path::PathBuf::from)
9302 .or_else(|| std::env::current_dir().ok());
9303 match anchor {
9304 Some(dir) => {
9305 car_memgine::project::resolve_config(&dir, car_memgine::MemgineConfig::default())
9306 }
9307 None => car_memgine::MemgineConfig::default(),
9308 }
9309}
9310
9311pub fn seed_trusted_skill_signers() -> Vec<String> {
9338 let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
9339 .map(std::path::PathBuf::from)
9340 .or_else(|| std::env::current_dir().ok())
9341 else {
9342 return Vec::new();
9343 };
9344 let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
9345 return Vec::new();
9346 };
9347 car_memgine::project::load_config_overrides(&car_dir)
9348 .and_then(|o| o.trusted_skill_signers)
9349 .unwrap_or_default()
9350}
9351
9352fn namespace_memgine_snapshot_path(namespace: &str) -> Result<std::path::PathBuf, String> {
9389 if namespace.is_empty() {
9390 return Err("memory_namespace must contain at least one usable character".into());
9391 }
9392 const HEX: &[u8; 16] = b"0123456789abcdef";
9393 let mut encoded = String::with_capacity(namespace.len());
9394 for &b in namespace.as_bytes() {
9395 match b {
9396 b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => encoded.push(b as char),
9397 _ => {
9398 encoded.push('%');
9399 encoded.push(HEX[(b >> 4) as usize] as char);
9400 encoded.push(HEX[(b & 0x0f) as usize] as char);
9401 }
9402 }
9403 }
9404 let file_name = format!("{encoded}.json");
9405 if file_name.len() > 255 {
9409 return Err(format!(
9410 "memory_namespace is too long: {:?} encodes to a {}-byte filename \
9411 (limit 255)",
9412 namespace,
9413 file_name.len()
9414 ));
9415 }
9416 let base = car_ffi_common::memory_path::ensure_base()
9417 .map_err(|e| format!("memory base unavailable: {e}"))?;
9418 let dir = base.join("memory-namespaces");
9419 std::fs::create_dir_all(&dir).map_err(|e| format!("create namespaces dir: {e}"))?;
9420 Ok(dir.join(file_name))
9421}
9422
9423async fn mirror_stored_identity(
9424 state: &ServerState,
9425 engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9426) {
9427 match state.identity_store.load() {
9428 Ok(identity) => {
9429 let mut engine = engine.lock().await;
9430 crate::session::mirror_identity_into_memgine(&mut engine, &identity);
9431 }
9432 Err(error) => tracing::warn!(
9433 error = %error,
9434 "identity profile could not be mirrored into a loaded memory engine"
9435 ),
9436 }
9437}
9438
9439async fn get_or_load_namespace_memgine(
9444 state: &Arc<ServerState>,
9445 namespace: &str,
9446) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9447 {
9448 let map = state.namespace_memgines.lock().await;
9449 if let Some(eng) = map.get(namespace) {
9450 let eng = eng.clone();
9451 drop(map);
9452 mirror_stored_identity(state, &eng).await;
9453 return Ok(eng);
9454 }
9455 }
9456 let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9457 Some(seed_memgine_config()),
9458 )));
9459 let path = namespace_memgine_snapshot_path(namespace)?;
9460 if path.exists() {
9461 let content = std::fs::read_to_string(&path)
9463 .map_err(|e| format!("read {}: {}", path.display(), e))?;
9464 let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9465 let mut g = engine.lock().await;
9466 for (loaded, fact) in facts.iter().enumerate() {
9467 ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9468 }
9469 }
9470 mirror_stored_identity(state, &engine).await;
9471 let mut map = state.namespace_memgines.lock().await;
9472 Ok(map.entry(namespace.to_string()).or_insert(engine).clone())
9473}
9474
9475async fn bind_memory_namespace(
9484 state: &Arc<ServerState>,
9485 session: &crate::session::ClientSession,
9486 namespace: &str,
9487) -> Result<(), String> {
9488 let eng = get_or_load_namespace_memgine(state, namespace).await?;
9489 *session.bound_memgine.lock().await = Some(eng);
9490 *session.memory_namespace.lock().await = Some(namespace.to_string());
9491 Ok(())
9492}
9493
9494async fn persist_namespace_memgine(
9508 namespace: &str,
9509 engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9510) -> Result<(), String> {
9511 let path = namespace_memgine_snapshot_path(namespace)?;
9512 let g = engine.lock().await;
9513 let facts = memory_snapshot_facts(&g);
9514 drop(g);
9515 let path_owned = path.clone();
9516 tokio::task::spawn_blocking(move || -> Result<(), String> {
9517 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9518 atomic_write_sync(&path_owned, json.as_bytes())
9519 .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9520 })
9521 .await
9522 .map_err(|e| format!("persist_namespace_memgine join: {e}"))?
9523}
9524
9525fn memory_snapshot_facts(engine: &car_memgine::MemgineEngine) -> Vec<Value> {
9540 engine
9541 .graph
9542 .inner
9543 .node_indices()
9544 .filter_map(|nix| {
9545 let node = engine.graph.inner.node_weight(nix)?;
9546 if !node.is_valid() {
9547 return None;
9548 }
9549 if node.kind == car_memgine::MemKind::Identity
9550 || node.kind == car_memgine::MemKind::Environment
9551 {
9552 return None;
9553 }
9554 Some(serde_json::json!({
9555 "fact_id": node.fact_id,
9556 "subject": node.key,
9557 "body": node.value,
9558 "kind": match node.kind {
9559 car_memgine::MemKind::Fact if node.is_constraint => "constraint",
9560 car_memgine::MemKind::Fact => "pattern",
9561 car_memgine::MemKind::Conversation => "outcome",
9562 _ => "pattern",
9563 },
9564 "confidence": 0.5,
9565 "content_type": node.content_type.as_label(),
9566 "tags": node.metadata.tags,
9567 "source": node
9568 .metadata
9569 .provenance
9570 .first()
9571 .map(|entry| entry.source.as_str())
9572 .unwrap_or(""),
9573 }))
9574 })
9575 .collect()
9576}
9577
9578fn ingest_snapshot_fact(
9579 engine: &mut car_memgine::MemgineEngine,
9580 fact: &Value,
9581 fallback_id: String,
9582) {
9583 let subject = fact.get("subject").and_then(Value::as_str).unwrap_or("");
9584 let body = fact.get("body").and_then(Value::as_str).unwrap_or("");
9585 let kind = fact
9586 .get("kind")
9587 .and_then(Value::as_str)
9588 .unwrap_or("pattern");
9589 let fact_id = fact
9590 .get("fact_id")
9591 .and_then(Value::as_str)
9592 .unwrap_or(&fallback_id);
9593 let tags = fact
9594 .get("tags")
9595 .cloned()
9596 .and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
9597 .unwrap_or_default();
9598 let source = fact
9599 .get("source")
9600 .and_then(Value::as_str)
9601 .unwrap_or_default();
9602 let nix = engine.ingest_fact(
9603 fact_id,
9604 subject,
9605 body,
9606 "user",
9607 "peer",
9608 chrono::Utc::now(),
9609 "global",
9610 None,
9611 vec![],
9612 kind == "constraint",
9613 );
9614 if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9615 node.metadata.tags = tags;
9616 if !source.is_empty() {
9617 node.metadata.provenance = vec![car_memgine::Provenance {
9618 source: source.to_string(),
9619 ..Default::default()
9620 }];
9621 }
9622 }
9623}
9624
9625async fn get_or_load_agent_memgine(
9626 state: &Arc<ServerState>,
9627 agent_id: &str,
9628) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9629 {
9630 let map = state.agent_memgines.lock().await;
9631 if let Some(eng) = map.get(agent_id) {
9632 let eng = eng.clone();
9633 drop(map);
9634 mirror_stored_identity(state, &eng).await;
9635 return Ok(eng);
9636 }
9637 }
9638 let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9644 Some(seed_memgine_config()),
9645 )));
9646 let path = agent_memgine_snapshot_path(agent_id)?;
9647 if path.exists() {
9648 let content = std::fs::read_to_string(&path)
9649 .map_err(|e| format!("read {}: {}", path.display(), e))?;
9650 let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9651 let mut g = engine.lock().await;
9652 for (loaded, fact) in facts.iter().enumerate() {
9653 ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9654 }
9655 }
9656 mirror_stored_identity(state, &engine).await;
9657 let mut map = state.agent_memgines.lock().await;
9658 let stored = map.entry(agent_id.to_string()).or_insert(engine).clone();
9659 Ok(stored)
9660}
9661
9662fn atomic_write_sync(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
9669 use std::sync::atomic::{AtomicU64, Ordering};
9670 static SEQ: AtomicU64 = AtomicU64::new(0);
9671 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
9672 let mut tmp_os = path.as_os_str().to_owned();
9673 tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
9674 let tmp = std::path::PathBuf::from(tmp_os);
9675 std::fs::write(&tmp, bytes)?;
9676 std::fs::rename(&tmp, path)
9677}
9678
9679async fn persist_agent_memgine(
9683 agent_id: &str,
9684 engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9685) -> Result<(), String> {
9686 let path = agent_memgine_snapshot_path(agent_id)?;
9687 let g = engine.lock().await;
9688 let facts = memory_snapshot_facts(&g);
9689 drop(g);
9695 let path_owned = path.clone();
9696 tokio::task::spawn_blocking(move || -> Result<(), String> {
9697 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9698 atomic_write_sync(&path_owned, json.as_bytes())
9699 .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9700 })
9701 .await
9702 .map_err(|e| format!("persist_agent_memgine join: {e}"))?
9703}
9704
9705async fn handle_memory_fact_count(
9712 session: &crate::session::ClientSession,
9713) -> Result<Value, String> {
9714 let engine_arc = session.effective_memgine().await;
9715 let engine = engine_arc.lock().await;
9716 Ok(Value::from(engine.valid_fact_count()))
9717}
9718
9719fn parse_commit_authority(s: &str) -> Result<car_verify::CommitAuthority, String> {
9725 use car_verify::CommitAuthority as A;
9726 for candidate in [
9727 A::Execution,
9728 A::Review,
9729 A::Coordination,
9730 A::Scheduling,
9731 A::SystemConfig,
9732 ] {
9733 if candidate.as_str() == s {
9734 return Ok(candidate);
9735 }
9736 }
9737 Err(format!(
9738 "unknown commit authority `{s}` (expected one of: execution, review, coordination, scheduling, system_config)"
9739 ))
9740}
9741
9742async fn handle_memory_set_admission_table(
9751 req: &JsonRpcMessage,
9752 session: &crate::session::ClientSession,
9753) -> Result<Value, String> {
9754 let table: Option<car_verify::OwnershipTable> = match req.params.get("table") {
9755 None | Some(Value::Null) => None,
9756 Some(v) => {
9757 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid table: {e}"))?)
9758 }
9759 };
9760
9761 let ungated: Vec<&'static str> = table
9765 .as_ref()
9766 .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect())
9767 .unwrap_or_default();
9768 let enabled = table.is_some();
9769
9770 let engine_arc = session.effective_memgine().await;
9771 engine_arc.lock().await.set_admission_table(table);
9772
9773 Ok(serde_json::json!({
9774 "enabled": enabled,
9775 "ungated_surfaces": ungated,
9776 }))
9777}
9778
9779async fn handle_memory_admission_table(
9784 _req: &JsonRpcMessage,
9785 session: &crate::session::ClientSession,
9786) -> Result<Value, String> {
9787 let engine_arc = session.effective_memgine().await;
9788 let engine = engine_arc.lock().await;
9789 let table = engine.admission_table();
9790 Ok(serde_json::json!({
9791 "enabled": table.is_some(),
9792 "table": table,
9793 "ungated_surfaces": table
9794 .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect::<Vec<_>>())
9795 .unwrap_or_default(),
9796 }))
9797}
9798
9799async fn handle_memory_add_fact(
9800 req: &JsonRpcMessage,
9801 session: &crate::session::ClientSession,
9802) -> Result<Value, String> {
9803 let subject = req
9804 .params
9805 .get("subject")
9806 .and_then(|v| v.as_str())
9807 .ok_or("missing subject")?;
9808 let body = req
9809 .params
9810 .get("body")
9811 .and_then(|v| v.as_str())
9812 .ok_or("missing body")?;
9813 let kind = req
9814 .params
9815 .get("kind")
9816 .and_then(|v| v.as_str())
9817 .unwrap_or("pattern");
9818 let tags = match req.params.get("tags") {
9819 Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
9820 .map_err(|_| "tags must be an array of strings".to_string())?,
9821 None => vec![],
9822 };
9823 let source = match req.params.get("source") {
9824 Some(Value::String(value)) => value.clone(),
9825 Some(_) => return Err("source must be a string".to_string()),
9826 None => "user".to_string(),
9827 };
9828 let committed_by = req
9849 .params
9850 .get("committed_by")
9851 .and_then(|v| v.as_str())
9852 .map(parse_commit_authority)
9853 .transpose()?;
9854 let verdicts: Vec<car_verify::VerifierVerdict> = match req.params.get("verdicts") {
9855 Some(v) => {
9856 serde_json::from_value(v.clone()).map_err(|e| format!("invalid verdicts: {e}"))?
9857 }
9858 None => Vec::new(),
9859 };
9860
9861 let engine_arc = session.effective_memgine().await;
9862 let count = {
9863 let mut engine = engine_arc.lock().await;
9864 let fid = match req.params.get("fact_id") {
9871 Some(Value::String(value)) if !value.is_empty() => {
9872 let fid = value.clone();
9873 if let Some((_, existing)) = engine.graph.get_by_fact_id(&fid) {
9874 if existing.key == subject && existing.value == body {
9875 return Ok(Value::from(engine.valid_fact_count()));
9876 }
9877 return Err(format!("fact_id `{fid}` already exists"));
9878 }
9879 fid
9880 }
9881 Some(Value::String(_)) => return Err("fact_id must not be empty".to_string()),
9882 Some(_) => return Err("fact_id must be a string".to_string()),
9883 None => {
9888 let mut n = engine.valid_fact_count();
9889 loop {
9890 let candidate = format!("ws-{n}");
9891 if engine.graph.get_by_fact_id(&candidate).is_none() {
9892 break candidate;
9893 }
9894 n += 1;
9895 }
9896 }
9897 };
9898 let nix = engine
9899 .try_ingest_fact(
9900 &fid,
9901 subject,
9902 body,
9903 "user",
9904 "peer",
9905 chrono::Utc::now(),
9906 "global",
9907 None,
9908 vec![],
9909 kind == "constraint",
9910 car_verify::CommitAuthority::Execution,
9911 committed_by,
9912 verdicts,
9913 )
9914 .map_err(|decision| {
9915 format!(
9918 "memory admission refused for `{}`: {}",
9919 decision.id,
9920 serde_json::to_string(&decision.refusals)
9921 .unwrap_or_else(|_| "<unserialisable>".into())
9922 )
9923 })?;
9924 if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9925 node.metadata.tags = tags;
9926 node.metadata.provenance = vec![car_memgine::Provenance {
9927 source,
9928 ..Default::default()
9929 }];
9930 }
9931 engine.valid_fact_count()
9932 };
9933 persist_bound_agent_memory(session, &engine_arc, "memory.add_fact").await;
9937 Ok(Value::from(count))
9938}
9939
9940async fn persist_bound_agent_memory(
9952 session: &crate::session::ClientSession,
9953 engine_arc: &Arc<Mutex<car_memgine::MemgineEngine>>,
9954 op: &str,
9955) {
9956 if let Some(ns) = session.memory_namespace.lock().await.clone() {
9957 if let Err(e) = persist_namespace_memgine(&ns, engine_arc).await {
9958 tracing::warn!(memory_namespace = %ns, error = %e, op = %op,
9959 "namespace memgine persist failed; in-memory state is canonical");
9960 }
9961 return;
9962 }
9963 if let Some(id) = session.agent_id.lock().await.clone() {
9964 if let Err(e) = persist_agent_memgine(&id, engine_arc).await {
9965 tracing::warn!(agent_id = %id, error = %e, op = %op,
9966 "agent memgine persist failed; in-memory state is canonical");
9967 }
9968 }
9969}
9970
9971#[derive(Debug, Deserialize)]
9972struct MemoryUpdateStatusParams {
9973 body: String,
9974 #[serde(default)]
9975 tenant_id: Option<String>,
9976}
9977
9978async fn handle_memory_update_status(
9979 req: &JsonRpcMessage,
9980 session: &crate::session::ClientSession,
9981) -> Result<Value, String> {
9982 let params: MemoryUpdateStatusParams = typed_params(&req.params)?;
9983 let engine_arc = session.effective_memgine().await;
9984 let status = {
9985 let mut engine = engine_arc.lock().await;
9986 engine.update_proactive_status(params.body, params.tenant_id)
9987 };
9988 serde_json::to_value(status).map_err(|e| e.to_string())
9989}
9990
9991async fn handle_memory_maintain(
9992 req: &JsonRpcMessage,
9993 session: &crate::session::ClientSession,
9994) -> Result<Value, String> {
9995 let params: car_memgine::ProactiveMaintenanceRequest = typed_params(&req.params)?;
9996 let events = {
9997 let log = session.runtime.log.lock().await;
9998 log.events().to_vec()
9999 };
10000 let engine_arc = session.effective_memgine().await;
10001 let report = {
10002 let mut engine = engine_arc.lock().await;
10003 engine.maintain_proactive_memory_from_events(&events, ¶ms)
10004 };
10005 if !report.saved.is_empty() {
10006 persist_bound_agent_memory(session, &engine_arc, "memory.maintain").await;
10007 }
10008 {
10009 let mut log = session.runtime.log.lock().await;
10010 log.append(
10011 car_eventlog::EventKind::ProactiveMemoryMaintained,
10012 None,
10013 None,
10014 proactive_maintenance_event_data(&report),
10015 );
10016 }
10017 serde_json::to_value(report).map_err(|e| e.to_string())
10018}
10019
10020async fn handle_memory_save_knowledge(
10021 req: &JsonRpcMessage,
10022 session: &crate::session::ClientSession,
10023) -> Result<Value, String> {
10024 let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
10025 let engine_arc = session.effective_memgine().await;
10026 let saved = {
10027 let mut engine = engine_arc.lock().await;
10028 engine.save_proactive_knowledge(save)
10029 };
10030 persist_bound_agent_memory(session, &engine_arc, "memory.save_knowledge").await;
10031 serde_json::to_value(saved).map_err(|e| e.to_string())
10032}
10033
10034async fn handle_memory_save_procedural(
10035 req: &JsonRpcMessage,
10036 session: &crate::session::ClientSession,
10037) -> Result<Value, String> {
10038 let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
10039 let engine_arc = session.effective_memgine().await;
10040 let saved = {
10041 let mut engine = engine_arc.lock().await;
10042 engine.save_proactive_procedural(save)
10043 };
10044 persist_bound_agent_memory(session, &engine_arc, "memory.save_procedural").await;
10045 serde_json::to_value(saved).map_err(|e| e.to_string())
10046}
10047
10048#[derive(Debug, Deserialize)]
10049struct MemoryDeleteParams {
10050 id: String,
10051}
10052
10053async fn handle_memory_delete(
10054 req: &JsonRpcMessage,
10055 session: &crate::session::ClientSession,
10056) -> Result<Value, String> {
10057 let params: MemoryDeleteParams = typed_params(&req.params)?;
10058 let engine_arc = session.effective_memgine().await;
10059 let deleted = {
10060 let mut engine = engine_arc.lock().await;
10061 engine.delete_proactive_memory(¶ms.id)
10062 };
10063 if deleted.deleted
10064 && !matches!(
10065 deleted.kind,
10066 Some(car_memgine::ProactiveMemoryEntryKind::Status)
10067 )
10068 {
10069 persist_bound_agent_memory(session, &engine_arc, "memory.delete").await;
10070 }
10071 serde_json::to_value(deleted).map_err(|e| e.to_string())
10072}
10073
10074async fn handle_memory_query(
10075 req: &JsonRpcMessage,
10076 session: &crate::session::ClientSession,
10077) -> Result<Value, String> {
10078 let query = req
10079 .params
10080 .get("query")
10081 .and_then(|v| v.as_str())
10082 .ok_or("missing query")?;
10083 let k = req.params.get("k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
10084 let engine_arc = session.effective_memgine().await;
10085 let engine = engine_arc.lock().await;
10086 let seeds = engine.graph.find_seeds(query, 5);
10087 let hits = if !seeds.is_empty() {
10091 engine.graph.retrieve_ppr(&seeds, None, 0.5, k)
10092 } else {
10093 vec![]
10094 };
10095 let results: Vec<Value> = hits
10096 .iter()
10097 .filter_map(|hit| {
10098 let node = engine.graph.inner.node_weight(hit.node_ix)?;
10099 Some(serde_json::json!({
10100 "fact_id": node.fact_id,
10101 "subject": node.key,
10102 "body": node.value,
10103 "kind": format!("{:?}", node.kind).to_lowercase(),
10104 "confidence": hit.activation,
10105 "tags": node.metadata.tags,
10106 "source": node
10107 .metadata
10108 .provenance
10109 .first()
10110 .map(|entry| entry.source.as_str())
10111 .unwrap_or(""),
10112 }))
10113 })
10114 .collect();
10115 serde_json::to_value(results).map_err(|e| e.to_string())
10116}
10117
10118async fn handle_memory_intervene(
10122 req: &JsonRpcMessage,
10123 session: &crate::session::ClientSession,
10124) -> Result<Value, String> {
10125 let request: car_memgine::ProactiveMemoryRequest = typed_params(&req.params)?;
10126 let engine_arc = session.effective_memgine().await;
10127 let decision = {
10128 let mut engine = engine_arc.lock().await;
10129 engine.proactive_intervention(&request)
10130 };
10131 {
10132 let mut log = session.runtime.log.lock().await;
10133 log.append(
10134 car_eventlog::EventKind::ProactiveMemoryIntervention,
10135 None,
10136 None,
10137 proactive_intervention_event_data(&decision),
10138 );
10139 }
10140 serde_json::to_value(decision).map_err(|e| e.to_string())
10141}
10142
10143async fn handle_memory_evaluate(
10148 req: &JsonRpcMessage,
10149 session: &crate::session::ClientSession,
10150) -> Result<Value, String> {
10151 let request: car_memgine::ProactiveEvaluationRequest = typed_params(&req.params)?;
10152 let engine_arc = session.effective_memgine().await;
10153 let engine = engine_arc.lock().await;
10154 let report = engine.evaluate_proactive_memory(&request);
10155 serde_json::to_value(report).map_err(|e| e.to_string())
10156}
10157
10158async fn maybe_apply_proactive_memory(
10159 msg: &JsonRpcMessage,
10160 session: &crate::session::ClientSession,
10161 req: &mut car_inference::GenerateRequest,
10162) -> Result<(), String> {
10163 let tier = session.permission_gate.read().await.granted_tier();
10164 let mut request = match proactive_memory_activation(&msg.params, req, tier)? {
10165 ProactiveMemoryActivation::Disabled => return Ok(()),
10166 ProactiveMemoryActivation::Default => car_memgine::ProactiveMemoryRequest::default(),
10167 ProactiveMemoryActivation::Request(request) => request,
10168 };
10169
10170 if request.query.trim().is_empty() {
10171 request.query = msg
10172 .params
10173 .get("context_query")
10174 .and_then(|v| v.as_str())
10175 .filter(|s| !s.trim().is_empty())
10176 .unwrap_or(&req.prompt)
10177 .to_string();
10178 }
10179 if request.recent.is_empty() && !req.prompt.trim().is_empty() {
10180 request.recent.push(req.prompt.clone());
10181 }
10182 let events = {
10183 let log = session.runtime.log.lock().await;
10184 log.events().to_vec()
10185 };
10186 let engine_arc = session.effective_memgine().await;
10187 let maintenance = {
10188 let mut engine = engine_arc.lock().await;
10189 engine.maintain_proactive_memory_from_events(
10190 &events,
10191 &car_memgine::ProactiveMaintenanceRequest {
10192 max_recent: 32,
10193 tenant_id: request.tenant_id.clone(),
10194 },
10195 )
10196 };
10197 if !maintenance.saved.is_empty() {
10198 persist_bound_agent_memory(session, &engine_arc, "memory_intervention.maintain").await;
10199 }
10200 {
10201 let mut log = session.runtime.log.lock().await;
10202 log.append(
10203 car_eventlog::EventKind::ProactiveMemoryMaintained,
10204 None,
10205 None,
10206 proactive_maintenance_event_data(&maintenance),
10207 );
10208 }
10209 let mut derived_trigger = maintenance.trigger;
10210 if tier == car_policy::PermissionTier::FullAccess
10211 || req.intent.as_ref().is_some_and(|intent| intent.high_stakes)
10212 {
10213 derived_trigger.high_risk_action = true;
10214 }
10215 request.trigger.merge(derived_trigger);
10216
10217 let decision = {
10218 let mut engine = engine_arc.lock().await;
10219 engine.proactive_intervention(&request)
10220 };
10221 {
10222 let mut log = session.runtime.log.lock().await;
10223 log.append(
10224 car_eventlog::EventKind::ProactiveMemoryIntervention,
10225 None,
10226 None,
10227 proactive_intervention_event_data(&decision),
10228 );
10229 }
10230 if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
10231 append_context_block(req, "Proactive Memory", &reminder);
10232 }
10233 Ok(())
10234}
10235
10236enum ProactiveMemoryActivation {
10237 Disabled,
10238 Default,
10239 Request(car_memgine::ProactiveMemoryRequest),
10240}
10241
10242fn proactive_memory_activation(
10243 params: &Value,
10244 req: &car_inference::GenerateRequest,
10245 tier: car_policy::PermissionTier,
10246) -> Result<ProactiveMemoryActivation, String> {
10247 match params.get("memory_intervention") {
10248 Some(raw) if raw.as_bool() == Some(false) || raw.is_null() => {
10249 Ok(ProactiveMemoryActivation::Disabled)
10250 }
10251 Some(raw) if raw.as_bool() == Some(true) => Ok(ProactiveMemoryActivation::Default),
10252 Some(raw) => serde_json::from_value(raw.clone())
10253 .map(ProactiveMemoryActivation::Request)
10254 .map_err(|e| format!("invalid memory_intervention: {e}")),
10255 None if should_auto_apply_proactive_memory(req, tier) => {
10256 Ok(ProactiveMemoryActivation::Default)
10257 }
10258 None => Ok(ProactiveMemoryActivation::Disabled),
10259 }
10260}
10261
10262fn should_auto_apply_proactive_memory(
10263 req: &car_inference::GenerateRequest,
10264 tier: car_policy::PermissionTier,
10265) -> bool {
10266 if tier == car_policy::PermissionTier::FullAccess {
10267 return true;
10268 }
10269 if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
10270 return true;
10271 }
10272 let Some(intent) = req.intent.as_ref() else {
10273 return false;
10274 };
10275 if intent.high_stakes {
10276 return true;
10277 }
10278 matches!(
10279 intent.task,
10280 Some(car_inference::TaskHint::Code | car_inference::TaskHint::Reasoning)
10281 )
10282}
10283
10284fn proactive_maintenance_event_data(
10285 report: &car_memgine::ProactiveMaintenanceReport,
10286) -> HashMap<String, Value> {
10287 let mut data = proactive_trigger_event_data(&report.trigger);
10288 data.insert(
10289 "saved_count".to_string(),
10290 Value::from(report.saved.len() as u64),
10291 );
10292 data.insert(
10293 "skipped_existing".to_string(),
10294 Value::from(report.skipped_existing as u64),
10295 );
10296 data.insert(
10297 "status_updated".to_string(),
10298 Value::from(report.status.is_some()),
10299 );
10300 data
10301}
10302
10303fn proactive_intervention_event_data(
10304 decision: &car_memgine::ProactiveMemoryDecision,
10305) -> HashMap<String, Value> {
10306 let mut data = HashMap::new();
10307 match decision {
10308 car_memgine::ProactiveMemoryDecision::Inject {
10309 selected,
10310 candidates,
10311 bank,
10312 ..
10313 } => {
10314 data.insert("decision".to_string(), Value::from("inject"));
10315 data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
10316 data.insert(
10317 "selected_kind".to_string(),
10318 Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
10319 );
10320 data.insert(
10321 "candidate_count".to_string(),
10322 Value::from(candidates.len() as u64),
10323 );
10324 data.insert(
10325 "bank_knowledge".to_string(),
10326 Value::from(bank.knowledge as u64),
10327 );
10328 data.insert(
10329 "bank_procedural".to_string(),
10330 Value::from(bank.procedural as u64),
10331 );
10332 data.insert(
10333 "bank_open_subgoals".to_string(),
10334 Value::from(bank.open_subgoals as u64),
10335 );
10336 }
10337 car_memgine::ProactiveMemoryDecision::Silent {
10338 reason,
10339 candidates,
10340 bank,
10341 } => {
10342 data.insert("decision".to_string(), Value::from("silent"));
10343 data.insert("reason".to_string(), Value::from(reason.clone()));
10344 data.insert(
10345 "candidate_count".to_string(),
10346 Value::from(candidates.len() as u64),
10347 );
10348 data.insert(
10349 "bank_knowledge".to_string(),
10350 Value::from(bank.knowledge as u64),
10351 );
10352 data.insert(
10353 "bank_procedural".to_string(),
10354 Value::from(bank.procedural as u64),
10355 );
10356 data.insert(
10357 "bank_open_subgoals".to_string(),
10358 Value::from(bank.open_subgoals as u64),
10359 );
10360 }
10361 }
10362 data
10363}
10364
10365fn proactive_trigger_event_data(
10366 trigger: &car_memgine::ProactiveMemoryTrigger,
10367) -> HashMap<String, Value> {
10368 HashMap::from([
10369 (
10370 "repeated_failures".to_string(),
10371 Value::from(trigger.repeated_failures as u64),
10372 ),
10373 ("tool_error".to_string(), Value::from(trigger.tool_error)),
10374 (
10375 "explicit_uncertainty".to_string(),
10376 Value::from(trigger.explicit_uncertainty),
10377 ),
10378 (
10379 "high_risk_action".to_string(),
10380 Value::from(trigger.high_risk_action),
10381 ),
10382 (
10383 "context_shift".to_string(),
10384 Value::from(trigger.context_shift),
10385 ),
10386 ])
10387}
10388
10389fn append_context_block(req: &mut car_inference::GenerateRequest, title: &str, body: &str) {
10390 let block = format!("## {title}\n{body}");
10391 req.context = Some(match req.context.take() {
10392 Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
10393 _ => block,
10394 });
10395}
10396
10397#[cfg(test)]
10398mod proactive_memory_activation_tests {
10399 use super::{proactive_memory_activation, ProactiveMemoryActivation};
10400 use car_inference::{GenerateRequest, IntentHint, TaskHint};
10401 use car_policy::PermissionTier;
10402 use serde_json::json;
10403
10404 fn req() -> GenerateRequest {
10405 GenerateRequest {
10406 prompt: "finish the task".to_string(),
10407 ..Default::default()
10408 }
10409 }
10410
10411 fn is_disabled(activation: ProactiveMemoryActivation) -> bool {
10412 matches!(activation, ProactiveMemoryActivation::Disabled)
10413 }
10414
10415 fn is_default(activation: ProactiveMemoryActivation) -> bool {
10416 matches!(activation, ProactiveMemoryActivation::Default)
10417 }
10418
10419 #[test]
10420 fn explicit_false_disables_even_when_auto_eligible() {
10421 let mut req = req();
10422 req.tools = Some(vec![json!({"name": "edit_file"})]);
10423 let activation = proactive_memory_activation(
10424 &json!({"memory_intervention": false}),
10425 &req,
10426 PermissionTier::FullAccess,
10427 )
10428 .unwrap();
10429 assert!(is_disabled(activation));
10430 }
10431
10432 #[test]
10433 fn explicit_true_uses_default_request() {
10434 let activation = proactive_memory_activation(
10435 &json!({"memory_intervention": true}),
10436 &req(),
10437 PermissionTier::ReadOnly,
10438 )
10439 .unwrap();
10440 assert!(is_default(activation));
10441 }
10442
10443 #[test]
10444 fn explicit_object_is_preserved() {
10445 let activation = proactive_memory_activation(
10446 &json!({
10447 "memory_intervention": {
10448 "query": "pricing rollout",
10449 "tenant_id": "tenant-a"
10450 }
10451 }),
10452 &req(),
10453 PermissionTier::ReadOnly,
10454 )
10455 .unwrap();
10456 let ProactiveMemoryActivation::Request(request) = activation else {
10457 panic!("expected explicit request");
10458 };
10459 assert_eq!(request.query, "pricing rollout");
10460 assert_eq!(request.tenant_id.as_deref(), Some("tenant-a"));
10461 }
10462
10463 #[test]
10464 fn ordinary_chat_is_not_auto_enabled() {
10465 let mut req = req();
10466 req.intent = Some(IntentHint {
10467 task: Some(TaskHint::Chat),
10468 ..Default::default()
10469 });
10470 let activation =
10471 proactive_memory_activation(&json!({}), &req, PermissionTier::ReadOnly).unwrap();
10472 assert!(is_disabled(activation));
10473 }
10474
10475 #[test]
10476 fn tool_code_reasoning_and_high_stakes_requests_auto_enable() {
10477 let mut tool_req = req();
10478 tool_req.tools = Some(vec![json!({"name": "shell"})]);
10479 assert!(is_default(
10480 proactive_memory_activation(&json!({}), &tool_req, PermissionTier::ReadOnly).unwrap()
10481 ));
10482
10483 let mut code_req = req();
10484 code_req.intent = Some(IntentHint {
10485 task: Some(TaskHint::Code),
10486 ..Default::default()
10487 });
10488 assert!(is_default(
10489 proactive_memory_activation(&json!({}), &code_req, PermissionTier::ReadOnly).unwrap()
10490 ));
10491
10492 let mut reasoning_req = req();
10493 reasoning_req.intent = Some(IntentHint {
10494 task: Some(TaskHint::Reasoning),
10495 ..Default::default()
10496 });
10497 assert!(is_default(
10498 proactive_memory_activation(&json!({}), &reasoning_req, PermissionTier::ReadOnly)
10499 .unwrap()
10500 ));
10501
10502 assert!(is_default(
10503 proactive_memory_activation(&json!({}), &req(), PermissionTier::FullAccess).unwrap()
10504 ));
10505 }
10506}
10507
10508async fn handle_memory_build_context(
10509 req: &JsonRpcMessage,
10510 session: &crate::session::ClientSession,
10511) -> Result<Value, String> {
10512 let query = req
10513 .params
10514 .get("query")
10515 .and_then(|v| v.as_str())
10516 .unwrap_or("");
10517 let model_context_window = req
10521 .params
10522 .get("model_context_window")
10523 .and_then(|v| v.as_u64())
10524 .map(|w| w as usize);
10525 let engine_arc = session.effective_memgine().await;
10530 let mut engine = engine_arc.lock().await;
10531 Ok(Value::from(
10532 engine.build_context_for_model(query, model_context_window),
10533 ))
10534}
10535
10536async fn handle_memory_build_context_fast(
10542 req: &JsonRpcMessage,
10543 session: &crate::session::ClientSession,
10544) -> Result<Value, String> {
10545 let query = req
10546 .params
10547 .get("query")
10548 .and_then(|v| v.as_str())
10549 .unwrap_or("");
10550 let model_context_window = req
10551 .params
10552 .get("model_context_window")
10553 .and_then(|v| v.as_u64())
10554 .map(|w| w as usize);
10555 let engine_arc = session.effective_memgine().await;
10557 let mut engine = engine_arc.lock().await;
10558 Ok(Value::from(engine.build_context_with_options(
10559 query,
10560 model_context_window,
10561 car_memgine::ContextMode::Fast,
10562 None,
10563 )))
10564}
10565
10566struct ProtocolHandshakeError {
10582 code: i32,
10583 message: String,
10584}
10585
10586pub(crate) fn handle_server_schema() -> Result<Value, String> {
10595 crate::wire_schema::committed_payload()
10596}
10597
10598fn handle_server_handshake(
10607 req: &JsonRpcMessage,
10608 session: &crate::session::ClientSession,
10609) -> Result<Value, ProtocolHandshakeError> {
10610 let client_protocol = req
10611 .params
10612 .get("protocol_version")
10613 .and_then(Value::as_u64)
10614 .ok_or_else(|| ProtocolHandshakeError {
10615 code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10616 message: format!(
10617 "{} `server.handshake` requires an unsigned numeric \
10618 `protocol_version`; this daemon requires v{}",
10619 car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10620 car_proto::PROTOCOL_VERSION,
10621 ),
10622 })?;
10623
10624 if client_protocol != u64::from(car_proto::PROTOCOL_VERSION) {
10625 return Err(ProtocolHandshakeError {
10626 code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10627 message: format!(
10628 "{} client requested v{}, but this daemon requires v{}; \
10629 update/restart CarHost and car-server so their wire protocols match",
10630 car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10631 client_protocol,
10632 car_proto::PROTOCOL_VERSION,
10633 ),
10634 });
10635 }
10636
10637 fn string_array(params: &Value, field: &str) -> Result<Vec<String>, ProtocolHandshakeError> {
10638 let Some(value) = params.get(field) else {
10639 return Ok(Vec::new());
10640 };
10641 let values = value.as_array().ok_or_else(|| ProtocolHandshakeError {
10642 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10643 message: format!(
10644 "{} `{field}` must be an array of capability strings",
10645 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10646 ),
10647 })?;
10648 values
10649 .iter()
10650 .map(|value| {
10651 value
10652 .as_str()
10653 .filter(|capability| !capability.trim().is_empty())
10654 .map(str::to_string)
10655 .ok_or_else(|| ProtocolHandshakeError {
10656 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10657 message: format!(
10658 "{} `{field}` entries must be non-empty strings",
10659 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10660 ),
10661 })
10662 })
10663 .collect()
10664 }
10665
10666 let required_capabilities = string_array(&req.params, "required_capabilities")?;
10667 let optional_capabilities = string_array(&req.params, "optional_capabilities")?;
10668 let negotiated_capabilities =
10669 car_proto::negotiate_capabilities(&required_capabilities, &optional_capabilities).map_err(
10670 |missing| ProtocolHandshakeError {
10671 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10672 message: format!(
10673 "{} unsupported mandatory capabilities: {}; update/restart CarHost and car-server",
10674 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10675 missing.join(", "),
10676 ),
10677 },
10678 )?;
10679
10680 session.negotiated_protocol_version.store(
10681 car_proto::PROTOCOL_VERSION,
10682 std::sync::atomic::Ordering::Release,
10683 );
10684 *session
10685 .negotiated_capabilities
10686 .write()
10687 .expect("negotiated capability lock poisoned") =
10688 negotiated_capabilities.iter().cloned().collect();
10689
10690 let identity = car_identity::IdentityStore::from_home().load_or_default();
10696
10697 const MAX_CLIENT_VERSION: usize = 64;
10715 let reported = req
10716 .params
10717 .get("client_version")
10718 .and_then(Value::as_str)
10719 .unwrap_or("unknown");
10720 let client_version = match reported.char_indices().nth(MAX_CLIENT_VERSION) {
10721 Some((cut, _)) => &reported[..cut],
10722 None => reported,
10723 };
10724 tracing::debug!(
10725 target: "car_server_core::handler",
10726 client_version,
10727 server_version = env!("CARGO_PKG_VERSION"),
10728 "server.handshake negotiated"
10729 );
10730
10731 serde_json::to_value(crate::wire_schema::ServerHandshakeResult {
10736 protocol_version: car_proto::PROTOCOL_VERSION,
10737 server_version: env!("CARGO_PKG_VERSION").to_string(),
10738 client_protocol_version: client_protocol,
10739 client_version: client_version.to_string(),
10740 negotiated_capabilities,
10741 assistant_name: identity.name.clone(),
10742 assistant_aliases: identity.aliases(),
10743 assistant_brand: car_identity::BRAND_NAME.to_string(),
10744 })
10745 .map_err(|error| ProtocolHandshakeError {
10746 code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10747 message: format!("server.handshake reply does not serialize: {error}"),
10748 })
10749}
10750
10751async fn handle_memory_persist(
10752 req: &JsonRpcMessage,
10753 session: &crate::session::ClientSession,
10754) -> Result<Value, String> {
10755 let path = req
10756 .params
10757 .get("path")
10758 .and_then(|v| v.as_str())
10759 .ok_or("missing path")?;
10760 let resolved = car_ffi_common::memory_path::resolve(path)
10761 .map_err(|e| format!("memory.persist rejected path {path:?}: {e}"))?;
10762 let engine_arc = session.effective_memgine().await;
10768 let engine = engine_arc.lock().await;
10769 let facts = memory_snapshot_facts(&engine);
10770 let count = facts.len();
10771 drop(engine);
10775 let resolved_owned = resolved.clone();
10776 tokio::task::spawn_blocking(move || -> Result<(), String> {
10777 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
10778 atomic_write_sync(&resolved_owned, json.as_bytes())
10779 .map_err(|e| format!("failed to write {}: {}", resolved_owned.display(), e))
10780 })
10781 .await
10782 .map_err(|e| format!("memory.persist join: {e}"))??;
10783 Ok(Value::from(count as u64))
10784}
10785
10786async fn handle_memory_load(
10792 req: &JsonRpcMessage,
10793 session: &crate::session::ClientSession,
10794) -> Result<Value, String> {
10795 let path = req
10796 .params
10797 .get("path")
10798 .and_then(|v| v.as_str())
10799 .ok_or("missing path")?;
10800 let resolved = car_ffi_common::memory_path::resolve(path)
10801 .map_err(|e| format!("memory.load rejected path {path:?}: {e}"))?;
10802 let content = std::fs::read_to_string(&resolved)
10803 .map_err(|e| format!("failed to read {}: {}", resolved.display(), e))?;
10804 let facts: Vec<Value> =
10805 serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {}", e))?;
10806 let engine_arc = session.effective_memgine().await;
10811 let mut engine = engine_arc.lock().await;
10812 engine.reset();
10813 let mut count: u32 = 0;
10814 for fact in &facts {
10815 ingest_snapshot_fact(&mut engine, fact, format!("loaded-{count}"));
10816 count += 1;
10817 }
10818 Ok(Value::from(count))
10819}
10820
10821async fn handle_skill_ingest(
10824 req: &JsonRpcMessage,
10825 session: &crate::session::ClientSession,
10826) -> Result<Value, String> {
10827 let name = req
10828 .params
10829 .get("name")
10830 .and_then(|v| v.as_str())
10831 .ok_or("missing name")?;
10832 let code = req
10833 .params
10834 .get("code")
10835 .and_then(|v| v.as_str())
10836 .ok_or("missing code")?;
10837 let platform = req
10838 .params
10839 .get("platform")
10840 .and_then(|v| v.as_str())
10841 .unwrap_or("unknown");
10842 let persona = req
10843 .params
10844 .get("persona")
10845 .and_then(|v| v.as_str())
10846 .unwrap_or("");
10847 let url_pattern = req
10848 .params
10849 .get("url_pattern")
10850 .and_then(|v| v.as_str())
10851 .unwrap_or("");
10852 let description = req
10853 .params
10854 .get("description")
10855 .and_then(|v| v.as_str())
10856 .unwrap_or("");
10857 let supersedes = opt_str(&req.params, "supersedes");
10858 let keywords: Vec<String> = req
10859 .params
10860 .get("task_keywords")
10861 .and_then(|v| v.as_array())
10862 .map(|arr| {
10863 arr.iter()
10864 .filter_map(|v| v.as_str().map(String::from))
10865 .collect()
10866 })
10867 .unwrap_or_default();
10868
10869 let trigger = car_memgine::SkillTrigger {
10870 persona: persona.into(),
10871 url_pattern: url_pattern.into(),
10872 task_keywords: keywords,
10873 structured: None,
10874 };
10875 let mut engine = session.memgine.lock().await;
10876 let node = engine.ingest_skill(
10877 name,
10878 code,
10879 platform,
10880 trigger,
10881 description,
10882 supersedes,
10883 vec![],
10884 vec![],
10885 );
10886 Ok(Value::from(node.index() as u64))
10887}
10888
10889async fn handle_skill_find(
10890 req: &JsonRpcMessage,
10891 session: &crate::session::ClientSession,
10892) -> Result<Value, String> {
10893 let persona = req
10894 .params
10895 .get("persona")
10896 .and_then(|v| v.as_str())
10897 .unwrap_or("");
10898 let url = str_or(&req.params, "url", "");
10899 let task = req
10900 .params
10901 .get("task")
10902 .and_then(|v| v.as_str())
10903 .unwrap_or("");
10904 let max = req
10905 .params
10906 .get("max_results")
10907 .and_then(|v| v.as_u64())
10908 .unwrap_or(1) as usize;
10909 let tenant = effective_tenant(req, session).await?;
10915 let engine = session.memgine.lock().await;
10916 let results = engine.find_skill_scoped(persona, url, task, max, tenant.as_deref());
10917 let json: Vec<Value> = results
10918 .iter()
10919 .map(|(m, s)| {
10920 serde_json::json!({
10921 "name": m.name, "code": m.code, "platform": m.platform,
10922 "description": m.description, "stats": m.stats, "match_score": s,
10923 })
10924 })
10925 .collect();
10926 serde_json::to_value(json).map_err(|e| e.to_string())
10927}
10928
10929async fn handle_skill_report(
10930 req: &JsonRpcMessage,
10931 session: &crate::session::ClientSession,
10932) -> Result<Value, String> {
10933 let name = req
10934 .params
10935 .get("skill_name")
10936 .and_then(|v| v.as_str())
10937 .ok_or("missing skill_name")?;
10938 let outcome_str = req
10939 .params
10940 .get("outcome")
10941 .and_then(|v| v.as_str())
10942 .ok_or("missing outcome")?;
10943 let outcome = match outcome_str {
10944 "success" => car_memgine::SkillOutcome::Success,
10945 _ => car_memgine::SkillOutcome::Fail,
10946 };
10947 let tenant = effective_tenant(req, session).await?;
10950 let mut engine = session.memgine.lock().await;
10951 let stats = engine
10952 .report_outcome_scoped(name, outcome, tenant.as_deref())
10953 .ok_or(format!("skill '{}' not found", name))?;
10954 serde_json::to_value(stats).map_err(|e| e.to_string())
10955}
10956
10957async fn handle_skill_gate_deployment(
10966 req: &JsonRpcMessage,
10967 session: &crate::session::ClientSession,
10968) -> Result<Value, String> {
10969 let name = req
10970 .params
10971 .get("skill_name")
10972 .and_then(|v| v.as_str())
10973 .ok_or("missing skill_name")?;
10974 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10975 req.params
10976 .get("provenance")
10977 .cloned()
10978 .unwrap_or_else(|| serde_json::json!({})),
10979 )
10980 .map_err(|e| format!("invalid provenance: {e}"))?;
10981 let tier_str = req
10982 .params
10983 .get("requested_tier")
10984 .and_then(|v| v.as_str())
10985 .ok_or("missing requested_tier")?;
10986 let requested =
10987 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10988 format!(
10989 "invalid requested_tier '{tier_str}' \
10990 (expected read_only|sandbox_edit|full_access)"
10991 )
10992 })?;
10993 let engine = session.memgine.lock().await;
10994 let decision = engine.gate_skill_deployment(name, provenance, requested);
10995 serde_json::to_value(decision).map_err(|e| e.to_string())
10996}
10997
10998async fn handle_skill_enforce_deployment(
11008 req: &JsonRpcMessage,
11009 session: &crate::session::ClientSession,
11010 state: &ServerState,
11011) -> Result<Value, String> {
11012 let name = req
11013 .params
11014 .get("skill_name")
11015 .and_then(|v| v.as_str())
11016 .ok_or("missing skill_name")?;
11017 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
11018 req.params
11019 .get("provenance")
11020 .cloned()
11021 .unwrap_or_else(|| serde_json::json!({})),
11022 )
11023 .map_err(|e| format!("invalid provenance: {e}"))?;
11024 let tier_str = req
11025 .params
11026 .get("requested_tier")
11027 .and_then(|v| v.as_str())
11028 .ok_or("missing requested_tier")?;
11029 let requested =
11030 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11031 format!(
11032 "invalid requested_tier '{tier_str}' \
11033 (expected read_only|sandbox_edit|full_access)"
11034 )
11035 })?;
11036
11037 let decision = {
11039 let engine = session.memgine.lock().await;
11040 engine.gate_skill_deployment(name, provenance, requested)
11041 };
11042 let enforcement = {
11045 let ledger = state.approval_ledger.read().await;
11046 car_policy::skill_trust::enforce_deployment(&decision, name, requested, &ledger)
11047 };
11048
11049 let pending_json = enforcement.pending.as_ref().map(|p| {
11052 serde_json::json!({
11053 "fingerprint": p.fingerprint,
11054 "skill_name": p.skill_name,
11055 "requested_tier": requested.as_str(),
11056 })
11057 });
11058 let mut resp = serde_json::json!({ "decision": decision, "enforcement": enforcement });
11059 if let Some(pj) = pending_json {
11060 resp.as_object_mut()
11061 .unwrap()
11062 .insert("pending_approval".into(), pj);
11063 }
11064 Ok(resp)
11065}
11066
11067async fn handle_skill_ingest_governed(
11078 req: &JsonRpcMessage,
11079 session: &crate::session::ClientSession,
11080 state: &ServerState,
11081) -> Result<Value, String> {
11082 let name = req
11083 .params
11084 .get("name")
11085 .and_then(|v| v.as_str())
11086 .ok_or("missing name")?;
11087 let code = req
11088 .params
11089 .get("code")
11090 .and_then(|v| v.as_str())
11091 .ok_or("missing code")?;
11092 let platform = req
11093 .params
11094 .get("platform")
11095 .and_then(|v| v.as_str())
11096 .unwrap_or("unknown");
11097 let persona = req
11098 .params
11099 .get("persona")
11100 .and_then(|v| v.as_str())
11101 .unwrap_or("");
11102 let url_pattern = req
11103 .params
11104 .get("url_pattern")
11105 .and_then(|v| v.as_str())
11106 .unwrap_or("");
11107 let description = req
11108 .params
11109 .get("description")
11110 .and_then(|v| v.as_str())
11111 .unwrap_or("");
11112 let supersedes = opt_str(&req.params, "supersedes");
11113 let keywords: Vec<String> = req
11114 .params
11115 .get("task_keywords")
11116 .and_then(|v| v.as_array())
11117 .map(|arr| {
11118 arr.iter()
11119 .filter_map(|v| v.as_str().map(String::from))
11120 .collect()
11121 })
11122 .unwrap_or_default();
11123 let trigger = car_memgine::SkillTrigger {
11124 persona: persona.into(),
11125 url_pattern: url_pattern.into(),
11126 task_keywords: keywords,
11127 structured: None,
11128 };
11129
11130 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
11131 req.params
11132 .get("provenance")
11133 .cloned()
11134 .unwrap_or_else(|| serde_json::json!({})),
11135 )
11136 .map_err(|e| format!("invalid provenance: {e}"))?;
11137 let tier_str = req
11138 .params
11139 .get("requested_tier")
11140 .and_then(|v| v.as_str())
11141 .ok_or("missing requested_tier")?;
11142 let requested =
11143 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11144 format!(
11145 "invalid requested_tier '{tier_str}' \
11146 (expected read_only|sandbox_edit|full_access)"
11147 )
11148 })?;
11149
11150 let ledger = state.approval_ledger.read().await;
11156 let out = {
11157 let mut engine = session.memgine.lock().await;
11158 engine.ingest_skill_governed(
11159 name,
11160 code,
11161 platform,
11162 trigger,
11163 description,
11164 supersedes,
11165 vec![],
11166 vec![],
11167 provenance,
11168 requested,
11169 &ledger,
11170 )
11171 };
11172 drop(ledger);
11173
11174 let pending_json = out.enforcement.pending.as_ref().map(|p| {
11175 serde_json::json!({
11176 "fingerprint": p.fingerprint,
11177 "skill_name": p.skill_name,
11178 "requested_tier": requested.as_str(),
11179 })
11180 });
11181 let mut resp = serde_json::json!({
11182 "ingested": out.ingested.is_some(),
11183 "node": out.ingested.map(|n| n.index()),
11184 "decision": out.decision,
11185 "enforcement": out.enforcement,
11186 });
11187 if let Some(pj) = pending_json {
11188 resp.as_object_mut()
11189 .unwrap()
11190 .insert("pending_approval".into(), pj);
11191 }
11192 Ok(resp)
11193}
11194
11195fn resolve_adopt_provenance(
11215 params: &Value,
11216 trusted: &[String],
11217) -> Result<car_policy::skill_trust::SkillProvenance, String> {
11218 let scanned = params
11219 .get("scanned")
11220 .and_then(|v| v.as_bool())
11221 .unwrap_or(false);
11222 let vulnerabilities = params
11223 .get("vulnerabilities")
11224 .and_then(|v| v.as_u64())
11225 .unwrap_or(0);
11226 let source_str = params
11227 .get("source")
11228 .and_then(|v| v.as_str())
11229 .unwrap_or("unknown");
11230 let source: car_policy::skill_trust::SkillSource =
11231 serde_json::from_value(Value::String(source_str.to_string())).map_err(|_| {
11232 format!(
11233 "invalid source '{source_str}' \
11234 (expected official|first_party|community|unknown)"
11235 )
11236 })?;
11237
11238 let manifest_val = params.get("manifest");
11239 let provenance_val = params.get("provenance");
11240 if manifest_val.is_some() && provenance_val.is_some() {
11241 return Err(
11242 "pass either 'manifest' (signature derived from the bundle) or \
11243 'provenance' (caller-assembled), not both"
11244 .into(),
11245 );
11246 }
11247
11248 if let Some(mv) = manifest_val {
11249 let manifest: car_registry::manifest::AgentManifest =
11250 serde_json::from_value(mv.clone()).map_err(|e| format!("invalid manifest: {e}"))?;
11251 return Ok(car_memgine::MemgineEngine::skill_provenance_from_bundle(
11252 &manifest,
11253 trusted,
11254 scanned,
11255 vulnerabilities,
11256 source,
11257 ));
11258 }
11259
11260 if let Some(pv) = provenance_val {
11261 return serde_json::from_value(pv.clone()).map_err(|e| format!("invalid provenance: {e}"));
11262 }
11263
11264 Ok(car_policy::skill_trust::SkillProvenance {
11265 scanned,
11266 vulnerabilities,
11267 source,
11268 ..Default::default()
11269 })
11270}
11271
11272async fn handle_skill_adopt_pack(
11307 req: &JsonRpcMessage,
11308 session: &crate::session::ClientSession,
11309 state: &ServerState,
11310) -> Result<Value, String> {
11311 let pack_val = req.params.get("pack").ok_or("missing 'pack' parameter")?;
11312 let pack: car_memgine::ApprovedSkillPack =
11313 serde_json::from_value(pack_val.clone()).map_err(|e| format!("invalid pack: {e}"))?;
11314 let tier_str = req
11315 .params
11316 .get("requested_tier")
11317 .and_then(|v| v.as_str())
11318 .unwrap_or("read_only");
11319 let requested =
11320 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11321 format!(
11322 "invalid requested_tier '{tier_str}' \
11323 (expected read_only|sandbox_edit|full_access)"
11324 )
11325 })?;
11326
11327 let trusted = seed_trusted_skill_signers();
11330 let provenance = resolve_adopt_provenance(&req.params, &trusted)?;
11331
11332 let ledger = state.approval_ledger.read().await;
11337 let out = {
11338 let mut engine = session.memgine.lock().await;
11339 pack.materialize_into_memgine_governed(&mut engine, &provenance, requested, &ledger)
11340 };
11341 drop(ledger);
11342
11343 let pending: Vec<Value> = out
11344 .pending
11345 .iter()
11346 .map(|(skill_id, fingerprint)| {
11347 serde_json::json!({ "skill_id": skill_id, "fingerprint": fingerprint })
11348 })
11349 .collect();
11350 let refused: Vec<Value> = out
11351 .refused
11352 .iter()
11353 .map(|(skill_id, reason)| serde_json::json!({ "skill_id": skill_id, "reason": reason }))
11354 .collect();
11355
11356 Ok(serde_json::json!({
11357 "loaded": out.loaded,
11358 "pending": pending,
11359 "refused": refused,
11360 "requested_tier": requested.as_str(),
11361 "provenance": provenance,
11362 "trusted_signers": trusted.len(),
11367 }))
11368}
11369
11370struct WsAgentRunner {
11379 channel: Arc<WsChannel>,
11380 host: Arc<crate::host::HostState>,
11381 client_id: String,
11382}
11383
11384#[async_trait::async_trait]
11385impl car_multi::AgentRunner for WsAgentRunner {
11386 async fn run(
11387 &self,
11388 spec: &car_multi::AgentSpec,
11389 task: &str,
11390 _runtime: &car_engine::Runtime,
11391 _mailbox: &car_multi::Mailbox,
11392 ) -> std::result::Result<car_multi::AgentOutput, car_multi::MultiError> {
11393 use futures::SinkExt;
11394
11395 let request_id = self.channel.next_request_id();
11396 let agent_id = agent_id_for_run(&self.client_id, &spec.name, &request_id);
11397 let agent = self
11398 .host
11399 .register_agent(
11400 &self.client_id,
11401 RegisterHostAgentRequest {
11402 id: Some(agent_id.clone()),
11403 name: spec.name.clone(),
11404 kind: "callback".to_string(),
11405 capabilities: spec.tools.clone(),
11406 project: spec
11407 .metadata
11408 .get("project")
11409 .and_then(|v| v.as_str())
11410 .map(str::to_string),
11411 pid: None,
11412 display: serde_json::from_value(
11413 spec.metadata
11414 .get("display")
11415 .cloned()
11416 .unwrap_or(serde_json::Value::Null),
11417 )
11418 .unwrap_or_default(),
11419 metadata: serde_json::to_value(&spec.metadata).unwrap_or(Value::Null),
11420 },
11421 )
11422 .await
11423 .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e))?;
11424 let _ = self
11425 .host
11426 .set_status(
11427 &self.client_id,
11428 SetHostAgentStatusRequest {
11429 agent_id: agent.id.clone(),
11430 status: HostAgentStatus::Running,
11431 current_task: Some(task.to_string()),
11432 message: Some(format!("{} started", spec.name)),
11433 payload: serde_json::json!({ "task": task }),
11434 },
11435 )
11436 .await;
11437
11438 let rpc_request = serde_json::json!({
11439 "jsonrpc": "2.0",
11440 "method": "multi.run_agent",
11441 "params": {
11442 "spec": spec,
11443 "task": task,
11444 },
11445 "id": request_id,
11446 });
11447
11448 let (tx, rx) = tokio::sync::oneshot::channel();
11450 self.channel
11451 .pending
11452 .lock()
11453 .await
11454 .insert(request_id.clone(), tx);
11455
11456 let msg = Message::Text(
11457 serde_json::to_string(&rpc_request)
11458 .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e.to_string()))?
11459 .into(),
11460 );
11461 if let Err(e) = self.channel.write.lock().await.send(msg).await {
11462 let _ = self
11463 .host
11464 .set_status(
11465 &self.client_id,
11466 SetHostAgentStatusRequest {
11467 agent_id: agent_id.clone(),
11468 status: HostAgentStatus::Errored,
11469 current_task: None,
11470 message: Some(format!("{} failed to start", spec.name)),
11471 payload: serde_json::json!({ "error": e.to_string() }),
11472 },
11473 )
11474 .await;
11475 return Err(car_multi::MultiError::AgentFailed(
11476 spec.name.clone(),
11477 format!("ws send error: {}", e),
11478 ));
11479 }
11480
11481 let response = match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
11483 Ok(Ok(response)) => response,
11484 Ok(Err(_)) => {
11485 let _ = self
11486 .host
11487 .set_status(
11488 &self.client_id,
11489 SetHostAgentStatusRequest {
11490 agent_id: agent_id.clone(),
11491 status: HostAgentStatus::Errored,
11492 current_task: None,
11493 message: Some(format!("{} callback channel closed", spec.name)),
11494 payload: Value::Null,
11495 },
11496 )
11497 .await;
11498 return Err(car_multi::MultiError::AgentFailed(
11499 spec.name.clone(),
11500 "agent callback channel closed".into(),
11501 ));
11502 }
11503 Err(_) => {
11504 let _ = self
11505 .host
11506 .set_status(
11507 &self.client_id,
11508 SetHostAgentStatusRequest {
11509 agent_id: agent_id.clone(),
11510 status: HostAgentStatus::Errored,
11511 current_task: None,
11512 message: Some(format!("{} timed out", spec.name)),
11513 payload: Value::Null,
11514 },
11515 )
11516 .await;
11517 return Err(car_multi::MultiError::AgentFailed(
11518 spec.name.clone(),
11519 "agent callback timed out (300s)".into(),
11520 ));
11521 }
11522 };
11523
11524 if let Some(err) = response.error {
11525 let _ = self
11526 .host
11527 .set_status(
11528 &self.client_id,
11529 SetHostAgentStatusRequest {
11530 agent_id: agent_id.clone(),
11531 status: HostAgentStatus::Errored,
11532 current_task: None,
11533 message: Some(format!("{} errored", spec.name)),
11534 payload: serde_json::json!({ "error": err }),
11535 },
11536 )
11537 .await;
11538 return Err(car_multi::MultiError::AgentFailed(spec.name.clone(), err));
11539 }
11540
11541 let output_value = response.output.unwrap_or(Value::Null);
11542 let output: car_multi::AgentOutput = serde_json::from_value(output_value).map_err(|e| {
11543 car_multi::MultiError::AgentFailed(
11544 spec.name.clone(),
11545 format!("invalid AgentOutput: {}", e),
11546 )
11547 })?;
11548 let status = if output.error.is_some() {
11549 HostAgentStatus::Errored
11550 } else {
11551 HostAgentStatus::Completed
11552 };
11553 let message = if output.error.is_some() {
11554 format!("{} errored", spec.name)
11555 } else {
11556 format!("{} completed", spec.name)
11557 };
11558 let _ = self
11559 .host
11560 .set_status(
11561 &self.client_id,
11562 SetHostAgentStatusRequest {
11563 agent_id,
11564 status,
11565 current_task: None,
11566 message: Some(message),
11567 payload: serde_json::to_value(&output).unwrap_or(Value::Null),
11568 },
11569 )
11570 .await;
11571
11572 Ok(output)
11573 }
11574}
11575
11576fn agent_id_for_run(client_id: &str, name: &str, request_id: &str) -> String {
11577 let safe_name: String = name
11578 .chars()
11579 .map(|c| {
11580 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
11581 c
11582 } else {
11583 '-'
11584 }
11585 })
11586 .collect();
11587 format!("{}:{}:{}", client_id, safe_name, request_id)
11588}
11589
11590fn multi_infra_with_budget(
11595 req: &JsonRpcMessage,
11596 session: &crate::session::ClientSession,
11597) -> Result<car_multi::SharedInfra, String> {
11598 let infra = car_multi::SharedInfra::with_shared(
11608 std::sync::Arc::clone(&session.runtime.state),
11609 std::sync::Arc::clone(&session.runtime.log),
11610 std::sync::Arc::clone(&session.runtime.policies),
11611 )
11612 .with_concurrency_gating();
11613 match req.params.get("budget") {
11614 None | Some(Value::Null) => Ok(infra),
11615 Some(v) => {
11616 let limits: car_multi::BudgetLimits =
11617 serde_json::from_value(v.clone()).map_err(|e| format!("invalid budget: {}", e))?;
11618 Ok(infra.with_budget(limits))
11619 }
11620 }
11621}
11622
11623async fn handle_multi_swarm(
11624 req: &JsonRpcMessage,
11625 session: &crate::session::ClientSession,
11626) -> Result<Value, String> {
11627 let mode_str = req
11628 .params
11629 .get("mode")
11630 .and_then(|v| v.as_str())
11631 .ok_or("missing 'mode'")?;
11632 let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11633 let task = req
11634 .params
11635 .get("task")
11636 .and_then(|v| v.as_str())
11637 .ok_or("missing 'task'")?;
11638
11639 let swarm_mode: car_multi::SwarmMode = serde_json::from_str(&format!("\"{}\"", mode_str))
11640 .map_err(|e| format!("invalid mode '{}': {}", mode_str, e))?;
11641 let agent_specs: Vec<car_multi::AgentSpec> =
11642 serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11643 let synth: Option<car_multi::AgentSpec> = req
11644 .params
11645 .get("synthesizer")
11646 .map(|v| serde_json::from_value(v.clone()))
11647 .transpose()
11648 .map_err(|e| format!("invalid synthesizer: {}", e))?;
11649
11650 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11651 channel: session.channel.clone(),
11652 host: session.host.clone(),
11653 client_id: session.client_id.clone(),
11654 });
11655 let infra = multi_infra_with_budget(req, session)?;
11656
11657 let mut swarm = car_multi::Swarm::new(agent_specs, swarm_mode);
11658 if let Some(s) = synth {
11659 swarm = swarm.with_synthesizer(s);
11660 }
11661
11662 let result = swarm
11663 .run(task, &runner, &infra)
11664 .await
11665 .map_err(|e| format!("swarm error: {}", e))?;
11666 serde_json::to_value(result).map_err(|e| e.to_string())
11667}
11668
11669async fn handle_multi_pipeline(
11670 req: &JsonRpcMessage,
11671 session: &crate::session::ClientSession,
11672) -> Result<Value, String> {
11673 let stages_val = req.params.get("stages").ok_or("missing 'stages'")?;
11674 let task = req
11675 .params
11676 .get("task")
11677 .and_then(|v| v.as_str())
11678 .ok_or("missing 'task'")?;
11679
11680 let stage_specs: Vec<car_multi::AgentSpec> =
11681 serde_json::from_value(stages_val.clone()).map_err(|e| format!("invalid stages: {}", e))?;
11682
11683 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11684 channel: session.channel.clone(),
11685 host: session.host.clone(),
11686 client_id: session.client_id.clone(),
11687 });
11688 let infra = multi_infra_with_budget(req, session)?;
11689
11690 let result = car_multi::Pipeline::new(stage_specs)
11691 .run(task, &runner, &infra)
11692 .await
11693 .map_err(|e| format!("pipeline error: {}", e))?;
11694 serde_json::to_value(result).map_err(|e| e.to_string())
11695}
11696
11697async fn handle_multi_supervisor(
11698 req: &JsonRpcMessage,
11699 session: &crate::session::ClientSession,
11700) -> Result<Value, String> {
11701 let workers_val = req.params.get("workers").ok_or("missing 'workers'")?;
11702 let supervisor_val = req.params.get("supervisor").ok_or("missing 'supervisor'")?;
11703 let task = req
11704 .params
11705 .get("task")
11706 .and_then(|v| v.as_str())
11707 .ok_or("missing 'task'")?;
11708 let max_rounds = req
11709 .params
11710 .get("max_rounds")
11711 .and_then(|v| v.as_u64())
11712 .unwrap_or(3) as u32;
11713
11714 let worker_specs: Vec<car_multi::AgentSpec> = serde_json::from_value(workers_val.clone())
11715 .map_err(|e| format!("invalid workers: {}", e))?;
11716 let supervisor_spec: car_multi::AgentSpec = serde_json::from_value(supervisor_val.clone())
11717 .map_err(|e| format!("invalid supervisor: {}", e))?;
11718
11719 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11720 channel: session.channel.clone(),
11721 host: session.host.clone(),
11722 client_id: session.client_id.clone(),
11723 });
11724 let infra = multi_infra_with_budget(req, session)?;
11725
11726 let result = car_multi::Supervisor::new(worker_specs, supervisor_spec)
11727 .with_max_rounds(max_rounds)
11728 .run(task, &runner, &infra)
11729 .await
11730 .map_err(|e| format!("supervisor error: {}", e))?;
11731 serde_json::to_value(result).map_err(|e| e.to_string())
11732}
11733
11734async fn handle_multi_map_reduce(
11735 req: &JsonRpcMessage,
11736 session: &crate::session::ClientSession,
11737) -> Result<Value, String> {
11738 let mapper_val = req.params.get("mapper").ok_or("missing 'mapper'")?;
11739 let reducer_val = req.params.get("reducer").ok_or("missing 'reducer'")?;
11740 let task = req
11741 .params
11742 .get("task")
11743 .and_then(|v| v.as_str())
11744 .ok_or("missing 'task'")?;
11745 let items_val = req.params.get("items").ok_or("missing 'items'")?;
11746
11747 let mapper_spec: car_multi::AgentSpec =
11748 serde_json::from_value(mapper_val.clone()).map_err(|e| format!("invalid mapper: {}", e))?;
11749 let reducer_spec: car_multi::AgentSpec = serde_json::from_value(reducer_val.clone())
11750 .map_err(|e| format!("invalid reducer: {}", e))?;
11751 let items: Vec<String> =
11752 serde_json::from_value(items_val.clone()).map_err(|e| format!("invalid items: {}", e))?;
11753
11754 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11755 channel: session.channel.clone(),
11756 host: session.host.clone(),
11757 client_id: session.client_id.clone(),
11758 });
11759 let infra = multi_infra_with_budget(req, session)?;
11760
11761 let result = car_multi::MapReduce::new(mapper_spec, reducer_spec)
11762 .run(task, &items, &runner, &infra)
11763 .await
11764 .map_err(|e| format!("map_reduce error: {}", e))?;
11765 serde_json::to_value(result).map_err(|e| e.to_string())
11766}
11767
11768async fn handle_multi_vote(
11769 req: &JsonRpcMessage,
11770 session: &crate::session::ClientSession,
11771) -> Result<Value, String> {
11772 let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11773 let task = req
11774 .params
11775 .get("task")
11776 .and_then(|v| v.as_str())
11777 .ok_or("missing 'task'")?;
11778
11779 let agent_specs: Vec<car_multi::AgentSpec> =
11780 serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11781 let synth: Option<car_multi::AgentSpec> = req
11782 .params
11783 .get("synthesizer")
11784 .map(|v| serde_json::from_value(v.clone()))
11785 .transpose()
11786 .map_err(|e| format!("invalid synthesizer: {}", e))?;
11787
11788 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11789 channel: session.channel.clone(),
11790 host: session.host.clone(),
11791 client_id: session.client_id.clone(),
11792 });
11793 let infra = multi_infra_with_budget(req, session)?;
11794
11795 let mut vote = car_multi::Vote::new(agent_specs);
11796 if let Some(s) = synth {
11797 vote = vote.with_synthesizer(s);
11798 }
11799
11800 let result = vote
11801 .run(task, &runner, &infra)
11802 .await
11803 .map_err(|e| format!("vote error: {}", e))?;
11804 serde_json::to_value(result).map_err(|e| e.to_string())
11805}
11806
11807async fn handle_multi_tournament(
11808 req: &JsonRpcMessage,
11809 session: &crate::session::ClientSession,
11810) -> Result<Value, String> {
11811 let competitors_val = req
11812 .params
11813 .get("competitors")
11814 .ok_or("missing 'competitors'")?;
11815 let judge_val = req.params.get("judge").ok_or("missing 'judge'")?;
11816 let task = req
11817 .params
11818 .get("task")
11819 .and_then(|v| v.as_str())
11820 .ok_or("missing 'task'")?;
11821
11822 let competitors: Vec<car_multi::AgentSpec> = serde_json::from_value(competitors_val.clone())
11823 .map_err(|e| format!("invalid competitors: {}", e))?;
11824 let judge: car_multi::AgentSpec =
11825 serde_json::from_value(judge_val.clone()).map_err(|e| format!("invalid judge: {}", e))?;
11826
11827 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11828 channel: session.channel.clone(),
11829 host: session.host.clone(),
11830 client_id: session.client_id.clone(),
11831 });
11832 let infra = multi_infra_with_budget(req, session)?;
11833
11834 let result = car_multi::Tournament::new(competitors, judge)
11835 .run(task, &runner, &infra)
11836 .await
11837 .map_err(|e| format!("tournament error: {}", e))?;
11838 serde_json::to_value(result).map_err(|e| e.to_string())
11839}
11840
11841async fn handle_multi_subtask(
11842 req: &JsonRpcMessage,
11843 session: &crate::session::ClientSession,
11844) -> Result<Value, String> {
11845 let main_val = req.params.get("main").ok_or("missing 'main'")?;
11846 let task = req
11847 .params
11848 .get("task")
11849 .and_then(|v| v.as_str())
11850 .ok_or("missing 'task'")?;
11851
11852 let main_spec: car_multi::AgentSpec =
11853 serde_json::from_value(main_val.clone()).map_err(|e| format!("invalid main: {}", e))?;
11854
11855 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11856 channel: session.channel.clone(),
11857 host: session.host.clone(),
11858 client_id: session.client_id.clone(),
11859 });
11860 let infra = multi_infra_with_budget(req, session)?;
11861
11862 let result = car_multi::SpawnSubtask::new(main_spec)
11863 .run(task, &runner, &infra)
11864 .await
11865 .map_err(|e| format!("spawn_subtask error: {}", e))?;
11866 serde_json::to_value(result).map_err(|e| e.to_string())
11867}
11868
11869fn handle_schedule_suggest(req: &JsonRpcMessage) -> Result<Value, String> {
11877 let phrase = req
11878 .params
11879 .get("phrase")
11880 .and_then(Value::as_str)
11881 .ok_or("schedule.suggest requires 'phrase'")?;
11882 let timezone = req.params.get("timezone").and_then(Value::as_str);
11883 serde_json::to_value(car_scheduler::suggest_schedule(phrase, timezone))
11884 .map_err(|e| e.to_string())
11885}
11886
11887fn handle_scheduler_create(req: &JsonRpcMessage) -> Result<Value, String> {
11888 let name = req
11889 .params
11890 .get("name")
11891 .and_then(|v| v.as_str())
11892 .ok_or("scheduler.create requires 'name'")?;
11893 let prompt = req
11894 .params
11895 .get("prompt")
11896 .and_then(|v| v.as_str())
11897 .ok_or("scheduler.create requires 'prompt'")?;
11898
11899 let mut task = car_scheduler::Task::new(name, prompt);
11900
11901 if let Some(t) = opt_str(&req.params, "trigger") {
11902 let trigger = match t {
11903 "once" => car_scheduler::TaskTrigger::Once,
11904 "cron" => car_scheduler::TaskTrigger::Cron,
11905 "interval" => car_scheduler::TaskTrigger::Interval,
11906 "file_watch" => car_scheduler::TaskTrigger::FileWatch,
11907 _ => car_scheduler::TaskTrigger::Manual,
11908 };
11909 let schedule = req
11910 .params
11911 .get("schedule")
11912 .and_then(|v| v.as_str())
11913 .unwrap_or("");
11914 task = task.with_trigger(trigger, schedule);
11915 }
11916
11917 if let Some(sp) = opt_str(&req.params, "system_prompt") {
11918 task = task.with_system_prompt(sp);
11919 }
11920
11921 serde_json::to_value(&task).map_err(|e| e.to_string())
11922}
11923
11924fn os_schedule_params(req: &JsonRpcMessage) -> Result<(String, String, String), String> {
11926 let task = req
11927 .params
11928 .get("task")
11929 .ok_or("requires 'task'")
11930 .and_then(|v| serde_json::to_string(v).map_err(|_| "invalid 'task'"))?;
11931 let program = req
11932 .params
11933 .get("program")
11934 .and_then(|v| v.as_str())
11935 .ok_or("requires 'program' (the binary the OS runs to execute the task)")?
11936 .to_string();
11937 let args = req
11938 .params
11939 .get("args")
11940 .cloned()
11941 .unwrap_or_else(|| Value::Array(vec![]));
11942 let args_json = serde_json::to_string(&args).map_err(|e| e.to_string())?;
11943 Ok((task, program, args_json))
11944}
11945
11946fn handle_scheduler_os_render(req: &JsonRpcMessage) -> Result<Value, String> {
11948 let (task, program, args) = os_schedule_params(req)?;
11949 let json = car_ffi_common::scheduler::render_os_schedule(&task, &program, &args)?;
11950 serde_json::from_str(&json).map_err(|e| e.to_string())
11951}
11952
11953fn handle_scheduler_os_install(req: &JsonRpcMessage) -> Result<Value, String> {
11956 let (task, program, args) = os_schedule_params(req)?;
11957 let json = car_ffi_common::scheduler::install_os_schedule(&task, &program, &args)?;
11958 serde_json::from_str(&json).map_err(|e| e.to_string())
11959}
11960
11961fn handle_tasks_schedule(
11970 req: &JsonRpcMessage,
11971 session: &crate::session::ClientSession,
11972 state: &ServerState,
11973) -> Result<Value, String> {
11974 require_approval_authority(session, state)?;
11975 let spec = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
11976 let json = car_ffi_common::scheduler::schedule_task(&spec)?;
11977 serde_json::from_str(&json).map_err(|e| e.to_string())
11978}
11979
11980fn handle_tasks_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11982 let json = car_ffi_common::scheduler::list_scheduled_tasks()?;
11983 serde_json::from_str(&json).map_err(|e| e.to_string())
11984}
11985
11986fn handle_tasks_unschedule(
11990 req: &JsonRpcMessage,
11991 session: &crate::session::ClientSession,
11992 state: &ServerState,
11993) -> Result<Value, String> {
11994 require_approval_authority(session, state)?;
11995 let id = req
11996 .params
11997 .get("id")
11998 .and_then(|v| v.as_str())
11999 .ok_or("tasks.unschedule requires 'id'")?;
12000 let json = car_ffi_common::scheduler::unschedule_task(id)?;
12001 serde_json::from_str(&json).map_err(|e| e.to_string())
12002}
12003
12004fn handle_scheduler_os_uninstall(req: &JsonRpcMessage) -> Result<Value, String> {
12006 let label = req
12007 .params
12008 .get("label")
12009 .and_then(|v| v.as_str())
12010 .ok_or("scheduler.os_uninstall requires 'label' (full label or task id)")?;
12011 let json = car_ffi_common::scheduler::uninstall_os_schedule(label)?;
12012 serde_json::from_str(&json).map_err(|e| e.to_string())
12013}
12014
12015fn handle_scheduler_os_list(_req: &JsonRpcMessage) -> Result<Value, String> {
12017 let json = car_ffi_common::scheduler::list_os_schedules()?;
12018 serde_json::from_str(&json).map_err(|e| e.to_string())
12019}
12020
12021fn handle_scheduler_os_reconcile(_req: &JsonRpcMessage) -> Result<Value, String> {
12024 let json = car_ffi_common::scheduler::reconcile_os_schedules()?;
12025 serde_json::from_str(&json).map_err(|e| e.to_string())
12026}
12027
12028pub fn reconcile_os_schedules_at_boot() {
12032 match car_ffi_common::scheduler::reconcile_os_schedules() {
12033 Ok(json) => match serde_json::from_str::<Value>(&json) {
12034 Ok(report) => {
12035 let removed = report
12036 .get("removed")
12037 .and_then(Value::as_array)
12038 .map_or(0, Vec::len);
12039 let errors = report
12040 .get("errors")
12041 .and_then(Value::as_array)
12042 .map_or(0, Vec::len);
12043 if removed > 0 || errors > 0 {
12044 tracing::info!(removed, errors, "reaped orphaned OS schedules at boot");
12045 }
12046 }
12047 Err(e) => {
12048 tracing::warn!(error = %e, "OS-schedule reconcile returned unparseable report")
12049 }
12050 },
12051 Err(e) => tracing::warn!(error = %e, "OS-schedule reconcile failed at boot"),
12052 }
12053}
12054
12055pub async fn reap_orphaned_sandboxes_at_boot() {
12062 let reaped = car_sandbox::reap_orphaned_sandboxes().await;
12063 if reaped > 0 {
12064 tracing::info!(reaped, "reaped orphaned CAR sandbox containers at boot");
12065 }
12066}
12067
12068fn seed_task_from_store(task: &mut car_scheduler::Task, store: &car_scheduler::TaskStore) {
12079 if let Some(prior) = store.load(&task.id) {
12080 task.created_at = prior.created_at;
12083 task.executions = prior.executions;
12084 task.run_count = prior.run_count;
12085 task.last_run_at = prior.last_run_at;
12086 }
12087}
12088
12089async fn run_scheduler_task_once(
12093 task: &mut car_scheduler::Task,
12094 runner: Arc<dyn car_multi::AgentRunner>,
12095 store: &car_scheduler::TaskStore,
12096) -> car_scheduler::TaskExecution {
12097 seed_task_from_store(task, store);
12098 let executor = car_scheduler::Executor::new(runner);
12099 let execution = executor.run_once(task).await;
12100 let _ = store.save(task);
12101 execution
12102}
12103
12104async fn run_scheduler_task_loop(
12107 task: &mut car_scheduler::Task,
12108 max_iterations: Option<u32>,
12109 runner: Arc<dyn car_multi::AgentRunner>,
12110 store: &car_scheduler::TaskStore,
12111) -> Vec<car_scheduler::TaskExecution> {
12112 seed_task_from_store(task, store);
12113 let executor = car_scheduler::Executor::new(runner);
12114 let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
12115 let executions = executor.run_loop(task, max_iterations, cancel_rx).await;
12116 let _ = store.save(task);
12117 executions
12118}
12119
12120async fn handle_scheduler_run(
12121 req: &JsonRpcMessage,
12122 session: &crate::session::ClientSession,
12123) -> Result<Value, String> {
12124 let task_val = req
12125 .params
12126 .get("task")
12127 .ok_or("scheduler.run requires 'task'")?;
12128 let mut task: car_scheduler::Task =
12129 serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
12130
12131 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
12132 channel: session.channel.clone(),
12133 host: session.host.clone(),
12134 client_id: session.client_id.clone(),
12135 });
12136 let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
12137 let execution = run_scheduler_task_once(&mut task, runner, &store).await;
12138
12139 serde_json::to_value(&execution).map_err(|e| e.to_string())
12140}
12141
12142async fn handle_scheduler_run_loop(
12143 req: &JsonRpcMessage,
12144 session: &crate::session::ClientSession,
12145) -> Result<Value, String> {
12146 let task_val = req
12147 .params
12148 .get("task")
12149 .ok_or("scheduler.run_loop requires 'task'")?;
12150 let mut task: car_scheduler::Task =
12151 serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
12152 let max_iterations = req
12153 .params
12154 .get("max_iterations")
12155 .and_then(|v| v.as_u64())
12156 .map(|v| v as u32);
12157
12158 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
12159 channel: session.channel.clone(),
12160 host: session.host.clone(),
12161 client_id: session.client_id.clone(),
12162 });
12163 let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
12164 let executions = run_scheduler_task_loop(&mut task, max_iterations, runner, &store).await;
12165
12166 serde_json::to_value(&executions).map_err(|e| e.to_string())
12167}
12168
12169pub(crate) fn get_inference_engine(state: &ServerState) -> &Arc<car_inference::InferenceEngine> {
12174 state.inference.get_or_init(|| {
12175 let engine = Arc::new(car_inference::InferenceEngine::new(
12176 car_inference::InferenceConfig::default(),
12177 ));
12178 let worker_disabled = std::env::var_os("CAR_NO_INFERENCE_WORKER").is_some();
12186 if !car_inference::is_offload_worker() && !worker_disabled {
12187 match crate::inference_worker::WorkerOffload::new() {
12188 Ok(offload) => {
12189 car_inference::set_local_offload(Some(std::sync::Arc::new(offload)));
12190 info!("on-device inference isolated in a worker subprocess (car-releases#74)");
12191 }
12192 Err(e) => tracing::warn!(
12193 error = %e,
12194 "could not install the inference worker offloader; on-device \
12195 generation will run in-process (a native MLX abort could crash the daemon)"
12196 ),
12197 }
12198 }
12199 let discovery_enabled = std::env::var("CAR_MODEL_DISCOVERY")
12208 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
12209 .unwrap_or(false);
12210 if discovery_enabled {
12211 match tokio::runtime::Handle::try_current() {
12215 Ok(handle) => {
12216 let disco = Arc::clone(&engine);
12217 handle.spawn(async move {
12218 loop {
12219 match disco.discover_models().await {
12220 Ok(n) => info!("model discovery: {n} discovered models cached"),
12221 Err(e) => tracing::debug!("model discovery skipped: {e}"),
12222 }
12223 tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
12224 }
12225 });
12226 }
12227 Err(_) => tracing::debug!(
12228 "model discovery enabled (CAR_MODEL_DISCOVERY) but no tokio runtime at \
12229 engine init — skipping the background task"
12230 ),
12231 }
12232 }
12233 engine
12234 })
12235}
12236
12237async fn handle_foreman_plan(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
12242 let goal = msg
12243 .params
12244 .get("goal")
12245 .and_then(|v| v.as_str())
12246 .ok_or("missing 'goal'")?
12247 .to_string();
12248 let repo = msg
12249 .params
12250 .get("repo")
12251 .and_then(|v| v.as_str())
12252 .map(std::path::PathBuf::from)
12253 .or_else(|| std::env::current_dir().ok())
12254 .ok_or("no 'repo' and cwd is unavailable")?;
12255 let max_attempts = msg
12256 .params
12257 .get("max_attempts")
12258 .and_then(|v| v.as_u64())
12259 .unwrap_or(3) as u32;
12260
12261 let engine = std::sync::Arc::clone(get_inference_engine(state));
12262 let result = car_multi::decompose(&repo, &goal, max_attempts, move |prompt| {
12263 let engine = std::sync::Arc::clone(&engine);
12264 async move {
12265 engine
12266 .generate(car_inference::GenerateRequest {
12267 prompt,
12268 ..Default::default()
12269 })
12270 .await
12271 .map_err(|e| e.to_string())
12272 }
12273 })
12274 .await;
12275
12276 serde_json::to_value(car_multi::PlanReport::from(&result))
12277 .map_err(|e| format!("serialize plan: {e}"))
12278}
12279
12280async fn handle_foreman_run(
12284 msg: &JsonRpcMessage,
12285 state: &ServerState,
12286 session: &crate::session::ClientSession,
12287) -> Result<Value, String> {
12288 let goal = msg
12289 .params
12290 .get("goal")
12291 .and_then(|v| v.as_str())
12292 .ok_or("missing 'goal'")?
12293 .to_string();
12294 let repo = msg
12295 .params
12296 .get("repo")
12297 .and_then(|v| v.as_str())
12298 .map(std::path::PathBuf::from)
12299 .or_else(|| std::env::current_dir().ok())
12300 .ok_or("no 'repo' and cwd is unavailable")?;
12301 let max_attempts = msg
12302 .params
12303 .get("max_attempts")
12304 .and_then(|v| v.as_u64())
12305 .unwrap_or(3) as u32;
12306 let adapter = msg
12307 .params
12308 .get("adapter")
12309 .and_then(|v| v.as_str())
12310 .unwrap_or("claude-code")
12311 .to_string();
12312 let str_array = |key: &str| -> Option<Vec<String>> {
12313 msg.params.get(key).and_then(|v| v.as_array()).map(|a| {
12314 a.iter()
12315 .filter_map(|x| x.as_str().map(String::from))
12316 .collect()
12317 })
12318 };
12319 let verify_command = str_array("verify_command");
12323 let union_verify_command = str_array("union_verify_command");
12324
12325 let distributed = msg
12330 .params
12331 .get("distributed")
12332 .and_then(|v| v.as_bool())
12333 .unwrap_or(false);
12334 let worker_filter = str_array("workers");
12335
12336 let engine = std::sync::Arc::clone(get_inference_engine(state));
12340 let run_id = uuid::Uuid::new_v4().to_string();
12341 let agent = car_external_agents::ForemanExternalAgent::new(adapter.clone());
12342 let (pool, pool_plan) = if distributed {
12345 let (pool, plan) =
12346 crate::fleet::build_pool(state, &repo, &run_id, &adapter, worker_filter.as_deref())
12347 .await?;
12348 (Some(pool), Some(plan))
12349 } else {
12350 (None, None)
12351 };
12352 let worktree_agent: &dyn car_multi::WorktreeAgent = match &pool {
12353 Some(pool) => pool,
12354 None => &agent,
12355 };
12356 let infra = car_multi::SharedInfra::with_shared(
12361 std::sync::Arc::clone(&session.runtime.state),
12362 std::sync::Arc::clone(&session.runtime.log),
12363 std::sync::Arc::clone(&session.runtime.policies),
12364 );
12365 let config = car_multi::FarmOutConfig {
12366 verify_command,
12367 union_verify_command,
12368 recover_via_single_session: true,
12371 ..Default::default()
12372 };
12373 let outcome = car_multi::run_foreman(
12374 &repo,
12375 &goal,
12376 max_attempts,
12377 worktree_agent,
12378 &config,
12379 &infra,
12380 move |prompt| {
12381 let engine = std::sync::Arc::clone(&engine);
12382 async move {
12383 engine
12384 .generate(car_inference::GenerateRequest {
12385 prompt,
12386 ..Default::default()
12387 })
12388 .await
12389 .map_err(|e| e.to_string())
12390 }
12391 },
12392 )
12393 .await;
12394
12395 let mode = match outcome.mode {
12396 car_multi::RunMode::Parallel => "parallel",
12397 car_multi::RunMode::SingleSession => "single_session",
12398 car_multi::RunMode::ParallelThenSingleSession => "parallel_then_single_session",
12399 car_multi::RunMode::RegionalReplan => "regional_replan",
12400 };
12401 let report =
12402 car_multi::ForemanReport::from_run(&outcome.outcomes, outcome.integration.as_ref());
12403 Ok(serde_json::json!({
12404 "plan": car_multi::PlanReport::from(&outcome.plan),
12405 "mode": mode,
12406 "ran": true,
12407 "delivered": outcome.delivered(),
12408 "run": report,
12409 "run_id": run_id,
12410 "distributed": distributed,
12411 "workers": pool.as_ref().map(|p| p.worker_ids()),
12415 "placements": pool.as_ref().map(crate::fleet::placements_json),
12416 "pool": pool_plan.as_ref().map(|plan| serde_json::json!({
12420 "remote_workers": plan.remote_workers,
12421 "excluded": plan.excluded.iter().map(|(instance, reason)| serde_json::json!({
12422 "instance": instance,
12423 "reason": reason,
12424 })).collect::<Vec<_>>(),
12425 "quarantined": pool.as_ref().map(|p| p.quarantined()).unwrap_or_default(),
12433 "local_only": plan.local_only(),
12434 "degraded_reason": plan.degraded_reason(),
12435 })),
12436 }))
12437}
12438
12439struct ChatInflightGuard<'a>(&'a std::sync::atomic::AtomicUsize);
12443impl Drop for ChatInflightGuard<'_> {
12444 fn drop(&mut self) {
12445 self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
12446 }
12447}
12448
12449fn begin_chat_turn<'a>(
12463 req: &car_inference::GenerateRequest,
12464 session: &'a crate::session::ClientSession,
12465) -> (Option<String>, bool, Option<ChatInflightGuard<'a>>) {
12466 let is_chat = req
12467 .intent
12468 .as_ref()
12469 .is_some_and(|i| i.task == Some(car_inference::TaskHint::Chat));
12470 if !is_chat {
12471 return (None, false, None);
12472 }
12473 let chat_user_text = req
12474 .messages
12475 .as_ref()
12476 .and_then(|ms| {
12477 ms.iter().rev().find_map(|m| match m {
12478 car_inference::Message::User { content } => Some(content.clone()),
12479 _ => None,
12480 })
12481 })
12482 .or_else(|| {
12483 let p = req.prompt.trim();
12484 (!p.is_empty()).then(|| p.to_string())
12485 });
12486 let was_idle = session
12487 .chat_inflight
12488 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
12489 == 0;
12490 (
12491 chat_user_text,
12492 was_idle,
12493 Some(ChatInflightGuard(&session.chat_inflight)),
12494 )
12495}
12496
12497fn score_and_remember_chat_turn(
12503 slot: &mut Option<crate::session::LastChatTurn>,
12504 tracker: &mut car_inference::OutcomeTracker,
12505 new_user_text: String,
12506 result_text: &str,
12507 trace_id: &str,
12508 model_id: &str,
12509) -> car_memgine::outcome_bridge::CreditTally {
12510 use car_memgine::outcome_signal::ConversationTurn;
12511 let mut tally = car_memgine::outcome_bridge::CreditTally::default();
12512 if let Some(prev) = slot.as_ref() {
12513 let turns = vec![
12514 ConversationTurn::user(prev.user_text.clone(), "u_prev"),
12515 ConversationTurn::assistant(
12516 prev.assistant_text.clone(),
12517 "a_prev",
12518 Some(prev.model_id.clone()),
12519 Some(prev.trace_id.clone()),
12520 ),
12521 ConversationTurn::user(new_user_text.clone(), "u_new"),
12522 ];
12523 tally = car_memgine::outcome_bridge::record_conversation_outcomes(tracker, &turns);
12524 }
12525 *slot = Some(crate::session::LastChatTurn {
12526 user_text: new_user_text,
12527 assistant_text: result_text.to_string(),
12528 trace_id: trace_id.to_string(),
12529 model_id: model_id.to_string(),
12530 });
12531 tally
12532}
12533
12534#[cfg(test)]
12535mod chat_outcome_tests {
12536 use super::score_and_remember_chat_turn;
12537 use crate::session::LastChatTurn;
12538 use car_inference::{InferenceTask, OutcomeTracker};
12539
12540 #[test]
12541 fn first_turn_records_nothing_and_stashes() {
12542 let mut slot: Option<LastChatTurn> = None;
12543 let mut tracker = OutcomeTracker::new();
12544 let tally = score_and_remember_chat_turn(
12545 &mut slot,
12546 &mut tracker,
12547 "hello".into(),
12548 "hi there",
12549 "t1",
12550 "m1",
12551 );
12552 assert_eq!(tally.submitted, 0, "no prior turn → nothing to score");
12553 let stashed = slot.expect("this turn must be remembered");
12554 assert_eq!(stashed.trace_id, "t1");
12555 assert_eq!(stashed.user_text, "hello");
12556 }
12557
12558 #[test]
12559 fn second_turn_scores_prior_turn_and_reverses_mechanical_success() {
12560 let mut tracker = OutcomeTracker::new();
12561 let tid = tracker.record_start("m1", InferenceTask::Generate, "test");
12564 tracker.record_complete(&tid, 10, 1, 2);
12565 assert_eq!(tracker.profile("m1").unwrap().success_count, 1);
12566
12567 let mut slot = Some(LastChatTurn {
12568 user_text: "convert this to async".into(),
12569 assistant_text: "here's a threaded version".into(),
12570 trace_id: tid.clone(),
12571 model_id: "m1".into(),
12572 });
12573 let tally = score_and_remember_chat_turn(
12575 &mut slot,
12576 &mut tracker,
12577 "no, that's not what i asked".into(),
12578 "async version",
12579 "t2",
12580 "m1",
12581 );
12582 assert_eq!(
12583 tally.resolved, 1,
12584 "prior turn's trace was pending → resolved"
12585 );
12586 let p = tracker.profile("m1").unwrap();
12587 assert_eq!(p.success_count, 0, "mechanical success reversed");
12588 assert_eq!(p.fail_count, 1, "circle booked as a failure for m1");
12589 assert_eq!(slot.unwrap().trace_id, "t2", "this turn is now remembered");
12590 }
12591}
12592
12593fn parse_inference_request(params: &Value) -> Result<car_inference::GenerateRequest, String> {
12597 let exact_model_id = match params.get("model_id") {
12598 None | Some(Value::Null) => None,
12599 Some(Value::String(model_id)) if !model_id.trim().is_empty() => Some(model_id.clone()),
12600 Some(_) => return Err("`model_id` must be a non-empty string".to_string()),
12601 };
12602 if exact_model_id.is_some() && params.get("model").is_some_and(|value| !value.is_null()) {
12603 return Err("`model` and `model_id` are mutually exclusive".to_string());
12604 }
12605
12606 let mut normalized = params.clone();
12607 if let Some(object) = normalized.as_object_mut() {
12608 object.remove("model_id");
12609 }
12610 let mut req: car_inference::GenerateRequest = typed_params(&normalized)?;
12611 if let Some(model_id) = exact_model_id {
12612 car_inference::pin_exact_model_id(&mut req, model_id)?;
12613 }
12614 if req.model.is_some() {
12615 req.params.strict_model = true;
12616 }
12617 Ok(req)
12618}
12619
12620#[cfg(test)]
12621mod inference_request_tests {
12622 use super::parse_inference_request;
12623 use serde_json::json;
12624
12625 #[test]
12626 fn explicit_model_is_strict() {
12627 let req = parse_inference_request(&json!({
12628 "prompt": "hello",
12629 "model": "openrouter/qwen/qwen3-coder-next",
12630 }))
12631 .expect("valid explicit-model request");
12632
12633 assert!(req.params.strict_model);
12634 }
12635
12636 #[test]
12637 fn explicit_model_overrides_requested_non_strict_mode() {
12638 let req = parse_inference_request(&json!({
12639 "prompt": "hello",
12640 "model": "openrouter/qwen/qwen3-coder-next",
12641 "params": { "strict_model": false },
12642 }))
12643 .expect("valid explicit-model request");
12644
12645 assert!(req.params.strict_model);
12646 }
12647
12648 #[test]
12649 fn exact_model_id_is_strict_and_does_not_alias() {
12650 let req = parse_inference_request(&json!({
12651 "prompt": "hello",
12652 "model_id": "openai/gpt-5.4:latest",
12653 "params": { "strict_model": false },
12654 }))
12655 .expect("valid exact-id request");
12656
12657 assert_eq!(
12658 car_inference::exact_pinned_model_id(&req),
12659 Some("openai/gpt-5.4:latest")
12660 );
12661 assert!(req.params.strict_model);
12662 }
12663
12664 #[test]
12665 fn legacy_model_and_exact_model_id_are_rejected_together() {
12666 let error = parse_inference_request(&json!({
12667 "prompt": "hello",
12668 "model": "GPT 5.4",
12669 "model_id": "openai/gpt-5.4:latest",
12670 }))
12671 .expect_err("ambiguous dual pin must fail");
12672
12673 assert!(error.contains("mutually exclusive"));
12674 }
12675
12676 #[test]
12677 fn model_less_requests_preserve_requested_strict_mode() {
12678 let loose = parse_inference_request(&json!({
12679 "prompt": "hello",
12680 "params": { "strict_model": false },
12681 }))
12682 .expect("valid adaptive request");
12683 let strict = parse_inference_request(&json!({
12684 "prompt": "hello",
12685 "params": { "strict_model": true },
12686 }))
12687 .expect("valid strict adaptive request");
12688
12689 assert!(!loose.params.strict_model);
12690 assert!(strict.params.strict_model);
12691 }
12692}
12693
12694fn inference_dispatch_error(e: &car_inference::InferenceError) -> String {
12709 match e {
12710 car_inference::InferenceError::ContentRefused { .. } => {
12711 format!("{} {e}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX)
12712 }
12713 car_inference::InferenceError::CatalogPreconditionMismatch { detail } => format!(
12714 "{} {detail}",
12715 car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX
12716 ),
12717 _ => e.to_string(),
12718 }
12719}
12720
12721fn stream_dispatch_error(detail: String) -> String {
12736 match car_inference::stream::content_refusal_tags(&detail) {
12737 Some(_) => format!("{} {detail}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
12738 None => detail,
12739 }
12740}
12741
12742async fn run_owned_inference_backend<F>(
12743 registry: Arc<crate::inference_control::InferenceRegistry>,
12744 inference_id: String,
12745 result_tx: tokio::sync::oneshot::Sender<Result<Value, String>>,
12746 work: F,
12747) where
12748 F: std::future::Future<Output = Result<Value, String>>,
12749{
12750 use futures::FutureExt;
12751 use std::panic::AssertUnwindSafe;
12752
12753 let result = match AssertUnwindSafe(work).catch_unwind().await {
12754 Ok(result) => result,
12755 Err(payload) => {
12756 let detail = payload
12757 .downcast_ref::<&str>()
12758 .map(|message| (*message).to_string())
12759 .or_else(|| payload.downcast_ref::<String>().cloned())
12760 .unwrap_or_else(|| "unknown panic payload".to_string());
12761 Err(format!("inference backend panicked: {detail}"))
12762 }
12763 };
12764 registry.backend_terminated(&inference_id);
12765 let _ = result_tx.send(result);
12766}
12767
12768async fn handle_infer(
12769 msg: &JsonRpcMessage,
12770 state: Arc<ServerState>,
12771 session: Arc<crate::session::ClientSession>,
12772) -> Result<Value, String> {
12773 reject_client_inference_id(&msg.params)?;
12774 let current_run = session.current_run_id.lock().await.clone();
12775 let request_id = msg.id.as_str().map(str::to_string);
12776 let (inference_id, mut terminal) = session
12777 .inference_control
12778 .begin_for_run(current_run, request_id)
12779 .map_err(|error| format!("cannot start inference: {error:?}"))?;
12780 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
12781 let task_msg = msg.clone();
12782 let task_state = state.clone();
12783 let task_session = session.clone();
12784 let task_registry = session.inference_control.clone();
12785 let task_id = inference_id.clone();
12786 let backend = tokio::spawn(run_owned_inference_backend(
12787 task_registry,
12788 task_id.clone(),
12789 result_tx,
12790 async move {
12791 car_inference::scope_inference_control_id(
12792 task_id.clone(),
12793 handle_infer_active(&task_msg, &task_state, &task_session, &task_id),
12794 )
12795 .await
12796 },
12797 ));
12798 let mut owner =
12799 attach_owned_inference_backend(session.inference_control.clone(), &inference_id, backend)
12800 .map_err(|error| format!("cannot own inference backend: {error:?}"))?;
12801 let exposes_control = session_has_capability(&session, car_proto::INFER_CANCEL_CAPABILITY)
12802 || session_has_capability(&session, car_proto::INFER_DEADLINE_CAPABILITY);
12803 if exposes_control {
12804 if let Err(error) = send_infer_started(&session, &msg.id, &inference_id).await {
12805 return Err(clean_up_failed_infer_started(
12806 &session.inference_control,
12807 &inference_id,
12808 error,
12809 ));
12810 }
12811 }
12812
12813 let result = tokio::select! {
12814 biased;
12815 result = &mut result_rx => match result {
12816 Ok(Ok(mut value)) => {
12817 if session.inference_control.complete(&inference_id) {
12818 if exposes_control {
12819 insert_inference_id(&mut value, &inference_id)?;
12820 }
12821 Ok(value)
12822 } else {
12823 Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12824 }
12825 }
12826 Ok(Err(error)) => {
12827 if session.inference_control.complete(&inference_id) {
12828 Err(error)
12829 } else {
12830 Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12831 }
12832 }
12833 Err(_) => Err("owned inference backend ended without a result".to_string()),
12834 },
12835 _ = terminal.changed() => {
12836 Ok(wait_terminal_control_value(terminal, &inference_id).await)
12837 }
12838 };
12839 car_inference::clear_remote_deadline(&inference_id);
12842 owner.complete_normally();
12843 result
12844}
12845
12846#[derive(Deserialize)]
12847#[serde(deny_unknown_fields)]
12848struct InferCancelParams {
12849 inference_id: String,
12850}
12851
12852#[derive(Deserialize)]
12853#[serde(deny_unknown_fields)]
12854struct InferDeadlineParams {
12855 inference_id: String,
12856 timeout_ms: u64,
12857}
12858
12859async fn exact_backend_termination_ack(inference_id: &str) -> bool {
12860 let Some(offload) = car_inference::current_local_offload() else {
12861 return false;
12862 };
12863 matches!(
12864 offload.terminate_inference(inference_id).await,
12865 car_inference::InferenceTerminationAck::Confirmed
12866 )
12867}
12868
12869async fn handle_infer_cancel(
12870 msg: &JsonRpcMessage,
12871 session: &crate::session::ClientSession,
12872) -> Result<Value, String> {
12873 if !session_has_capability(session, car_proto::INFER_CANCEL_CAPABILITY) {
12874 return Err(format!(
12875 "{} negotiate `{}` as a required or optional capability before calling `infer.cancel`",
12876 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12877 car_proto::INFER_CANCEL_CAPABILITY
12878 ));
12879 }
12880 let params: InferCancelParams = typed_params(&msg.params)?;
12881 if params.inference_id.is_empty() {
12882 return Err("`inference_id` must be a non-empty opaque string".into());
12883 }
12884 let _pending = session
12885 .inference_control
12886 .try_acquire_control()
12887 .map_err(|error| format!("infer.cancel control admission rejected: {error:?}"))?;
12888 let status = session
12889 .inference_control
12890 .control(
12891 ¶ms.inference_id,
12892 crate::inference_control::ControlCause::Cancel,
12893 exact_backend_termination_ack(¶ms.inference_id),
12894 )
12895 .await;
12896 serde_json::to_value(car_proto::InferenceControlResponse {
12897 inference_id: params.inference_id,
12898 status,
12899 })
12900 .map_err(|error| format!("serialize infer.cancel response: {error}"))
12901}
12902
12903async fn handle_infer_deadline(
12904 msg: &JsonRpcMessage,
12905 session: &crate::session::ClientSession,
12906) -> Result<Value, String> {
12907 if !session_has_capability(session, car_proto::INFER_DEADLINE_CAPABILITY) {
12908 return Err(format!(
12909 "{} negotiate `{}` as a required or optional capability before calling `infer.deadline`",
12910 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12911 car_proto::INFER_DEADLINE_CAPABILITY
12912 ));
12913 }
12914 let params: InferDeadlineParams = typed_params(&msg.params)?;
12915 if params.inference_id.is_empty() {
12916 return Err("`inference_id` must be a non-empty opaque string".into());
12917 }
12918 if params.timeout_ms == 0
12919 || params.timeout_ms > crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12920 {
12921 return Err(format!(
12922 "`timeout_ms` must be between 1 and {}",
12923 crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12924 ));
12925 }
12926
12927 let ack_id = params.inference_id.clone();
12928 let status = match session
12929 .inference_control
12930 .schedule_deadline(
12931 ¶ms.inference_id,
12932 std::time::Duration::from_millis(params.timeout_ms),
12933 || exact_backend_termination_ack(&ack_id),
12934 )
12935 .await
12936 {
12937 Ok(status) => {
12938 car_inference::set_remote_deadline(
12944 ¶ms.inference_id,
12945 std::time::Duration::from_millis(params.timeout_ms),
12946 );
12947 status
12948 }
12949 Err(crate::inference_control::DeadlineReservationError::Terminal(status)) => {
12950 return serde_json::to_value(car_proto::InferenceControlResponse {
12951 inference_id: params.inference_id,
12952 status,
12953 })
12954 .map_err(|error| format!("serialize infer.deadline response: {error}"));
12955 }
12956 Err(crate::inference_control::DeadlineReservationError::Registry(
12957 crate::inference_control::RegistryError::PendingControlLimitReached,
12958 )) => {
12959 return Err(
12960 "infer.deadline control admission rejected: PendingControlLimitReached".into(),
12961 );
12962 }
12963 Err(crate::inference_control::DeadlineReservationError::Registry(error)) => {
12964 return Err(format!("infer.deadline rejected: {error:?}"));
12965 }
12966 };
12967 serde_json::to_value(car_proto::InferenceControlResponse {
12968 inference_id: params.inference_id,
12969 status,
12970 })
12971 .map_err(|error| format!("serialize infer.deadline response: {error}"))
12972}
12973
12974fn reject_client_inference_id(params: &Value) -> Result<(), String> {
12975 if params.get("inference_id").is_some() {
12976 return Err("`inference_id` is server-assigned and cannot be supplied or reused".into());
12977 }
12978 Ok(())
12979}
12980
12981#[derive(Debug)]
12982enum InferStartedSendError {
12983 BeforeAdmission(String),
12984 AfterAdmission(String),
12985}
12986
12987impl InferStartedSendError {
12988 fn may_have_been_admitted(&self) -> bool {
12989 matches!(self, Self::AfterAdmission(_))
12990 }
12991}
12992
12993impl std::fmt::Display for InferStartedSendError {
12994 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12995 match self {
12996 Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
12997 formatter.write_str(message)
12998 }
12999 }
13000 }
13001}
13002
13003fn clean_up_failed_infer_started(
13004 registry: &crate::inference_control::InferenceRegistry,
13005 inference_id: &str,
13006 error: InferStartedSendError,
13007) -> String {
13008 if error.may_have_been_admitted() {
13009 registry.abort_after_started_admission(inference_id);
13010 } else {
13011 registry.abandon(inference_id);
13012 }
13013 error.to_string()
13014}
13015
13016struct InferenceOwnerGuard {
13017 registry: Arc<crate::inference_control::InferenceRegistry>,
13018 inference_id: String,
13019 completed_normally: bool,
13020}
13021
13022impl InferenceOwnerGuard {
13023 fn new(
13024 registry: Arc<crate::inference_control::InferenceRegistry>,
13025 inference_id: String,
13026 ) -> Self {
13027 Self {
13028 registry,
13029 inference_id,
13030 completed_normally: false,
13031 }
13032 }
13033
13034 fn complete_normally(&mut self) {
13035 self.completed_normally = true;
13036 }
13037}
13038
13039impl Drop for InferenceOwnerGuard {
13040 fn drop(&mut self) {
13041 if !self.completed_normally {
13042 self.registry.abort_owned_backend(&self.inference_id);
13043 }
13044 }
13045}
13046
13047fn attach_owned_inference_backend(
13048 registry: Arc<crate::inference_control::InferenceRegistry>,
13049 inference_id: &str,
13050 backend: tokio::task::JoinHandle<()>,
13051) -> Result<InferenceOwnerGuard, crate::inference_control::RegistryError> {
13052 registry.attach_backend(inference_id, backend)?;
13053 Ok(InferenceOwnerGuard::new(registry, inference_id.to_string()))
13054}
13055
13056async fn send_infer_started(
13057 session: &crate::session::ClientSession,
13058 request_id: &Value,
13059 inference_id: &str,
13060) -> Result<(), InferStartedSendError> {
13061 use futures::SinkExt;
13062 use tokio_tungstenite::tungstenite::Message;
13063
13064 let frame = serde_json::json!({
13065 "jsonrpc": "2.0",
13066 "method": "infer.started",
13067 "params": {
13068 "request_id": request_id,
13069 "inference_id": inference_id,
13070 },
13071 });
13072 let text = serde_json::to_string(&frame)
13073 .map_err(|error| InferStartedSendError::BeforeAdmission(error.to_string()))?;
13074 let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13075 let mut write = tokio::time::timeout_at(deadline, session.channel.write.lock())
13076 .await
13077 .map_err(|_| {
13078 InferStartedSendError::BeforeAdmission(format!(
13079 "send infer.started timed out before admission after {}s",
13080 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13081 ))
13082 })?;
13083
13084 match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13085 Ok(Ok(())) => {}
13086 Ok(Err(error)) => {
13087 return Err(InferStartedSendError::AfterAdmission(format!(
13091 "send infer.started admission failed: {error}"
13092 )));
13093 }
13094 Err(_) => {
13095 return Err(InferStartedSendError::BeforeAdmission(format!(
13098 "send infer.started timed out before admission after {}s",
13099 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13100 )));
13101 }
13102 }
13103
13104 match tokio::time::timeout_at(deadline, write.flush()).await {
13105 Ok(Ok(())) => Ok(()),
13106 Ok(Err(error)) => Err(InferStartedSendError::AfterAdmission(format!(
13107 "flush infer.started: {error}"
13108 ))),
13109 Err(_) => Err(InferStartedSendError::AfterAdmission(format!(
13110 "flush infer.started timed out after {}s; inference is terminal",
13111 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13112 ))),
13113 }
13114}
13115
13116#[cfg(test)]
13117mod inference_backend_reliability_tests {
13118 use super::{
13119 attach_owned_inference_backend, clean_up_failed_infer_started, run_owned_inference_backend,
13120 send_infer_started, send_inference_notification_if_active, KEEPALIVE_WRITE_TIMEOUT,
13121 };
13122 use crate::inference_control::{InferenceRegistry, RegistryConfig};
13123 use crate::session::{ServerState, WsChannel, WsSink};
13124 use futures::{Sink, SinkExt};
13125 use serde_json::json;
13126 use std::collections::HashMap;
13127 use std::pin::Pin;
13128 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13129 use std::sync::Arc;
13130 use std::task::{Context, Poll};
13131 use std::time::Duration;
13132 use tokio::sync::Mutex;
13133 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
13134
13135 struct FlushStallingSink {
13136 pending: Arc<std::sync::Mutex<Vec<Message>>>,
13137 visible: Arc<std::sync::Mutex<Vec<Message>>>,
13138 flush_ready: Arc<AtomicBool>,
13139 }
13140
13141 impl Sink<Message> for FlushStallingSink {
13142 type Error = WsError;
13143
13144 fn poll_ready(
13145 self: Pin<&mut Self>,
13146 _context: &mut Context<'_>,
13147 ) -> Poll<Result<(), Self::Error>> {
13148 Poll::Ready(Ok(()))
13149 }
13150
13151 fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
13152 self.pending
13153 .lock()
13154 .expect("pending frames lock")
13155 .push(message);
13156 Ok(())
13157 }
13158
13159 fn poll_flush(
13160 self: Pin<&mut Self>,
13161 _context: &mut Context<'_>,
13162 ) -> Poll<Result<(), Self::Error>> {
13163 if !self.flush_ready.load(Ordering::SeqCst) {
13164 return Poll::Pending;
13165 }
13166 let mut pending = self.pending.lock().expect("pending frames lock");
13167 self.visible
13168 .lock()
13169 .expect("visible frames lock")
13170 .extend(pending.drain(..));
13171 Poll::Ready(Ok(()))
13172 }
13173
13174 fn poll_close(
13175 self: Pin<&mut Self>,
13176 context: &mut Context<'_>,
13177 ) -> Poll<Result<(), Self::Error>> {
13178 self.poll_flush(context)
13179 }
13180 }
13181
13182 fn one_slot_registry() -> Arc<InferenceRegistry> {
13183 Arc::new(InferenceRegistry::with_config(RegistryConfig {
13184 max_active: 1,
13185 max_tombstones: 1,
13186 max_pending_controls: 1,
13187 max_orphans: 1,
13188 tombstone_ttl: Duration::from_secs(300),
13189 termination_ack_timeout: Duration::from_millis(20),
13190 }))
13191 }
13192
13193 #[tokio::test]
13194 async fn panicking_backend_publishes_terminal_error_and_releases_registry_capacity() {
13195 let registry = one_slot_registry();
13196 registry
13197 .begin_with_id("panic-stream")
13198 .expect("register panicking stream");
13199 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
13200 let task = tokio::spawn(run_owned_inference_backend(
13201 registry.clone(),
13202 "panic-stream".to_string(),
13203 result_tx,
13204 async {
13205 panic!("fixture stream backend panic");
13206 #[allow(unreachable_code)]
13207 Ok(json!({}))
13208 },
13209 ));
13210 registry
13211 .attach_backend("panic-stream", task)
13212 .expect("attach panicking stream");
13213
13214 let error = result_rx
13215 .await
13216 .expect("panic must be converted into a published result")
13217 .expect_err("panic must be a terminal error");
13218 assert!(error.contains("backend panicked"), "{error}");
13219 assert!(
13220 registry.complete("panic-stream"),
13221 "client-visible failure must terminalize the active entry"
13222 );
13223 assert_eq!(registry.counts().0, 0);
13224 registry
13225 .begin_with_id("replacement-stream")
13226 .expect("panicking backend must not consume the one session slot forever");
13227 }
13228
13229 #[tokio::test]
13230 async fn panic_before_attach_and_receiver_drop_still_releases_backend_ownership() {
13231 let registry = one_slot_registry();
13232 registry
13233 .begin_with_id("panic-before-attach")
13234 .expect("register panicking backend");
13235 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
13236 drop(result_rx);
13237 run_owned_inference_backend(
13238 registry.clone(),
13239 "panic-before-attach".to_string(),
13240 result_tx,
13241 async {
13242 panic!("panic before JoinHandle attachment");
13243 #[allow(unreachable_code)]
13244 Ok(json!({}))
13245 },
13246 )
13247 .await;
13248 registry
13249 .attach_backend("panic-before-attach", tokio::spawn(async {}))
13250 .expect("late attachment observes prior termination");
13251 assert!(registry.complete("panic-before-attach"));
13252 assert_eq!(registry.counts().0, 0);
13253 registry
13254 .begin_with_id("after-panic-before-attach")
13255 .expect("panic before attachment must not strand capacity");
13256 }
13257
13258 #[tokio::test(start_paused = true)]
13259 async fn infer_started_write_timeout_cleans_up_instead_of_waiting_on_the_socket_forever() {
13260 let tmp = tempfile::TempDir::new().expect("temporary server state");
13261 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13262 let channel = Arc::new(WsChannel::test_stub());
13263 let session = state
13264 .create_session("stalled-infer-start", channel.clone())
13265 .await
13266 .expect("create test session");
13267 let registry = session.inference_control.clone();
13268 registry
13269 .begin_with_id("stalled-start")
13270 .expect("register pending inference");
13271 let backend = tokio::spawn(std::future::pending::<()>());
13272 registry
13273 .attach_backend("stalled-start", backend)
13274 .expect("attach pending backend");
13275
13276 let _held_write = channel.write.lock().await;
13277 let task_session = session.clone();
13278 let send = tokio::spawn(async move {
13279 let result = send_infer_started(&task_session, &json!(7), "stalled-start").await;
13280 if result.is_err() {
13281 task_session.inference_control.abandon("stalled-start");
13282 }
13283 result
13284 });
13285 tokio::task::yield_now().await;
13286 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13287
13288 let error = tokio::time::timeout(Duration::from_millis(1), send)
13289 .await
13290 .expect("infer.started must carry its own bounded-write policy")
13291 .expect("send task joins")
13292 .expect_err("held write lock must time out");
13293 assert!(error.to_string().contains("timed out"), "{error}");
13294 assert_eq!(registry.counts(), (0, 0), "failed start must be removed");
13295 }
13296
13297 #[tokio::test(start_paused = true)]
13298 async fn infer_started_flush_timeout_keeps_any_later_visible_id_terminally_queryable() {
13299 let tmp = tempfile::TempDir::new().expect("temporary server state");
13300 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13301 let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13302 let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13303 let flush_ready = Arc::new(AtomicBool::new(false));
13304 let sink: WsSink = Box::pin(FlushStallingSink {
13305 pending: pending.clone(),
13306 visible: visible.clone(),
13307 flush_ready: flush_ready.clone(),
13308 });
13309 let channel = Arc::new(WsChannel {
13310 write: Mutex::new(sink),
13311 pending: Mutex::new(HashMap::new()),
13312 active_actions: Mutex::new(HashMap::new()),
13313 next_id: AtomicU64::new(0),
13314 });
13315 let session = state
13316 .create_session("partial-infer-start", channel.clone())
13317 .await
13318 .expect("create test session");
13319 let registry = session.inference_control.clone();
13320 registry
13321 .begin_with_id("partially-admitted-start")
13322 .expect("register pending inference");
13323 registry
13324 .attach_backend(
13325 "partially-admitted-start",
13326 tokio::spawn(std::future::pending::<()>()),
13327 )
13328 .expect("attach pending backend");
13329
13330 let task_session = session.clone();
13331 let send = tokio::spawn(async move {
13332 let error = send_infer_started(&task_session, &json!(8), "partially-admitted-start")
13333 .await
13334 .expect_err("flush must stall");
13335 clean_up_failed_infer_started(
13336 &task_session.inference_control,
13337 "partially-admitted-start",
13338 error,
13339 )
13340 });
13341 tokio::task::yield_now().await;
13342 assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13343 assert!(visible.lock().expect("visible frames lock").is_empty());
13344
13345 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13346 let error = send.await.expect("send task joins");
13347 assert!(error.contains("terminal"), "{error}");
13348 assert_eq!(registry.counts(), (0, 1));
13349 assert!(
13350 registry
13351 .terminal_outcome("partially-admitted-start")
13352 .is_some(),
13353 "a queued infer.started ID must remain queryable"
13354 );
13355
13356 flush_ready.store(true, Ordering::SeqCst);
13357 channel
13358 .write
13359 .lock()
13360 .await
13361 .send(Message::Text(
13362 json!({"jsonrpc":"2.0","id":8,"error":{"code":-32603}})
13363 .to_string()
13364 .into(),
13365 ))
13366 .await
13367 .expect("later dispatcher response flushes queued frames");
13368 let visible = visible.lock().expect("visible frames lock");
13369 assert_eq!(visible.len(), 2);
13370 let started: serde_json::Value = match &visible[0] {
13371 Message::Text(text) => serde_json::from_str(text).expect("started JSON"),
13372 other => panic!("expected text frame, got {other:?}"),
13373 };
13374 assert_eq!(
13375 started["params"]["inference_id"],
13376 "partially-admitted-start"
13377 );
13378 assert!(
13379 registry
13380 .terminal_outcome("partially-admitted-start")
13381 .is_some(),
13382 "later visibility must not turn the admitted ID into Unknown"
13383 );
13384 }
13385
13386 #[tokio::test(start_paused = true)]
13387 async fn notification_write_budget_reports_whether_a_frame_was_admitted() {
13388 let registry = one_slot_registry();
13389 registry
13390 .begin_with_id("notification-lock-timeout")
13391 .expect("register lock-stalled notification");
13392 let channel = Arc::new(WsChannel::test_stub());
13393 let held_write = channel.write.lock().await;
13394 let task_channel = channel.clone();
13395 let task_registry = registry.clone();
13396 let lock_timeout = tokio::spawn(async move {
13397 send_inference_notification_if_active(
13398 &task_channel,
13399 &task_registry,
13400 "notification-lock-timeout",
13401 json!({"method":"infer.progress"}).to_string(),
13402 )
13403 .await
13404 .expect_err("held writer lock must time out")
13405 });
13406 tokio::task::yield_now().await;
13407 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13408 let error = lock_timeout.await.expect("lock-timeout task joins");
13409 assert!(!error.may_have_been_admitted(), "{error}");
13410 assert!(
13411 error.to_string().contains("write lock timed out"),
13412 "{error}"
13413 );
13414 drop(held_write);
13415 registry.abandon("notification-lock-timeout");
13416
13417 let registry = one_slot_registry();
13418 registry
13419 .begin_with_id("progress-flush-timeout")
13420 .expect("register flush-stalled notification");
13421 let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13422 let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13423 let flush_ready = Arc::new(AtomicBool::new(false));
13424 let sink: WsSink = Box::pin(FlushStallingSink {
13425 pending: pending.clone(),
13426 visible: visible.clone(),
13427 flush_ready,
13428 });
13429 let channel = Arc::new(WsChannel {
13430 write: Mutex::new(sink),
13431 pending: Mutex::new(HashMap::new()),
13432 active_actions: Mutex::new(HashMap::new()),
13433 next_id: AtomicU64::new(0),
13434 });
13435 let task_registry = registry.clone();
13436 let flush_timeout = tokio::spawn(async move {
13437 send_inference_notification_if_active(
13438 &channel,
13439 &task_registry,
13440 "progress-flush-timeout",
13441 json!({"method":"infer.progress"}).to_string(),
13442 )
13443 .await
13444 .expect_err("flush-stalled notification must time out")
13445 });
13446 tokio::task::yield_now().await;
13447 assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13448 assert!(visible.lock().expect("visible frames lock").is_empty());
13449 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13450 let error = flush_timeout.await.expect("flush-timeout task joins");
13451 assert!(error.may_have_been_admitted(), "{error}");
13452 assert!(error.to_string().contains("flush timed out"), "{error}");
13453 registry.abort_owned_backend("progress-flush-timeout");
13454 assert!(registry
13455 .terminal_outcome("progress-flush-timeout")
13456 .is_some());
13457 }
13458
13459 #[tokio::test]
13460 async fn dropping_an_attached_owner_aborts_infer_backend() {
13461 for method in ["infer"] {
13462 let registry = one_slot_registry();
13463 let inference_id = format!("dropped-owner-{method}");
13464 registry
13465 .begin_with_id(&inference_id)
13466 .expect("register owned backend");
13467 let admission = Arc::new(tokio::sync::Semaphore::new(1));
13468 let task_admission = admission.clone();
13469 let backend = tokio::spawn(async move {
13470 let _permit = task_admission
13471 .acquire_owned()
13472 .await
13473 .expect("admission open");
13474 std::future::pending::<()>().await;
13475 });
13476 let owner_guard =
13477 attach_owned_inference_backend(registry.clone(), &inference_id, backend)
13478 .expect("attach through the shared handler ownership path");
13479 let owner = tokio::spawn(async move {
13480 let _owner = owner_guard;
13481 std::future::pending::<()>().await;
13482 });
13483 tokio::task::yield_now().await;
13484 assert_eq!(admission.available_permits(), 0);
13485
13486 owner.abort();
13487 assert!(owner
13488 .await
13489 .expect_err("owner future is cancelled")
13490 .is_cancelled());
13491 tokio::task::yield_now().await;
13492
13493 assert_eq!(admission.available_permits(), 1, "{method}");
13494 assert_eq!(registry.counts(), (0, 1), "{method}");
13495 assert_eq!(registry.orphan_count(), 0, "{method}");
13496 assert!(
13497 registry.terminal_outcome(&inference_id).is_some(),
13498 "{method}"
13499 );
13500 registry
13501 .begin_with_id(&format!("replacement-{method}"))
13502 .expect("owner drop releases per-session capacity");
13503 registry.abort_all();
13504 }
13505 }
13506}
13507
13508fn insert_inference_id(value: &mut Value, inference_id: &str) -> Result<(), String> {
13509 value
13510 .as_object_mut()
13511 .ok_or_else(|| "inference result must be a JSON object".to_string())?
13512 .insert(
13513 "inference_id".to_string(),
13514 Value::String(inference_id.to_string()),
13515 );
13516 Ok(())
13517}
13518
13519async fn send_inference_notification_if_active(
13525 channel: &WsChannel,
13526 registry: &crate::inference_control::InferenceRegistry,
13527 inference_id: &str,
13528 text: String,
13529) -> Result<bool, InferenceNotificationSendError> {
13530 let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13531 let mut write = tokio::time::timeout_at(deadline, channel.write.lock())
13532 .await
13533 .map_err(|_| {
13534 InferenceNotificationSendError::BeforeAdmission(format!(
13535 "notification write lock timed out after {}s",
13536 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13537 ))
13538 })?;
13539 if !registry.is_active(inference_id) {
13540 return Ok(false);
13541 }
13542
13543 match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13544 Ok(Ok(())) => {}
13545 Ok(Err(error)) => {
13546 return Err(InferenceNotificationSendError::AfterAdmission(format!(
13547 "notification admission failed: {error}"
13548 )));
13549 }
13550 Err(_) => {
13551 return Err(InferenceNotificationSendError::BeforeAdmission(format!(
13552 "notification admission timed out after {}s",
13553 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13554 )));
13555 }
13556 }
13557
13558 match tokio::time::timeout_at(deadline, write.flush()).await {
13559 Ok(Ok(())) => Ok(true),
13560 Ok(Err(error)) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13561 "notification flush failed: {error}"
13562 ))),
13563 Err(_) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13564 "notification flush timed out after {}s",
13565 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13566 ))),
13567 }
13568}
13569
13570#[derive(Debug)]
13571enum InferenceNotificationSendError {
13572 BeforeAdmission(String),
13573 AfterAdmission(String),
13574}
13575
13576impl InferenceNotificationSendError {
13577 #[cfg(test)]
13578 fn may_have_been_admitted(&self) -> bool {
13579 matches!(self, Self::AfterAdmission(_))
13580 }
13581}
13582
13583impl std::fmt::Display for InferenceNotificationSendError {
13584 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13585 match self {
13586 Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
13587 formatter.write_str(message)
13588 }
13589 }
13590 }
13591}
13592
13593#[cfg(test)]
13594mod inference_notification_terminal_boundary_tests {
13595 use super::send_inference_notification_if_active;
13596 use crate::inference_control::{ControlCause, InferenceRegistry};
13597 use crate::session::WsChannel;
13598 use car_proto::InferenceControlStatus;
13599 use futures::SinkExt;
13600 use std::sync::Arc;
13601 use tokio::sync::oneshot;
13602 use tokio_tungstenite::tungstenite::Message;
13603
13604 #[tokio::test]
13605 async fn terminal_response_is_a_hard_boundary_for_progress_frames() {
13606 for (inference_id, cause, expected, wire_status) in [
13607 (
13608 "inf_terminal_boundary_cancel",
13609 ControlCause::Cancel,
13610 InferenceControlStatus::CancelledConfirmed,
13611 "cancelled_confirmed",
13612 ),
13613 (
13614 "inf_terminal_boundary_deadline",
13615 ControlCause::Deadline,
13616 InferenceControlStatus::DeadlineExceededConfirmed,
13617 "deadline_exceeded_confirmed",
13618 ),
13619 ] {
13620 let registry = Arc::new(InferenceRegistry::default());
13621 registry
13622 .begin_with_id(inference_id)
13623 .expect("register active inference");
13624 let (channel, captured) = WsChannel::test_capture();
13625 let channel = Arc::new(channel);
13626
13627 let mut boundary = channel.write.lock().await;
13631 assert!(registry.is_active(inference_id), "event precheck is active");
13632
13633 let frame = serde_json::json!({
13634 "jsonrpc": "2.0",
13635 "method": "infer.progress",
13636 "params": {"id": 1},
13637 });
13638 let channel = channel.clone();
13639 let notification_registry = registry.clone();
13640 let text = serde_json::to_string(&frame).expect("serialize notification");
13641 let (started_tx, started_rx) = oneshot::channel();
13642 let pending = tokio::spawn(async move {
13643 started_tx.send(()).expect("signal event receipt");
13644 send_inference_notification_if_active(
13645 &channel,
13646 ¬ification_registry,
13647 inference_id,
13648 text,
13649 )
13650 .await
13651 });
13652 started_rx.await.expect("notification task reached barrier");
13653
13654 let terminal = registry.control(inference_id, cause, async { true }).await;
13655 assert_eq!(terminal, expected);
13656 boundary
13657 .send(Message::Text(
13658 serde_json::json!({
13659 "jsonrpc": "2.0",
13660 "id": "terminal-1",
13661 "result": {
13662 "inference_id": inference_id,
13663 "status": wire_status,
13664 },
13665 })
13666 .to_string()
13667 .into(),
13668 ))
13669 .await
13670 .expect("write terminal response");
13671 drop(boundary);
13672
13673 assert!(
13674 !pending
13675 .await
13676 .expect("notification task")
13677 .expect("notification send"),
13678 "terminal notification must be suppressed"
13679 );
13680
13681 let frames = captured.lock().expect("capture lock");
13682 assert_eq!(frames.len(), 1, "no frame may follow terminal response");
13683 assert!(frames[0].contains(wire_status));
13684 }
13685 eprintln!(
13686 "C3_TERMINAL_BOUNDARY_SHARED=terminals=cancelled_confirmed+deadline_exceeded_confirmed,late_progress=suppressed,frames=1_each"
13687 );
13688 }
13689}
13690
13691async fn wait_terminal_control_value(
13692 mut terminal: tokio::sync::watch::Receiver<Option<crate::inference_control::TerminalOutcome>>,
13693 inference_id: &str,
13694) -> Value {
13695 if terminal.borrow().is_none() {
13696 let _ = terminal.changed().await;
13697 }
13698 let status = match *terminal.borrow() {
13699 Some(crate::inference_control::TerminalOutcome::Controlled(status)) => status,
13700 _ => car_proto::InferenceControlStatus::AlreadyTerminal,
13701 };
13702 serde_json::to_value(car_proto::InferenceControlResponse {
13703 inference_id: inference_id.to_string(),
13704 status,
13705 })
13706 .unwrap_or_else(|_| {
13707 serde_json::json!({
13708 "inference_id": inference_id,
13709 "status": "already_terminal",
13710 })
13711 })
13712}
13713
13714async fn handle_infer_active(
13715 msg: &JsonRpcMessage,
13716 state: &ServerState,
13717 session: &crate::session::ClientSession,
13718 inference_id: &str,
13719) -> Result<Value, String> {
13720 use futures::FutureExt;
13721 use std::panic::AssertUnwindSafe;
13722 use std::time::Duration;
13723
13724 let engine = get_inference_engine(state);
13725 let mut req = parse_inference_request(&msg.params)?;
13726
13727 if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13729 let memgine_arc = session.effective_memgine().await;
13732 let mut memgine = memgine_arc.lock().await;
13733 let split = memgine.build_context_split_for_model(cq, None);
13739 if !split.full.is_empty() {
13740 req.context_stable_prefix = split.stable_prefix();
13741 req.context = Some(split.full);
13742 }
13743 }
13744 req.caller = Some(session.client_id.clone());
13748 maybe_apply_proactive_memory(msg, session, &mut req).await?;
13749
13750 if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13756 {
13757 req.intent.get_or_insert_with(Default::default).high_stakes = true;
13758 }
13759
13760 let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13764
13765 const INFER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
13776 struct HeartbeatGuard(tokio::task::JoinHandle<()>);
13777 impl Drop for HeartbeatGuard {
13778 fn drop(&mut self) {
13779 self.0.abort();
13780 }
13781 }
13782 let _heartbeat = {
13783 let channel = session.channel.clone();
13784 let request_id = msg.id.clone();
13785 let registry = session.inference_control.clone();
13786 let inference_id = inference_id.to_string();
13787 HeartbeatGuard(tokio::spawn(async move {
13788 let mut ticker = tokio::time::interval(INFER_HEARTBEAT_INTERVAL);
13789 ticker.tick().await; loop {
13791 ticker.tick().await;
13792 let notif = serde_json::json!({
13793 "jsonrpc": "2.0",
13794 "method": "infer.progress",
13795 "params": { "id": request_id },
13796 });
13797 let Ok(text) = serde_json::to_string(¬if) else {
13798 break;
13799 };
13800 match send_inference_notification_if_active(
13801 &channel,
13802 ®istry,
13803 &inference_id,
13804 text,
13805 )
13806 .await
13807 {
13808 Ok(true) => {}
13809 Ok(false) | Err(_) => break,
13810 }
13811 }
13812 }))
13813 };
13814
13815 let does_local_compute =
13830 match car_inference::exact_pinned_model_id(&req).or(req.model.as_deref()) {
13831 Some(pinned) => !engine
13832 .registered_schema(pinned)
13833 .map(|s| s.is_remote())
13834 .unwrap_or(false),
13835 None => true,
13836 };
13837 let _permit = state.admission.acquire_for(does_local_compute).await;
13838
13839 let infer_timeout = inference_total_timeout();
13860 let result = match tokio::time::timeout(
13861 infer_timeout,
13862 AssertUnwindSafe(engine.generate_tracked(req)).catch_unwind(),
13863 )
13864 .await
13865 {
13866 Ok(Ok(r)) => r.map_err(|e| inference_dispatch_error(&e))?,
13867 Ok(Err(panic)) => {
13868 let detail = panic
13869 .downcast_ref::<&str>()
13870 .map(|s| (*s).to_string())
13871 .or_else(|| panic.downcast_ref::<String>().cloned())
13872 .unwrap_or_else(|| "unknown panic".to_string());
13873 return Err(format!(
13874 "inference failed (internal panic): {detail}. If this is a CUDA build, \
13875 ensure the CUDA toolkit `bin` directory is on PATH so cudarc can load \
13876 cublas/cudart."
13877 ));
13878 }
13879 Err(_) => {
13880 return Err(format!(
13881 "inference timed out after {}s with no result — the compute backend may \
13882 have failed to initialize (e.g. a CUDA/cuBLAS library load error). \
13883 Set CAR_INFER_TIMEOUT_SECS to adjust the limit.",
13884 infer_timeout.as_secs()
13885 ));
13886 }
13887 };
13888
13889 if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13897 let mut slot = session.last_chat_turn.lock().await;
13898 let tracker_arc = engine.outcome_tracker();
13899 let mut tracker = tracker_arc.write().await;
13900 let tally = score_and_remember_chat_turn(
13901 &mut slot,
13902 &mut tracker,
13903 user_text,
13904 &result.text,
13905 &result.trace_id,
13906 &result.model_used,
13907 );
13908 if tally.submitted > 0 {
13909 tracing::debug!(
13910 submitted = tally.submitted,
13911 resolved = tally.resolved,
13912 "conversation-outcome recorded for prior chat turn"
13913 );
13914 }
13915 }
13916
13917 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13918}
13919
13920async fn handle_image_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13950 let engine = get_inference_engine(state);
13951 let req: car_inference::GenerateImageRequest = typed_params(&msg.params)?;
13952 let _permit = state.admission.acquire().await;
13955 let result = engine
13956 .generate_image(req)
13957 .await
13958 .map_err(|e| e.to_string())?;
13959 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13960}
13961
13962async fn handle_video_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13963 let engine = get_inference_engine(state);
13964 let req: car_inference::GenerateVideoRequest = typed_params(&msg.params)?;
13965 let _permit = state.admission.acquire().await;
13966 let result = engine
13967 .generate_video(req)
13968 .await
13969 .map_err(|e| e.to_string())?;
13970 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13971}
13972
13973async fn handle_infer_stream(
13974 msg: &JsonRpcMessage,
13975 session: &crate::session::ClientSession,
13976 state: &ServerState,
13977) -> Result<Value, String> {
13978 let engine = get_inference_engine(state);
13979 let mut req = parse_inference_request(&msg.params)?;
13980
13981 if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13984 let memgine_arc = session.effective_memgine().await;
13987 let mut memgine = memgine_arc.lock().await;
13988 let split = memgine.build_context_split_for_model(cq, None);
13994 if !split.full.is_empty() {
13995 req.context_stable_prefix = split.stable_prefix();
13996 req.context = Some(split.full);
13997 }
13998 }
13999 req.caller = Some(session.client_id.clone());
14003 maybe_apply_proactive_memory(msg, session, &mut req).await?;
14004
14005 if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
14008 {
14009 req.intent.get_or_insert_with(Default::default).high_stakes = true;
14010 }
14011
14012 let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
14016
14017 let _permit = state.admission.acquire().await;
14018 let mut handle = engine
14025 .generate_tracked_stream(req)
14026 .await
14027 .map_err(|e| inference_dispatch_error(&e))?;
14028
14029 let mut accumulator = car_inference::StreamAccumulator::default();
14030 let mut stream_error: Option<String> = None;
14031 let mut saw_done = false;
14032 let request_id = msg.id.clone();
14033
14034 while let Some(event) = handle.events.recv().await {
14035 let event_payload = match &event {
14036 car_inference::StreamEvent::TextDelta(text) => {
14037 serde_json::json!({"type": "text", "data": text})
14038 }
14039 car_inference::StreamEvent::ToolCallStart { name, index, .. } => {
14040 serde_json::json!({"type": "tool_start", "name": name, "index": index})
14041 }
14042 car_inference::StreamEvent::ToolCallDelta {
14043 index,
14044 arguments_delta,
14045 } => serde_json::json!({
14046 "type": "tool_delta",
14047 "index": index,
14048 "data": arguments_delta,
14049 }),
14050 car_inference::StreamEvent::Usage {
14051 input_tokens,
14052 output_tokens,
14053 cache_read_input_tokens,
14054 cache_creation_input_tokens,
14055 } => serde_json::json!({
14056 "type": "usage",
14057 "input_tokens": input_tokens,
14058 "output_tokens": output_tokens,
14059 "cache_read_input_tokens": cache_read_input_tokens,
14060 "cache_creation_input_tokens": cache_creation_input_tokens,
14061 }),
14062 car_inference::StreamEvent::StopReason(reason) => {
14063 serde_json::json!({"type": "stop_reason", "data": reason})
14064 }
14065 car_inference::StreamEvent::ProviderOutputItem(item) => {
14066 serde_json::json!({"type": "provider_output_item", "item": item})
14067 }
14068 car_inference::StreamEvent::Error(message) => {
14069 stream_error = Some(message.clone());
14070 serde_json::json!({"type": "error", "message": message})
14071 }
14072 car_inference::StreamEvent::Done { .. } => {
14077 saw_done = true;
14078 accumulator.push(&event);
14079 continue;
14080 }
14081 };
14082
14083 let notif = serde_json::json!({
14084 "jsonrpc": "2.0",
14085 "method": "inference.stream.event",
14086 "params": {
14087 "request_id": request_id,
14088 "event": event_payload,
14089 },
14090 });
14091 if let Ok(text) = serde_json::to_string(¬if) {
14092 let _ = session
14093 .channel
14094 .write
14095 .lock()
14096 .await
14097 .send(Message::Text(text.into()))
14098 .await;
14099 }
14100 accumulator.push(&event);
14101 }
14102
14103 let (text, tool_calls, usage, stop_reason) = accumulator.finish_with_usage();
14104
14105 if let Some(error) = stream_error {
14106 return Err(stream_dispatch_error(error));
14111 }
14112 if !saw_done {
14113 return Err("stream ended without positive completion".to_string());
14114 }
14115
14116 if let Some(u) = &usage {
14124 session.runtime.log.lock().await.append_metered(
14125 car_eventlog::EventKind::InferenceMetered,
14126 None,
14127 None,
14128 HashMap::new(),
14129 car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None),
14130 );
14131 }
14132
14133 if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
14144 let mut slot = session.last_chat_turn.lock().await;
14145 let tracker_arc = engine.outcome_tracker();
14146 let mut tracker = tracker_arc.write().await;
14147 let tally = score_and_remember_chat_turn(
14148 &mut slot,
14149 &mut tracker,
14150 user_text,
14151 &text,
14152 &handle.trace_id,
14153 &handle.model_used,
14154 );
14155 if tally.submitted > 0 {
14156 tracing::debug!(
14157 submitted = tally.submitted,
14158 resolved = tally.resolved,
14159 "conversation-outcome recorded for prior chat turn (streaming)"
14160 );
14161 }
14162 }
14163
14164 Ok(serde_json::json!({
14165 "text": text,
14166 "tool_calls": tool_calls,
14167 "usage": usage,
14168 "stop_reason": stop_reason,
14169 "trace_id": handle.trace_id,
14172 "model_used": handle.model_used,
14173 }))
14174}
14175
14176async fn handle_embed(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14177 let engine = get_inference_engine(state);
14178 let req: car_inference::EmbedRequest = typed_params(&msg.params)?;
14179 let _permit = state.admission.acquire().await;
14183 let result = engine.embed(req).await.map_err(|e| e.to_string())?;
14184 Ok(serde_json::json!({"embeddings": result}))
14185}
14186
14187async fn handle_classify(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14188 let engine = get_inference_engine(state);
14189 let req: car_inference::ClassifyRequest = typed_params(&msg.params)?;
14190 let _permit = state.admission.acquire().await;
14191 let result = engine.classify(req).await.map_err(|e| e.to_string())?;
14192 Ok(serde_json::json!({"classifications": result}))
14193}
14194
14195fn handle_admission_status(state: &ServerState) -> Result<Value, String> {
14199 let total = state.admission.permits();
14200 let available = state.admission.permits_available();
14201 let in_use = total.saturating_sub(available);
14202 Ok(serde_json::json!({
14203 "permits_total": total,
14204 "permits_available": available,
14205 "permits_in_use": in_use,
14206 "env_override": crate::admission::ENV_MAX_CONCURRENT,
14207 }))
14208}
14209
14210async fn handle_tokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14211 let model = msg
14212 .params
14213 .get("model")
14214 .and_then(|v| v.as_str())
14215 .ok_or("missing 'model' parameter")?;
14216 let text = msg
14217 .params
14218 .get("text")
14219 .and_then(|v| v.as_str())
14220 .ok_or("missing 'text' parameter")?;
14221 let engine = get_inference_engine(state);
14222 let ids = engine
14223 .tokenize(model, text)
14224 .await
14225 .map_err(|e| e.to_string())?;
14226 Ok(serde_json::json!({"tokens": ids}))
14227}
14228
14229async fn handle_detokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14230 let model = msg
14231 .params
14232 .get("model")
14233 .and_then(|v| v.as_str())
14234 .ok_or("missing 'model' parameter")?;
14235 let tokens: Vec<u32> = msg
14236 .params
14237 .get("tokens")
14238 .and_then(|v| v.as_array())
14239 .ok_or("missing 'tokens' parameter")?
14240 .iter()
14241 .map(|t| {
14242 t.as_u64()
14243 .and_then(|n| u32::try_from(n).ok())
14244 .ok_or_else(|| "tokens[] must be u32 values".to_string())
14245 })
14246 .collect::<Result<Vec<_>, _>>()?;
14247 let engine = get_inference_engine(state);
14248 let text = engine
14249 .detokenize(model, &tokens)
14250 .await
14251 .map_err(|e| e.to_string())?;
14252 Ok(serde_json::json!({"text": text}))
14253}
14254
14255fn parse_user_registered_model_schema(
14275 schema_value: Value,
14276) -> Result<car_inference::ModelSchema, String> {
14277 let mut schema: car_inference::ModelSchema =
14278 serde_json::from_value(schema_value).map_err(|e| format!("invalid ModelSchema: {e}"))?;
14279 if car_inference::openrouter::is_curated_managed_gateway_alias(&schema.id) {
14280 return Err(
14281 "Parslee-managed OpenRouter aliases are reserved and cannot be registered".into(),
14282 );
14283 }
14284 schema.mark_user_registered();
14285 Ok(schema)
14286}
14287
14288async fn handle_models_register(
14289 req: &JsonRpcMessage,
14290 _state: &Arc<ServerState>,
14291) -> Result<Value, String> {
14292 let schema_value = match req.params.get("schema") {
14296 Some(v) => v.clone(),
14297 None => req.params.clone(),
14298 };
14299 let schema = parse_user_registered_model_schema(schema_value)?;
14300 let id = schema.id.clone();
14301
14302 let path = car_inference::registry::user_config_path()
14308 .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14309 if let Some(car_dir) = path.parent() {
14310 std::fs::create_dir_all(car_dir)
14311 .map_err(|e| format!("create {}: {e}", car_dir.display()))?;
14312 }
14313
14314 let mut models: Vec<car_inference::ModelSchema> = if path.exists() {
14315 let text =
14316 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14317 if text.trim().is_empty() {
14318 Vec::new()
14319 } else {
14320 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14321 }
14322 } else {
14323 Vec::new()
14324 };
14325 for model in &mut models {
14326 model.mark_user_registered();
14329 }
14330 if let Some(slot) = models.iter_mut().find(|m| m.id == id) {
14332 *slot = schema;
14333 } else {
14334 models.push(schema);
14335 }
14336 let json =
14337 serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14338 let tmp = path.with_extension("json.tmp");
14339 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14340 std::fs::rename(&tmp, &path)
14341 .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14342 Ok(serde_json::json!({
14343 "id": id,
14344 "registered": true,
14345 "path": path.to_string_lossy(),
14346 "note": "Daemon restart required for live UnifiedRegistry visibility \
14347 (Parslee-ai/car-releases#39 phase 1). The model is persisted; \
14348 next car-server boot loads it via UnifiedRegistry::load_user_config.",
14349 }))
14350}
14351
14352#[cfg(test)]
14353mod model_registration_trust_tests {
14354 use super::parse_user_registered_model_schema;
14355
14356 #[test]
14357 fn daemon_models_register_rejects_reserved_managed_aliases() {
14358 let schema = car_inference::openrouter::curated_schemas()
14359 .into_iter()
14360 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14361 .unwrap();
14362 let error =
14363 parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap_err();
14364 assert_eq!(
14365 error,
14366 "Parslee-managed OpenRouter aliases are reserved and cannot be registered"
14367 );
14368 }
14369
14370 #[test]
14371 fn daemon_models_register_forces_non_reserved_schema_to_community() {
14372 let mut schema = car_inference::openrouter::curated_schemas()
14373 .into_iter()
14374 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14375 .unwrap();
14376 schema.id = "user/nearby-model".into();
14377
14378 let registered =
14379 parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap();
14380 assert_eq!(registered.trust_tier, car_inference::TrustTier::Community);
14381 }
14382}
14383
14384async fn handle_models_unregister(
14396 req: &JsonRpcMessage,
14397 _state: &Arc<ServerState>,
14398) -> Result<Value, String> {
14399 let id = match req.params.get("id") {
14403 Some(v) => v
14404 .as_str()
14405 .ok_or_else(|| "`id` must be a string".to_string())?
14406 .to_string(),
14407 None => match req.params.as_str() {
14408 Some(s) => s.to_string(),
14409 None => return Err("missing `id` parameter".to_string()),
14410 },
14411 };
14412
14413 let path = car_inference::registry::user_config_path()
14416 .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14417
14418 if !path.exists() {
14419 return Ok(serde_json::json!({
14424 "id": id,
14425 "unregistered": false,
14426 "note": "no models.json — nothing to unregister (already absent)",
14427 }));
14428 }
14429 let text =
14430 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14431 let mut models: Vec<car_inference::ModelSchema> = if text.trim().is_empty() {
14432 Vec::new()
14433 } else {
14434 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14435 };
14436 let before = models.len();
14437 models.retain(|m| m.id != id);
14438 if models.len() == before {
14439 return Ok(serde_json::json!({
14442 "id": id,
14443 "unregistered": false,
14444 "note": "model not in models.json — nothing to unregister",
14445 }));
14446 }
14447 let json =
14448 serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14449 let tmp = path.with_extension("json.tmp");
14450 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14451 std::fs::rename(&tmp, &path)
14452 .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14453 Ok(serde_json::json!({
14454 "id": id,
14455 "unregistered": true,
14456 "path": path.to_string_lossy(),
14457 "note": "Daemon restart required for live UnifiedRegistry visibility \
14458 (phase 1, matching models.register).",
14459 }))
14460}
14461
14462fn handle_models_list(state: &ServerState) -> Result<Value, String> {
14463 let engine = get_inference_engine(state);
14464 let models = engine.list_models();
14465 serde_json::to_value(&models).map_err(|e| e.to_string())
14466}
14467
14468#[derive(Debug, Default, Deserialize)]
14469struct ModelsListUnifiedParams {
14470 #[serde(default)]
14471 refresh_openrouter_status: bool,
14472}
14473
14474async fn handle_models_list_unified(
14475 message: &JsonRpcMessage,
14476 state: &ServerState,
14477) -> Result<Value, String> {
14478 let params = if message.params.is_null() {
14479 ModelsListUnifiedParams::default()
14480 } else {
14481 typed_params(&message.params)?
14482 };
14483 if params.refresh_openrouter_status {
14484 let _ = car_inference::openrouter::refresh_credential_source_for_status().await;
14485 }
14486 let engine = get_inference_engine(state);
14487 let models = engine.list_models_unified();
14488 serde_json::to_value(&models).map_err(|e| e.to_string())
14489}
14490
14491#[cfg(test)]
14492mod models_list_unified_status_tests {
14493 use super::{handle_models_list_unified, JsonRpcMessage};
14494 use crate::session::{ServerState, ServerStateConfig};
14495 use serde_json::{json, Value};
14496 use std::sync::Arc;
14497
14498 #[tokio::test]
14499 async fn daemon_list_refreshes_openrouter_only_when_explicitly_requested() {
14500 const SENTINEL: &str = "CAR_SERVER_MODELS_LIST_STATUS_CHILD";
14501 if std::env::var_os(SENTINEL).is_none() {
14502 let status = std::process::Command::new(std::env::current_exe().unwrap())
14503 .arg("--exact")
14504 .arg(
14505 "handler::models_list_unified_status_tests::daemon_list_refreshes_openrouter_only_when_explicitly_requested",
14506 )
14507 .arg("--nocapture")
14508 .arg("--test-threads=1")
14509 .env(SENTINEL, "1")
14510 .env_remove(car_home::ENV_VAR)
14511 .env_remove("CAR_SECRETS_FILE_DIR")
14512 .env_remove(car_inference::openrouter::API_KEY_ENV)
14513 .status()
14514 .expect("spawn isolated daemon models-list status test");
14515 assert!(status.success(), "isolated daemon models-list test failed");
14516 return;
14517 }
14518
14519 let fixture = tempfile::TempDir::new().unwrap();
14520 unsafe {
14521 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
14522 std::env::set_var(
14523 "CAR_SECRETS_FILE_DIR",
14524 fixture.path().join("isolated-secrets"),
14525 );
14526 std::env::remove_var(car_inference::openrouter::API_KEY_ENV);
14527 }
14528 car_secrets::SecretStore::new()
14529 .put(
14530 &car_secrets::SecretRef::with_default_service(
14531 car_inference::openrouter::API_KEY_ENV,
14532 ),
14533 "fixture-value",
14534 )
14535 .unwrap();
14536 let engine = Arc::new(car_inference::InferenceEngine::new(
14537 car_inference::InferenceConfig {
14538 state_root: fixture.path().join("state"),
14539 models_dir: fixture.path().join("models"),
14540 ..car_inference::InferenceConfig::default()
14541 },
14542 ));
14543 let state = ServerState::with_config(
14544 ServerStateConfig::new(fixture.path().join("journal")).with_inference(engine),
14545 );
14546 let request = |params| JsonRpcMessage {
14547 jsonrpc: "2.0".into(),
14548 method: Some("models.list_unified".into()),
14549 params,
14550 id: json!(1),
14551 result: None,
14552 error: None,
14553 };
14554
14555 assert_eq!(car_inference::openrouter::credential_source(), None);
14556 let before = car_secrets::secret_store_activity();
14557 let passive = handle_models_list_unified(&request(Value::Null), &state)
14558 .await
14559 .unwrap();
14560 let after_passive = car_secrets::secret_store_activity();
14561 assert_eq!(after_passive.status_attempts, before.status_attempts);
14562 assert_eq!(after_passive.get_attempts, before.get_attempts);
14563 let passive_openrouter = passive
14564 .as_array()
14565 .unwrap()
14566 .iter()
14567 .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14568 .collect::<Vec<_>>();
14569 assert!(!passive_openrouter.is_empty());
14570 assert!(passive_openrouter
14571 .iter()
14572 .all(|model| model["available"] == false));
14573
14574 let refreshed = handle_models_list_unified(
14575 &request(json!({ "refresh_openrouter_status": true })),
14576 &state,
14577 )
14578 .await
14579 .unwrap();
14580 let after_refresh = car_secrets::secret_store_activity();
14581 assert!(after_refresh.status_attempts > after_passive.status_attempts);
14582 assert_eq!(after_refresh.get_attempts, after_passive.get_attempts);
14583 let refreshed_openrouter = refreshed
14584 .as_array()
14585 .unwrap()
14586 .iter()
14587 .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14588 .collect::<Vec<_>>();
14589 assert!(!refreshed_openrouter.is_empty());
14590 assert!(refreshed_openrouter
14591 .iter()
14592 .all(|model| model["available"] == true));
14593 }
14594}
14595
14596fn handle_models_catalog_snapshot(
14597 session: &crate::session::ClientSession,
14598 state: &ServerState,
14599) -> Result<Value, String> {
14600 if !session_has_capability(session, car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY) {
14601 return Err(format!(
14602 "{} negotiate `{}` as a required or optional capability before calling `models.catalog_snapshot`",
14603 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
14604 car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY,
14605 ));
14606 }
14607 let engine = get_inference_engine(state);
14608 let snapshot = engine.catalog_snapshot()?;
14609 serde_json::to_value(snapshot).map_err(|error| error.to_string())
14610}
14611
14612#[derive(Debug, Default, Deserialize)]
14613#[serde(deny_unknown_fields)]
14614struct EmptyModelManagementParams {}
14615
14616#[derive(Debug, Deserialize)]
14617#[serde(deny_unknown_fields)]
14618struct ModelPreflightParams {
14619 model_id: String,
14620 #[serde(default)]
14621 context_tokens: usize,
14622}
14623
14624#[derive(Debug, Deserialize)]
14625#[serde(deny_unknown_fields)]
14626struct ModelIdParams {
14627 model_id: String,
14628}
14629
14630#[derive(Debug, Deserialize)]
14631#[serde(deny_unknown_fields)]
14632struct ModelPullParams {
14633 #[serde(alias = "id", alias = "model")]
14634 name: String,
14635}
14636
14637fn strict_model_params<T: serde::de::DeserializeOwned>(
14638 message: &JsonRpcMessage,
14639) -> Result<T, String> {
14640 let params = if message.params.is_null() {
14641 serde_json::json!({})
14642 } else {
14643 message.params.clone()
14644 };
14645 serde_json::from_value(params)
14646 .map_err(|error| format!("invalid model-management params: {error}"))
14647}
14648
14649fn handle_models_resource_policy_get(
14650 message: &JsonRpcMessage,
14651 state: &ServerState,
14652) -> Result<Value, String> {
14653 let _: EmptyModelManagementParams = strict_model_params(message)?;
14654 let engine = get_inference_engine(state);
14655 let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14656 engine.config.state_root.clone(),
14657 );
14658 let evidence = repository
14659 .load_with_evidence()
14660 .map_err(|error| error.to_string())?;
14661 let hardware = car_inference::HardwareInfo::detect();
14662 let evaluated_budget = evidence.policy.effective_budget(hardware.total_ram_mb);
14663 Ok(serde_json::json!({
14664 "policy": evidence.policy,
14665 "evaluated_budget": evaluated_budget,
14666 "hardware_total_mb": hardware.total_ram_mb,
14667 "source": evidence.source,
14668 "warning": evidence.warning,
14669 }))
14670}
14671
14672fn handle_models_resource_policy_set(
14673 message: &JsonRpcMessage,
14674 session: &crate::session::ClientSession,
14675 state: &ServerState,
14676) -> Result<Value, String> {
14677 require_approval_authority(session, state)?;
14678 let policy: car_inference::resource_policy::ResourcePolicy = strict_model_params(message)?;
14679 policy.validate().map_err(|error| error.to_string())?;
14680 let engine = get_inference_engine(state);
14681 let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14682 engine.config.state_root.clone(),
14683 );
14684 car_inference::resource_policy::ResourcePolicyRepository::save(&repository, &policy)
14685 .map_err(|error| error.to_string())?;
14686 engine.apply_local_resource_policy(policy.clone());
14687 let hardware = car_inference::HardwareInfo::detect();
14688 let evaluated_budget = policy.effective_budget(hardware.total_ram_mb);
14689 Ok(serde_json::json!({
14690 "policy": policy,
14691 "evaluated_budget": evaluated_budget,
14692 "hardware_total_mb": hardware.total_ram_mb,
14693 "notice": evaluated_budget.normalization_notice,
14694 }))
14695}
14696
14697fn handle_models_preflight(message: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14698 let params: ModelPreflightParams = strict_model_params(message)?;
14699 let preflight = get_inference_engine(state)
14700 .local_model_preflight(¶ms.model_id, params.context_tokens)
14701 .map_err(|error| error.to_string())?;
14702 serde_json::to_value(preflight).map_err(|error| error.to_string())
14703}
14704
14705fn handle_models_storage_roots(
14706 message: &JsonRpcMessage,
14707 session: &crate::session::ClientSession,
14708 state: &ServerState,
14709) -> Result<Value, String> {
14710 require_approval_authority(session, state)?;
14711 let _: EmptyModelManagementParams = strict_model_params(message)?;
14712 let engine = get_inference_engine(state);
14713 let store = engine.model_management_store();
14714 let hf_home = std::env::var_os("HF_HOME")
14715 .map(std::path::PathBuf::from)
14716 .or_else(|| {
14717 std::env::var_os("HOME")
14718 .or_else(|| std::env::var_os("USERPROFILE"))
14719 .map(|home| std::path::PathBuf::from(home).join(".cache/huggingface"))
14720 })
14721 .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface"));
14722 let normalize = car_inference::resource_policy::normalized_state_root_key;
14723 Ok(serde_json::json!({
14724 "state_root": normalize(&engine.config.state_root),
14725 "models_dir": normalize(&engine.config.models_dir),
14726 "hf_home": normalize(&hf_home),
14727 "hf_hub": normalize(&hf_home.join("hub")),
14728 "install_receipts_dir": normalize(store.receipts_root()),
14729 "management_state_dir": normalize(store.management_state_dir()),
14730 }))
14731}
14732
14733async fn handle_models_remove(
14734 message: &JsonRpcMessage,
14735 session: &crate::session::ClientSession,
14736 state: &ServerState,
14737) -> Result<Value, String> {
14738 require_approval_authority(session, state)?;
14739 let params: ModelIdParams = strict_model_params(message)?;
14740 let engine = Arc::clone(get_inference_engine(state));
14741 let operation = state
14742 .spawn_durable_operation("models.remove", async move {
14743 let result = engine
14744 .remove_model_from_car(¶ms.model_id)
14745 .await
14746 .map_err(|error| error.to_string())?;
14747 Ok(serde_json::json!({
14748 "model_id": result.model_id,
14749 "removed_from_car": true,
14750 "artifact_kind": result.artifact_kind,
14751 "shared_cache_preserved": true,
14752 }))
14753 })
14754 .await;
14755 operation.await.map_err(|_| {
14756 "daemon-owned models.remove operation stopped before publishing a result".to_string()
14757 })?
14758}
14759
14760async fn handle_models_adopt(
14761 message: &JsonRpcMessage,
14762 session: &crate::session::ClientSession,
14763 state: &ServerState,
14764) -> Result<Value, String> {
14765 require_approval_authority(session, state)?;
14766 let params: ModelIdParams = strict_model_params(message)?;
14767 let engine = Arc::clone(get_inference_engine(state));
14768 let operation = state
14769 .spawn_durable_operation("models.adopt", async move {
14770 let receipt = engine
14771 .adopt_model_into_car(¶ms.model_id)
14772 .await
14773 .map_err(|error| error.to_string())?;
14774 Ok(serde_json::json!({
14775 "model_id": receipt.model_id,
14776 "adopted": true,
14777 "can_remove": true,
14778 }))
14779 })
14780 .await;
14781 operation.await.map_err(|_| {
14782 "daemon-owned models.adopt operation stopped before publishing a result".to_string()
14783 })?
14784}
14785
14786async fn handle_models_route_provenance(
14792 req: &JsonRpcMessage,
14793 state: &ServerState,
14794) -> Result<Value, String> {
14795 let id = require_str(&req.params, "id")?;
14796 let engine = state.inference.get().ok_or_else(|| {
14797 "models.route_provenance requires an initialized model registry; \
14798 call `models.list_unified` to initialize it, then retry"
14799 .to_string()
14800 })?;
14801 let schema = engine
14802 .registered_schema(id)
14803 .ok_or_else(|| format!("unknown model id `{id}`"))?;
14804 let canonical_id = canonical_managed_route_provenance_id(&schema)?;
14805
14806 let (endpoint, authority) = resolved_managed_parslee_base().await?;
14807 serde_json::to_value(ManagedRouteProvenance {
14808 id: canonical_id,
14809 provider: "parslee",
14810 source: "proprietary_oauth_pkce",
14811 is_local: false,
14812 endpoint,
14813 authority,
14814 })
14815 .map_err(|error| error.to_string())
14816}
14817
14818fn canonical_managed_route_provenance_id(
14822 schema: &car_inference::ModelSchema,
14823) -> Result<&str, String> {
14824 let Some(id) = car_inference::openrouter::canonical_managed_gateway_selector(schema) else {
14825 return Err(
14826 "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14827 .into(),
14828 );
14829 };
14830 Ok(id)
14831}
14832
14833#[derive(Debug, Serialize)]
14834struct ManagedRouteProvenance<'a> {
14835 id: &'a str,
14836 provider: &'static str,
14837 source: &'static str,
14838 is_local: bool,
14839 endpoint: String,
14840 authority: String,
14841}
14842
14843async fn resolved_managed_parslee_base() -> Result<(String, String), String> {
14850 let api_base = tokio::task::spawn_blocking(|| car_auth::api_base(None))
14851 .await
14852 .map_err(|error| format!("managed inference API base worker failed: {error}"))?;
14853 redact_resolved_managed_api_base(&api_base)
14854}
14855
14856fn redact_resolved_managed_api_base(api_base: &str) -> Result<(String, String), String> {
14857 let url = reqwest::Url::parse(api_base)
14858 .map_err(|_| "invalid managed inference API base".to_string())?;
14859 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
14860 return Err("invalid managed inference API base".into());
14861 }
14862
14863 let authority = url.origin().ascii_serialization();
14864 Ok((authority.clone(), authority))
14865}
14866
14867#[cfg(test)]
14868mod managed_route_provenance_tests {
14869 use super::{canonical_managed_route_provenance_id, redact_resolved_managed_api_base};
14870 use car_inference::openrouter::{
14871 canonical_managed_gateway_selector, curated_schemas, is_curated_managed_gateway_alias,
14872 };
14873
14874 #[test]
14875 fn managed_route_provenance_redacts_the_runtime_resolved_base_without_refresh() {
14876 assert_eq!(
14877 redact_resolved_managed_api_base("https://persisted.example/").unwrap(),
14878 (
14879 "https://persisted.example".into(),
14880 "https://persisted.example".into()
14881 )
14882 );
14883 }
14884
14885 #[test]
14886 fn managed_route_provenance_normalizes_and_redacts_url_components() {
14887 assert_eq!(
14888 redact_resolved_managed_api_base("HTTPS://Staging-Api.Example:443/").unwrap(),
14889 (
14890 "https://staging-api.example".into(),
14891 "https://staging-api.example".into()
14892 )
14893 );
14894 for (base, expected) in [
14895 ("https://gateway.example/prefix", "https://gateway.example"),
14896 (
14897 "https://user:sensitive@gateway.example/private?token=value#fragment",
14898 "https://gateway.example",
14899 ),
14900 ] {
14901 assert_eq!(
14902 redact_resolved_managed_api_base(base).unwrap(),
14903 (expected.into(), expected.into())
14904 );
14905 }
14906 for invalid in [
14907 "ftp://gateway.example",
14908 "mailto:ops@example.com",
14909 "https://",
14910 ] {
14911 let error = redact_resolved_managed_api_base(invalid).unwrap_err();
14912 assert_eq!(error, "invalid managed inference API base");
14913 }
14914 }
14915
14916 #[test]
14917 fn managed_route_provenance_accepts_only_canonical_managed_aliases() {
14918 assert!(is_curated_managed_gateway_alias(
14919 "parslee/openrouter/frontier-general"
14920 ));
14921 assert!(!is_curated_managed_gateway_alias(
14922 "openrouter/openai/gpt-5.4"
14923 ));
14924 assert!(!is_curated_managed_gateway_alias("parslee/not-managed"));
14925
14926 let schemas = curated_schemas();
14927 let managed = schemas
14928 .iter()
14929 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14930 .unwrap();
14931 assert_eq!(
14932 canonical_managed_gateway_selector(managed),
14933 Some("parslee/openrouter/frontier-general")
14934 );
14935
14936 let personal = schemas
14937 .iter()
14938 .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
14939 .unwrap();
14940 assert!(canonical_managed_gateway_selector(personal).is_none());
14941
14942 let mut non_parslee = managed.clone();
14943 non_parslee.provider = "other".into();
14944 assert!(canonical_managed_gateway_selector(&non_parslee).is_none());
14945
14946 let mut noncanonical_id = managed.clone();
14947 noncanonical_id.id = "parslee/not-managed".into();
14948 assert!(canonical_managed_gateway_selector(&noncanonical_id).is_none());
14949 }
14950
14951 #[test]
14952 fn managed_route_provenance_uses_live_signed_transport_contract() {
14953 let mut signed_override = curated_schemas()
14954 .into_iter()
14955 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14956 .unwrap();
14957 signed_override.name = "signed-catalog-display-metadata-drift".into();
14958 assert_eq!(
14959 canonical_managed_route_provenance_id(&signed_override),
14960 Ok("parslee/openrouter/frontier-general")
14961 );
14962
14963 signed_override.provider = "not-parslee".into();
14964 assert_eq!(
14965 canonical_managed_route_provenance_id(&signed_override).unwrap_err(),
14966 "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14967 );
14968 }
14969}
14970
14971#[derive(Debug, Deserialize)]
14972#[serde(rename_all = "camelCase")]
14973struct ModelSearchParams {
14974 #[serde(default)]
14975 query: Option<String>,
14976 #[serde(default)]
14977 capability: Option<car_inference::ModelCapability>,
14978 #[serde(default)]
14979 provider: Option<String>,
14980 #[serde(default)]
14981 local_only: bool,
14982 #[serde(default)]
14983 available_only: bool,
14984 #[serde(default)]
14985 limit: Option<usize>,
14986}
14987
14988#[derive(Debug, Serialize)]
14989#[serde(rename_all = "camelCase")]
14990struct ModelSearchEntry {
14991 #[serde(flatten)]
14996 info: car_inference::ModelInfo,
14997 tags: Vec<String>,
14998 pullable: bool,
14999 upgrade: Option<car_inference::ModelUpgrade>,
15000}
15001
15002#[derive(Debug, Serialize)]
15003#[serde(rename_all = "camelCase")]
15004struct ModelSearchResponse {
15005 models: Vec<ModelSearchEntry>,
15006 upgrades: Vec<car_inference::ModelUpgrade>,
15007 total: usize,
15008 available: usize,
15009 local: usize,
15010 remote: usize,
15011}
15012
15013async fn handle_models_search(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15014 let params: ModelSearchParams =
15015 serde_json::from_value(req.params.clone()).unwrap_or(ModelSearchParams {
15016 query: None,
15017 capability: None,
15018 provider: None,
15019 local_only: false,
15020 available_only: false,
15021 limit: None,
15022 });
15023 let engine = get_inference_engine(state);
15024 let upgrades = engine.available_model_upgrades();
15025 let upgrades_by_from: HashMap<String, car_inference::ModelUpgrade> = upgrades
15026 .iter()
15027 .cloned()
15028 .map(|upgrade| (upgrade.from_id.clone(), upgrade))
15029 .collect();
15030 let query = params
15031 .query
15032 .as_deref()
15033 .map(str::trim)
15034 .filter(|q| !q.is_empty())
15035 .map(|q| q.to_ascii_lowercase());
15036 let provider = params
15037 .provider
15038 .as_deref()
15039 .map(str::trim)
15040 .filter(|p| !p.is_empty())
15041 .map(|p| p.to_ascii_lowercase());
15042
15043 let mut entries: Vec<ModelSearchEntry> = engine
15044 .list_schemas()
15045 .into_iter()
15046 .filter(|schema| {
15047 if let Some(capability) = params.capability {
15048 if !schema.has_capability(capability) {
15049 return false;
15050 }
15051 }
15052 if let Some(provider) = provider.as_deref() {
15053 if schema.provider.to_ascii_lowercase() != provider {
15054 return false;
15055 }
15056 }
15057 if params.local_only && !schema.is_local() {
15058 return false;
15059 }
15060 if params.available_only && !schema.available {
15061 return false;
15062 }
15063 if let Some(query) = query.as_deref() {
15064 let capability_text = schema
15065 .capabilities
15066 .iter()
15067 .map(|cap| format!("{cap:?}").to_ascii_lowercase())
15068 .collect::<Vec<_>>()
15069 .join(" ");
15070 let haystack = format!(
15071 "{} {} {} {} {} {}",
15072 schema.id,
15073 schema.name,
15074 schema.provider,
15075 schema.family,
15076 schema.tags.join(" "),
15077 capability_text
15078 )
15079 .to_ascii_lowercase();
15080 if !haystack.contains(query) {
15081 return false;
15082 }
15083 }
15084 true
15085 })
15086 .map(|schema| {
15087 let pullable = model_is_pullable(&schema);
15088 let mut info =
15089 car_inference::ModelInfo::from(&schema).with_fit(engine.model_fit(&schema));
15090 info.family = Some(schema.family);
15091 info.version = Some(schema.version);
15092 let upgrade = upgrades_by_from.get(&schema.id).cloned();
15093 ModelSearchEntry {
15094 info,
15095 tags: schema.tags,
15096 pullable,
15097 upgrade,
15098 }
15099 })
15100 .collect();
15101 entries.sort_by(|a, b| {
15102 b.info
15103 .available
15104 .cmp(&a.info.available)
15105 .then(b.info.is_local.cmp(&a.info.is_local))
15106 .then(a.info.name.cmp(&b.info.name))
15107 });
15108 if let Some(limit) = params.limit {
15109 entries.truncate(limit);
15110 }
15111
15112 let total = entries.len();
15113 let available = entries.iter().filter(|entry| entry.info.available).count();
15114 let local = entries.iter().filter(|entry| entry.info.is_local).count();
15115 let response = ModelSearchResponse {
15116 models: entries,
15117 upgrades,
15118 total,
15119 available,
15120 local,
15121 remote: total.saturating_sub(local),
15122 };
15123 serde_json::to_value(response).map_err(|e| e.to_string())
15124}
15125
15126fn model_is_pullable(schema: &car_inference::ModelSchema) -> bool {
15127 schema.downloads_weights() && !schema.weights_ready
15128}
15129
15130fn optional_enum_param<T: serde::de::DeserializeOwned>(
15134 req: &JsonRpcMessage,
15135 key: &str,
15136) -> Result<Option<T>, String> {
15137 match req.params.get(key) {
15138 None | Some(Value::Null) => Ok(None),
15139 Some(v) => serde_json::from_value(v.clone())
15140 .map(Some)
15141 .map_err(|_| format!("invalid '{key}': {v}")),
15142 }
15143}
15144
15145fn recommend_from_params(
15150 req: &JsonRpcMessage,
15151 engine: &car_inference::InferenceEngine,
15152) -> Result<car_inference::RecommendationSet, String> {
15153 let hw = car_inference::HardwareInfo::detect();
15154 recommend_from_params_with_hardware(req, engine, &hw)
15155}
15156
15157fn recommend_from_params_with_hardware(
15158 req: &JsonRpcMessage,
15159 engine: &car_inference::InferenceEngine,
15160 hw: &car_inference::HardwareInfo,
15161) -> Result<car_inference::RecommendationSet, String> {
15162 let schemas = engine.list_schemas();
15163 let policy = engine.active_local_resource_policy();
15164 recommend_from_params_with_policy(req, &schemas, hw, &policy.policy, policy.warning)
15165}
15166
15167#[cfg(test)]
15171fn recommend_from_params_with_inputs(
15172 req: &JsonRpcMessage,
15173 schemas: &[car_inference::ModelSchema],
15174 hw: &car_inference::HardwareInfo,
15175 policy_repository: &car_inference::FileResourcePolicyRepository,
15176) -> Result<car_inference::RecommendationSet, String> {
15177 let (policy, _, warning) = load_recommendation_policy(policy_repository);
15178 recommend_from_params_with_policy(req, schemas, hw, &policy, warning)
15179}
15180
15181#[cfg(test)]
15182fn load_recommendation_policy(
15183 policy_repository: &car_inference::FileResourcePolicyRepository,
15184) -> (
15185 car_inference::ResourcePolicy,
15186 car_inference::ResourcePolicyLoadSource,
15187 Option<String>,
15188) {
15189 match policy_repository.load_with_evidence() {
15190 Ok(evidence) => (evidence.policy, evidence.source, evidence.warning),
15191 Err(error) => (
15192 car_inference::ResourcePolicy::everyday(),
15193 car_inference::ResourcePolicyLoadSource::CorruptDefault,
15194 Some(format!(
15195 "The local-model resource policy could not be read ({error}); CAR used Everyday."
15196 )),
15197 ),
15198 }
15199}
15200
15201fn recommend_from_params_with_policy(
15202 req: &JsonRpcMessage,
15203 schemas: &[car_inference::ModelSchema],
15204 hw: &car_inference::HardwareInfo,
15205 policy: &car_inference::ResourcePolicy,
15206 warning: Option<String>,
15207) -> Result<car_inference::RecommendationSet, String> {
15208 let use_case =
15209 optional_enum_param::<car_inference::UseCase>(req, "use_case")?.unwrap_or_default();
15210 let tier = optional_enum_param::<car_inference::QualityTier>(req, "tier")?.unwrap_or_default();
15211 let privacy = if req
15212 .params
15213 .get("cloud_ok")
15214 .and_then(|v| v.as_bool())
15215 .unwrap_or(false)
15216 {
15217 car_inference::Privacy::CloudOk
15218 } else {
15219 car_inference::Privacy::OnDevice
15220 };
15221 let refs: Vec<&car_inference::ModelSchema> = schemas.iter().collect();
15222 let mut set = car_inference::recommend_with_policy(&refs, hw, policy, use_case, tier, privacy);
15223 if let Some(warning) = warning {
15224 set.note = Some(match set.note.take() {
15225 Some(note) => format!("Resource policy warning: {warning} {note}"),
15226 None => format!("Resource policy warning: {warning}"),
15227 });
15228 }
15229 Ok(set)
15230}
15231
15232fn handle_models_recommend(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15234 let engine = get_inference_engine(state);
15235 let set = recommend_from_params(req, engine)?;
15236 serde_json::to_value(set).map_err(|e| e.to_string())
15237}
15238
15239fn handle_models_setup_plan(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15242 let engine = get_inference_engine(state);
15243 let hw = car_inference::HardwareInfo::detect();
15244 let schemas = engine.list_schemas();
15245 let policy = engine.active_local_resource_policy();
15246 setup_plan_from_params_with_policy_inputs(
15247 req,
15248 &schemas,
15249 &hw,
15250 &policy.policy,
15251 policy.source,
15252 policy.warning,
15253 )
15254}
15255
15256#[cfg(test)]
15257fn setup_plan_from_params_with_inputs(
15258 req: &JsonRpcMessage,
15259 schemas: &[car_inference::ModelSchema],
15260 hw: &car_inference::HardwareInfo,
15261 policy_repository: &car_inference::FileResourcePolicyRepository,
15262) -> Result<Value, String> {
15263 let (policy, source, warning) = load_recommendation_policy(policy_repository);
15264 setup_plan_from_params_with_policy_inputs(req, schemas, hw, &policy, source, warning)
15265}
15266
15267fn setup_plan_from_params_with_policy_inputs(
15268 req: &JsonRpcMessage,
15269 schemas: &[car_inference::ModelSchema],
15270 hw: &car_inference::HardwareInfo,
15271 active_policy: &car_inference::ResourcePolicy,
15272 active_policy_source: car_inference::ResourcePolicyLoadSource,
15273 active_policy_warning: Option<String>,
15274) -> Result<Value, String> {
15275 let preview_policy = match req.params.get("resource_policy") {
15276 None => None,
15277 Some(value) => {
15278 let policy: car_inference::ResourcePolicy = serde_json::from_value(value.clone())
15279 .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15280 policy
15281 .validate()
15282 .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15283 Some(policy)
15284 }
15285 };
15286 let (policy, policy_source, warning) = match preview_policy {
15287 Some(policy) => (policy, serde_json::json!("preview"), None),
15288 None => (
15289 active_policy.clone(),
15290 serde_json::json!(active_policy_source),
15291 active_policy_warning,
15292 ),
15293 };
15294 let evaluated_budget = policy.effective_budget(hw.total_ram_mb);
15295 let mut set = recommend_from_params_with_policy(req, schemas, hw, &policy, warning)?;
15296 let (recommended, alternatives) = split_setup_recommendations(set.picks);
15297 if recommended.is_none() {
15298 let notice = "No model is within CAR's automatic recommendation target; heavier or unknown-memory alternatives require an explicit choice.";
15299 set.note = Some(match set.note.take() {
15300 Some(note) => format!("{note} {notice}"),
15301 None => notice.into(),
15302 });
15303 }
15304 serde_json::to_value(serde_json::json!({
15305 "machine": describe_machine_for_plan(hw),
15306 "recommended": recommended,
15307 "alternatives": alternatives,
15308 "needs_more_memory": set.not_enough_memory,
15309 "note": set.note,
15310 "resource_policy": policy,
15311 "evaluated_budget": evaluated_budget,
15312 "policy_source": policy_source,
15313 }))
15314 .map_err(|e| e.to_string())
15315}
15316
15317fn split_setup_recommendations(
15318 mut picks: Vec<car_inference::Recommendation>,
15319) -> (
15320 Option<car_inference::Recommendation>,
15321 Vec<car_inference::Recommendation>,
15322) {
15323 let recommended_index = picks
15324 .iter()
15325 .position(|pick| pick.within_recommendation_target);
15326 let recommended = recommended_index.map(|index| picks.remove(index));
15327 (recommended, picks)
15328}
15329
15330fn describe_machine_for_plan(hw: &car_inference::HardwareInfo) -> String {
15332 use car_inference::hardware::SupportedAcceleration::*;
15333 match hw.supported_acceleration() {
15334 Apple { unified_memory_mb } => format!(
15335 "Apple Silicon, {} GB unified memory (Metal)",
15336 unified_memory_mb / 1024
15337 ),
15338 Cuda { device_memory_mb } => match device_memory_mb {
15339 Some(mb) => format!("NVIDIA GPU, {} GB VRAM (CUDA)", mb / 1024),
15340 None => "NVIDIA GPU (CUDA)".to_string(),
15341 },
15342 UnsupportedDiscreteGpu { name, .. } => format!(
15343 "{} GB RAM, CPU inference ({name} not yet supported)",
15344 hw.total_ram_mb / 1024
15345 ),
15346 Cpu => format!("{} GB RAM, CPU inference", hw.total_ram_mb / 1024),
15347 }
15348}
15349
15350#[cfg(test)]
15351mod model_resource_policy_recommendation_tests {
15352 use super::{
15353 model_is_pullable, recommend_from_params_with_inputs, setup_plan_from_params_with_inputs,
15354 split_setup_recommendations, JsonRpcMessage,
15355 };
15356 use car_inference::hardware::GpuBackend;
15357 use car_inference::{
15358 FileResourcePolicyRepository, HardwareInfo, ModelSchema, ResourcePolicy,
15359 ResourcePolicyRepository,
15360 };
15361 use serde_json::json;
15362
15363 fn request() -> JsonRpcMessage {
15364 JsonRpcMessage {
15365 jsonrpc: "2.0".into(),
15366 method: Some("models.recommend".into()),
15367 params: json!({"use_case": "assistant", "tier": "balanced"}),
15368 id: json!(1),
15369 result: None,
15370 error: None,
15371 }
15372 }
15373
15374 fn mac_32gb() -> HardwareInfo {
15375 HardwareInfo {
15376 os: "macos".into(),
15377 arch: "aarch64".into(),
15378 cpu_cores: 10,
15379 total_ram_mb: 32 * 1024,
15380 gpu_backend: GpuBackend::Metal,
15381 gpu_memory_mb: None,
15382 gpu_devices: vec![],
15383 recommended_model: String::new(),
15384 recommended_context: 8_192,
15385 max_model_mb: 0,
15386 }
15387 }
15388
15389 fn setup_schemas(home: &tempfile::TempDir) -> Vec<ModelSchema> {
15390 let models_dir = home.path().join("weights");
15391 let huggingface_hub_root = home.path().join("huggingface-hub");
15392 std::fs::create_dir_all(&huggingface_hub_root).unwrap();
15393 car_inference::registry::builtin_catalog_with_huggingface_hub_for_testing(
15394 &models_dir,
15395 &huggingface_hub_root,
15396 )
15397 .into_iter()
15398 .filter(|model| matches!(model.id.as_str(), "mlx/qwen3-4b:4bit" | "mlx/qwen3-8b:4bit"))
15399 .collect()
15400 }
15401
15402 fn setup_request(params: serde_json::Value) -> JsonRpcMessage {
15403 JsonRpcMessage {
15404 jsonrpc: "2.0".into(),
15405 method: Some("models.setup_plan".into()),
15406 params,
15407 id: json!(1),
15408 result: None,
15409 error: None,
15410 }
15411 }
15412
15413 #[test]
15414 fn setup_plan_preview_custom_zero_changes_eligibility_without_persisting() {
15415 let home = tempfile::tempdir().unwrap();
15416 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15417 repository.save(&ResourcePolicy::everyday()).unwrap();
15418 let policy_path = repository.path();
15419 let saved_bytes = std::fs::read(&policy_path).unwrap();
15420 let schemas = setup_schemas(&home);
15421
15422 let preview = setup_plan_from_params_with_inputs(
15424 &setup_request(json!({
15425 "use_case": "assistant",
15426 "tier": "balanced",
15427 "resource_policy": {
15428 "profile": "custom",
15429 "custom_max_model_mb": 0
15430 }
15431 })),
15432 &schemas,
15433 &mac_32gb(),
15434 &repository,
15435 )
15436 .unwrap();
15437
15438 assert_eq!(preview["resource_policy"]["profile"], "custom");
15439 assert_eq!(preview["resource_policy"]["custom_max_model_mb"], 0);
15440 assert_eq!(
15441 preview["evaluated_budget"]["configured_model_ceiling_mb"],
15442 0
15443 );
15444 assert_eq!(
15445 preview["evaluated_budget"]["effective_new_load_ceiling_mb"],
15446 0
15447 );
15448 assert_eq!(preview["policy_source"], "preview");
15449 assert!(preview["recommended"].is_null());
15450 assert_eq!(preview["alternatives"].as_array().unwrap().len(), 0);
15451 assert_eq!(preview["needs_more_memory"].as_array().unwrap().len(), 2);
15452
15453 assert_eq!(std::fs::read(&policy_path).unwrap(), saved_bytes);
15454 let fresh_repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15455 assert_eq!(fresh_repository.load().unwrap(), ResourcePolicy::everyday());
15456
15457 let persisted = setup_plan_from_params_with_inputs(
15458 &setup_request(json!({"use_case":"assistant","tier":"balanced"})),
15459 &schemas,
15460 &mac_32gb(),
15461 &fresh_repository,
15462 )
15463 .unwrap();
15464 assert_eq!(persisted["policy_source"], "loaded");
15465 assert_eq!(persisted["resource_policy"]["profile"], "everyday");
15466 assert_eq!(persisted["recommended"]["model_id"], "mlx/qwen3-4b:4bit");
15467 assert!(persisted["alternatives"]
15468 .as_array()
15469 .unwrap()
15470 .iter()
15471 .any(|pick| pick["model_id"] == "mlx/qwen3-8b:4bit"));
15472 }
15473
15474 #[test]
15475 fn setup_plan_preview_preserves_exact_custom_ten_and_a_half_gb() {
15476 let home = tempfile::tempdir().unwrap();
15477 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15478 repository.save(&ResourcePolicy::everyday()).unwrap();
15479
15480 let plan = setup_plan_from_params_with_inputs(
15481 &setup_request(json!({
15482 "resource_policy": {
15483 "profile": "custom",
15484 "custom_max_model_mb": 10_752
15485 }
15486 })),
15487 &setup_schemas(&home),
15488 &mac_32gb(),
15489 &repository,
15490 )
15491 .unwrap();
15492
15493 assert_eq!(plan["resource_policy"]["custom_max_model_mb"], 10_752);
15494 assert_eq!(
15495 plan["evaluated_budget"]["configured_model_ceiling_mb"],
15496 10_752
15497 );
15498 assert_eq!(
15499 plan["evaluated_budget"]["effective_new_load_ceiling_mb"],
15500 10_752
15501 );
15502 assert_eq!(repository.load().unwrap(), ResourcePolicy::everyday());
15503 }
15504
15505 #[test]
15506 fn setup_plan_preview_rejects_invalid_policy_shapes() {
15507 let home = tempfile::tempdir().unwrap();
15508 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15509 let schemas = setup_schemas(&home);
15510 let invalid = [
15511 json!({"profile":"custom","custom_max_model_mb":10_752,"unknown":true}),
15512 json!({"profile":"custom"}),
15513 json!({"profile":"everyday","custom_max_model_mb":512}),
15514 json!({"profile":"custom","custom_max_model_mb":10_547}),
15515 json!({"profile":"custom","custom_max_model_mb":-512}),
15516 json!({"profile":"custom","custom_max_model_mb":10_752.0}),
15517 serde_json::Value::Null,
15518 json!("custom"),
15519 json!([]),
15520 json!(true),
15521 ];
15522
15523 for resource_policy in invalid {
15524 let error = setup_plan_from_params_with_inputs(
15525 &setup_request(json!({"resource_policy": resource_policy.clone()})),
15526 &schemas,
15527 &mac_32gb(),
15528 &repository,
15529 )
15530 .expect_err(&format!(
15531 "invalid preview must fail closed: {resource_policy}"
15532 ));
15533 assert!(
15534 error.contains("invalid 'resource_policy'"),
15535 "unexpected error for {resource_policy}: {error}"
15536 );
15537 }
15538 }
15539
15540 #[test]
15541 fn setup_plan_without_preview_keeps_corrupt_policy_warning_and_everyday_fallback() {
15542 let home = tempfile::tempdir().unwrap();
15543 std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15544 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15545
15546 let plan = setup_plan_from_params_with_inputs(
15547 &setup_request(json!({})),
15548 &setup_schemas(&home),
15549 &mac_32gb(),
15550 &repository,
15551 )
15552 .unwrap();
15553
15554 assert_eq!(plan["policy_source"], "corrupt_default");
15555 assert_eq!(
15556 plan["resource_policy"],
15557 json!({
15558 "profile": "everyday",
15559 "custom_max_model_mb": null
15560 })
15561 );
15562 assert_eq!(
15563 plan["evaluated_budget"]["configured_model_ceiling_mb"],
15564 13_107
15565 );
15566 assert!(plan["note"]
15567 .as_str()
15568 .is_some_and(|note| note.contains("saved resource policy could not be loaded")));
15569 }
15570
15571 #[test]
15572 fn isolated_car_home_everyday_policy_recommends_real_4b_and_keeps_8b_heavier() {
15573 let home = tempfile::tempdir().unwrap();
15574 let schemas = setup_schemas(&home);
15575 assert_eq!(schemas.len(), 2, "real built-in 4B/8B rows must exist");
15576
15577 let set = recommend_from_params_with_inputs(
15578 &request(),
15579 &schemas,
15580 &mac_32gb(),
15581 &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15582 )
15583 .unwrap();
15584
15585 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
15586 assert!(set.picks[0].within_recommendation_target);
15587 let eight = set
15588 .picks
15589 .iter()
15590 .find(|pick| pick.model_id == "mlx/qwen3-8b:4bit")
15591 .expect("8B remains visible as a heavier alternative");
15592 assert_eq!(eight.display_name, "Qwen3-8B-MLX");
15593 let mut over_target = eight.clone();
15594 over_target.within_recommendation_target = false;
15595 let (automatic, visible) = split_setup_recommendations(vec![over_target]);
15596 assert!(automatic.is_none());
15597 assert_eq!(visible.len(), 1);
15598
15599 let (recommended, alternatives) = split_setup_recommendations(set.picks);
15600 assert_eq!(recommended.unwrap().model_id, "mlx/qwen3-4b:4bit");
15601 assert!(alternatives
15602 .iter()
15603 .any(|pick| pick.model_id == "mlx/qwen3-8b:4bit"));
15604 }
15605
15606 #[test]
15607 fn corrupt_policy_is_visible_in_recommendation_notice() {
15608 let home = tempfile::tempdir().unwrap();
15609 std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15610 let schemas: Vec<ModelSchema> = setup_schemas(&home)
15611 .into_iter()
15612 .filter(|model| model.id == "mlx/qwen3-4b:4bit")
15613 .collect();
15614
15615 let set = recommend_from_params_with_inputs(
15616 &request(),
15617 &schemas,
15618 &mac_32gb(),
15619 &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15620 )
15621 .unwrap();
15622 assert!(
15623 set.note
15624 .as_deref()
15625 .is_some_and(|note| note.contains("saved resource policy could not be loaded")),
15626 "corrupt policy recovery must remain visible: {:?}",
15627 set.note
15628 );
15629 }
15630
15631 #[test]
15632 fn routable_mlx_model_without_weights_remains_pullable() {
15633 let home = tempfile::tempdir().unwrap();
15634 let mut schema = setup_schemas(&home)
15635 .into_iter()
15636 .find(|model| model.id == "mlx/qwen3-4b:4bit")
15637 .expect("built-in 4B MLX row from an explicit empty test hub");
15638 schema.available = true;
15642 schema.weights_ready = false;
15643
15644 assert!(schema.available, "the MLX runtime is routable on this host");
15645 assert!(
15646 !schema.weights_ready,
15647 "fresh test root has no model weights"
15648 );
15649 assert!(model_is_pullable(&schema));
15650 }
15651}
15652
15653fn handle_models_upgrades(state: &ServerState) -> Result<Value, String> {
15654 let engine = get_inference_engine(state);
15655 serde_json::to_value(serde_json::json!({
15656 "upgrades": engine.available_model_upgrades()
15657 }))
15658 .map_err(|e| e.to_string())
15659}
15660
15661async fn handle_models_detect_upgrades(state: &ServerState) -> Result<Value, String> {
15664 let engine = get_inference_engine(state);
15665 let findings = engine.detect_upgrades().await;
15666 serde_json::to_value(serde_json::json!({ "upgrades": findings })).map_err(|e| e.to_string())
15667}
15668
15669async fn handle_models_check_upgrade_nudge(
15673 req: &JsonRpcMessage,
15674 state: &ServerState,
15675) -> Result<Value, String> {
15676 let engine = get_inference_engine(state);
15677 let inference_active = req
15678 .params
15679 .get("inference_active")
15680 .and_then(|v| v.as_bool())
15681 .unwrap_or(false);
15682 let (decision, _state) = engine.check_upgrade_nudge(inference_active).await;
15683 serde_json::to_value(decision).map_err(|e| e.to_string())
15684}
15685
15686fn handle_models_dismiss_upgrade(
15688 req: &JsonRpcMessage,
15689 state: &ServerState,
15690) -> Result<Value, String> {
15691 let key = req
15692 .params
15693 .get("dismiss_key")
15694 .and_then(|v| v.as_str())
15695 .map(str::trim)
15696 .filter(|s| !s.is_empty())
15697 .ok_or("missing or empty 'dismiss_key' parameter")?;
15698 let engine = get_inference_engine(state);
15699 engine
15700 .dismiss_upgrade_nudge(key)
15701 .map_err(|e| e.to_string())?;
15702 Ok(serde_json::json!({ "dismissed": key }))
15703}
15704
15705async fn handle_models_check_concierge(
15709 req: &JsonRpcMessage,
15710 state: &ServerState,
15711) -> Result<Value, String> {
15712 let engine = get_inference_engine(state);
15713 let inference_active = req
15714 .params
15715 .get("inference_active")
15716 .and_then(|v| v.as_bool())
15717 .unwrap_or(false);
15718 let (suggestions, _state) = engine.check_concierge(inference_active).await;
15719 serde_json::to_value(serde_json::json!({ "suggestions": suggestions }))
15720 .map_err(|e| e.to_string())
15721}
15722
15723fn handle_models_dismiss_suggestion(
15726 req: &JsonRpcMessage,
15727 state: &ServerState,
15728) -> Result<Value, String> {
15729 let key = req
15730 .params
15731 .get("dismiss_key")
15732 .and_then(|v| v.as_str())
15733 .map(str::trim)
15734 .filter(|s| !s.is_empty())
15735 .ok_or("missing or empty 'dismiss_key' parameter")?;
15736 let engine = get_inference_engine(state);
15737 engine
15738 .dismiss_concierge_suggestion(key)
15739 .map_err(|e| e.to_string())?;
15740 Ok(serde_json::json!({ "dismissed": key }))
15741}
15742
15743async fn handle_concierge_status(
15747 req: &JsonRpcMessage,
15748 state: &ServerState,
15749) -> Result<Value, String> {
15750 let engine = get_inference_engine(state);
15751 let inference_active = req
15754 .params
15755 .get("inference_active")
15756 .and_then(|v| v.as_bool())
15757 .unwrap_or_else(|| state.admission.in_flight() > 0);
15758 let status = engine.concierge_status(inference_active).await;
15759 serde_json::to_value(status).map_err(|e| e.to_string())
15760}
15761
15762fn handle_concierge_dismiss(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15767 let key = req
15768 .params
15769 .get("dismiss_key")
15770 .and_then(|v| v.as_str())
15771 .map(str::trim)
15772 .filter(|s| !s.is_empty())
15773 .ok_or("missing or empty 'dismiss_key' parameter")?;
15774 let reason: car_inference::DismissReason = req
15775 .params
15776 .get("reason")
15777 .cloned()
15778 .map(serde_json::from_value)
15779 .transpose()
15780 .map_err(|e| format!("invalid 'reason': {e}"))?
15781 .unwrap_or(car_inference::DismissReason::NotNow);
15782 let engine = get_inference_engine(state);
15783 engine.dismiss_concierge_labeled(key, reason)?;
15784 Ok(serde_json::json!({ "dismissed": key, "reason": reason }))
15785}
15786
15787fn handle_concierge_defaults(state: &ServerState) -> Result<Value, String> {
15789 let engine = get_inference_engine(state);
15790 serde_json::to_value(engine.lane_defaults()).map_err(|e| e.to_string())
15791}
15792
15793async fn handle_concierge_set_default(
15797 req: &JsonRpcMessage,
15798 state: &ServerState,
15799) -> Result<Value, String> {
15800 let use_case: car_inference::UseCase = req
15801 .params
15802 .get("use_case")
15803 .cloned()
15804 .map(serde_json::from_value)
15805 .transpose()
15806 .map_err(|e| format!("invalid 'use_case': {e}"))?
15807 .ok_or("missing 'use_case'")?;
15808 let model_id = req
15809 .params
15810 .get("model_id")
15811 .and_then(|v| v.as_str())
15812 .map(str::trim)
15813 .filter(|s| !s.is_empty())
15814 .ok_or("missing or empty 'model_id'")?;
15815 let project = req
15816 .params
15817 .get("project")
15818 .and_then(|v| v.as_str())
15819 .map(str::to_string);
15820 let engine = get_inference_engine(state);
15821 engine
15822 .user_set_lane_default(use_case, model_id, project)
15823 .await?;
15824 Ok(serde_json::json!({ "set": model_id, "use_case": use_case }))
15825}
15826
15827async fn handle_concierge_clear_default(
15830 req: &JsonRpcMessage,
15831 state: &ServerState,
15832) -> Result<Value, String> {
15833 let use_case: car_inference::UseCase = req
15834 .params
15835 .get("use_case")
15836 .cloned()
15837 .map(serde_json::from_value)
15838 .transpose()
15839 .map_err(|e| format!("invalid 'use_case': {e}"))?
15840 .ok_or("missing 'use_case'")?;
15841 let project = req
15842 .params
15843 .get("project")
15844 .and_then(|v| v.as_str())
15845 .map(str::to_string);
15846 let engine = get_inference_engine(state);
15847 let removed = engine.user_clear_lane_default(use_case, project).await?;
15848 Ok(serde_json::json!({ "cleared": removed }))
15849}
15850
15851async fn handle_concierge_apply(
15856 req: &JsonRpcMessage,
15857 state: &ServerState,
15858) -> Result<Value, String> {
15859 let use_case: car_inference::UseCase = req
15860 .params
15861 .get("use_case")
15862 .cloned()
15863 .map(serde_json::from_value)
15864 .transpose()
15865 .map_err(|e| format!("invalid 'use_case': {e}"))?
15866 .ok_or("missing 'use_case'")?;
15867 let model_id = req
15868 .params
15869 .get("model_id")
15870 .and_then(|v| v.as_str())
15871 .map(str::trim)
15872 .filter(|s| !s.is_empty())
15873 .ok_or("missing or empty 'model_id'")?;
15874 let project = req
15875 .params
15876 .get("project")
15877 .and_then(|v| v.as_str())
15878 .map(str::to_string);
15879 let engine = get_inference_engine(state);
15880 let result = engine.apply_concierge(use_case, model_id, project).await?;
15881 serde_json::to_value(result).map_err(|e| e.to_string())
15882}
15883
15884async fn handle_concierge_rollback(
15887 req: &JsonRpcMessage,
15888 state: &ServerState,
15889) -> Result<Value, String> {
15890 let use_case: car_inference::UseCase = req
15891 .params
15892 .get("use_case")
15893 .cloned()
15894 .map(serde_json::from_value)
15895 .transpose()
15896 .map_err(|e| format!("invalid 'use_case': {e}"))?
15897 .ok_or("missing 'use_case'")?;
15898 let project = req
15899 .params
15900 .get("project")
15901 .and_then(|v| v.as_str())
15902 .map(str::to_string);
15903 let engine = get_inference_engine(state);
15904 let restored = engine.rollback_lane(use_case, project).await?;
15905 Ok(serde_json::json!({ "restored": restored }))
15906}
15907
15908fn handle_concierge_actions(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15911 let limit = req
15912 .params
15913 .get("limit")
15914 .and_then(|v| v.as_u64())
15915 .unwrap_or(50) as usize;
15916 let engine = get_inference_engine(state);
15917 serde_json::to_value(serde_json::json!({ "actions": engine.concierge_actions(limit) }))
15918 .map_err(|e| e.to_string())
15919}
15920
15921async fn handle_concierge_refresh_catalog(state: &ServerState) -> Result<Value, String> {
15925 let engine = get_inference_engine(state);
15926 let count = engine.refresh_catalog().await?;
15927 Ok(serde_json::json!({
15928 "refreshed": count,
15929 "note": "verified catalog cached; new models apply on next daemon restart"
15930 }))
15931}
15932
15933async fn handle_concierge_ask(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15938 let question = req
15939 .params
15940 .get("question")
15941 .and_then(|v| v.as_str())
15942 .map(str::trim)
15943 .filter(|s| !s.is_empty())
15944 .ok_or("missing or empty 'question'")?;
15945 let engine = get_inference_engine(state);
15946 let answer = engine.concierge_ask(question).await?;
15947 Ok(serde_json::json!({ "answer": answer }))
15948}
15949
15950fn handle_models_update_prefs_get(state: &ServerState) -> Result<Value, String> {
15952 let engine = get_inference_engine(state);
15953 serde_json::to_value(engine.update_prefs()).map_err(|e| e.to_string())
15954}
15955
15956fn handle_models_update_prefs_set(
15959 req: &JsonRpcMessage,
15960 state: &ServerState,
15961) -> Result<Value, String> {
15962 let prefs: car_inference::UpdatePreferences = serde_json::from_value(req.params.clone())
15963 .map_err(|e| format!("invalid preferences: {e}"))?;
15964 let engine = get_inference_engine(state);
15965 engine.set_update_prefs(&prefs).map_err(|e| e.to_string())?;
15966 serde_json::to_value(prefs).map_err(|e| e.to_string())
15967}
15968
15969pub async fn run_upgrade_nudge_check(state: &Arc<ServerState>) {
15975 let engine = get_inference_engine(state);
15976 let inference_active = state.admission.in_flight() > 0;
15979 let (decision, mut nstate) = engine.check_upgrade_nudge(inference_active).await;
15980 if let Some(nudge) = decision.nudge {
15981 let delivered = broadcast_upgrade_nudge(state, &nudge).await;
15982 if delivered > 0 {
15986 let now = std::time::SystemTime::now()
15987 .duration_since(std::time::UNIX_EPOCH)
15988 .map(|d| d.as_secs())
15989 .unwrap_or(0);
15990 nstate.last_nudge_secs = now;
15991 let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15992 }
15993 }
15994}
15995
15996pub async fn broadcast_upgrade_nudge(
16002 state: &Arc<ServerState>,
16003 nudge: &car_inference::UpgradeNudge,
16004) -> usize {
16005 use futures::SinkExt;
16006 use tokio_tungstenite::tungstenite::Message;
16007 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16008 .a2ui_subscribers
16009 .lock()
16010 .await
16011 .values()
16012 .cloned()
16013 .collect();
16014 if subscribers.is_empty() {
16015 return 0;
16016 }
16017 let Ok(json) = serde_json::to_string(&serde_json::json!({
16018 "jsonrpc": "2.0",
16019 "method": "models.upgrade_available",
16020 "params": nudge,
16021 })) else {
16022 return 0;
16023 };
16024 let mut delivered = 0;
16025 for channel in subscribers {
16026 if channel
16027 .write
16028 .lock()
16029 .await
16030 .send(Message::Text(json.clone().into()))
16031 .await
16032 .is_ok()
16033 {
16034 delivered += 1;
16035 }
16036 }
16037 delivered
16038}
16039
16040pub async fn run_concierge_check(state: &Arc<ServerState>) {
16046 let engine = get_inference_engine(state);
16047 let inference_active = state.admission.in_flight() > 0;
16048 let (suggestions, mut nstate) = engine.check_concierge(inference_active).await;
16049 if suggestions.is_empty() {
16050 return;
16051 }
16052 let mut any_delivered = false;
16053 for suggestion in &suggestions {
16054 if broadcast_concierge_suggestion(state, suggestion).await > 0 {
16055 any_delivered = true;
16056 }
16057 }
16058 if any_delivered {
16062 let now = std::time::SystemTime::now()
16063 .duration_since(std::time::UNIX_EPOCH)
16064 .map(|d| d.as_secs())
16065 .unwrap_or(0);
16066 nstate.last_concierge_secs = now;
16067 let _ = nstate.save_to(&car_inference::NudgeState::default_path());
16068 }
16069}
16070
16071pub async fn run_idle_backend_eviction(state: &Arc<ServerState>) {
16084 let Some(engine) = state.inference.get() else {
16085 return;
16086 };
16087 let (entries, bytes) = engine.evict_idle_backends();
16088 if entries > 0 {
16089 tracing::info!(
16090 entries,
16091 mb = bytes / (1024 * 1024),
16092 "evicted idle model backends; resident model memory released"
16093 );
16094 }
16095 let stopped = engine.evict_idle_vllm_servers().await;
16099 if stopped > 0 {
16100 tracing::info!(stopped, "stopped idle vllm-mlx servers");
16101 }
16102 if let Some(idle_for) = car_inference::backend_cache::idle_ttl_from_env() {
16106 if let Some(offload) = car_inference::current_local_offload() {
16107 let released = offload.release_idle_models(idle_for).await;
16108 if !released.is_empty() {
16109 tracing::info!(
16110 model_ids = ?released,
16111 "released idle on-device inference worker residents"
16112 );
16113 }
16114 }
16115 }
16116}
16117
16118pub async fn broadcast_concierge_suggestion(
16122 state: &Arc<ServerState>,
16123 suggestion: &car_inference::ConciergeSuggestion,
16124) -> usize {
16125 use futures::SinkExt;
16126 use tokio_tungstenite::tungstenite::Message;
16127 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16128 .a2ui_subscribers
16129 .lock()
16130 .await
16131 .values()
16132 .cloned()
16133 .collect();
16134 if subscribers.is_empty() {
16135 return 0;
16136 }
16137 let Ok(json) = serde_json::to_string(&serde_json::json!({
16138 "jsonrpc": "2.0",
16139 "method": "models.suggestion_available",
16140 "params": suggestion,
16141 })) else {
16142 return 0;
16143 };
16144 let mut delivered = 0;
16145 for channel in subscribers {
16146 if channel
16147 .write
16148 .lock()
16149 .await
16150 .send(Message::Text(json.clone().into()))
16151 .await
16152 .is_ok()
16153 {
16154 delivered += 1;
16155 }
16156 }
16157 delivered
16158}
16159
16160struct PullProgressSink {
16164 tx: tokio::sync::mpsc::UnboundedSender<car_inference::DownloadEvent>,
16165}
16166
16167impl car_inference::DownloadProgress for PullProgressSink {
16168 fn on_event(&self, event: &car_inference::DownloadEvent) {
16169 let _ = self.tx.send(event.clone());
16171 }
16172}
16173
16174async fn handle_models_pull(
16175 msg: &JsonRpcMessage,
16176 session: &crate::session::ClientSession,
16177 state: &Arc<ServerState>,
16178) -> Result<Value, String> {
16179 require_approval_authority(session, state)?;
16180 let params: ModelPullParams = strict_model_params(msg)?;
16181 let name = params.name;
16182 if name.is_empty() || name.trim() != name {
16183 return Err(
16184 "invalid model-management params: model id must be non-empty exact UTF-8 without surrounding whitespace"
16185 .into(),
16186 );
16187 }
16188 let engine = Arc::clone(get_inference_engine(state));
16189 let operation_state = Arc::clone(state);
16190 let operation = state
16191 .spawn_durable_operation("models.pull", async move {
16192 run_models_pull(name, engine, operation_state).await
16193 })
16194 .await;
16195 operation.await.map_err(|_| {
16196 "daemon-owned models.pull operation stopped before publishing a result".to_string()
16197 })?
16198}
16199
16200async fn run_models_pull(
16201 name: String,
16202 engine: Arc<car_inference::InferenceEngine>,
16203 state: Arc<ServerState>,
16204) -> Result<Value, String> {
16205 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<car_inference::DownloadEvent>();
16211 let sink = car_inference::ProgressSink::new(Arc::new(PullProgressSink { tx }));
16212 let broadcaster_state = state.clone();
16213 let model_label = name.clone();
16214 let broadcaster = tokio::spawn(async move {
16215 while let Some(event) = rx.recv().await {
16216 broadcast_pull_progress(&broadcaster_state, &model_label, &event).await;
16217 }
16218 });
16219
16220 let result = engine.pull_model_with_progress(&name, &sink).await;
16221 drop(sink);
16223 if let Err(e) = broadcaster.await {
16225 tracing::warn!(error = %e, "pull-progress broadcaster task failed");
16226 }
16227
16228 let path = result.map_err(|e| e.to_string())?;
16229 Ok(serde_json::json!({"path": path.display().to_string()}))
16230}
16231
16232async fn broadcast_pull_progress(
16235 state: &Arc<ServerState>,
16236 model: &str,
16237 event: &car_inference::DownloadEvent,
16238) {
16239 use futures::SinkExt;
16240 use tokio_tungstenite::tungstenite::Message;
16241 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16242 .a2ui_subscribers
16243 .lock()
16244 .await
16245 .values()
16246 .cloned()
16247 .collect();
16248 if subscribers.is_empty() {
16249 return;
16250 }
16251 let Ok(json) = serde_json::to_string(&serde_json::json!({
16252 "jsonrpc": "2.0",
16253 "method": "models.pull_progress",
16254 "params": { "model": model, "event": event },
16255 })) else {
16256 return;
16257 };
16258 for channel in subscribers {
16259 let _ = channel
16260 .write
16261 .lock()
16262 .await
16263 .send(Message::Text(json.clone().into()))
16264 .await;
16265 }
16266}
16267
16268async fn handle_skills_distill(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
16269 let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
16270 msg.params
16271 .get("events")
16272 .cloned()
16273 .unwrap_or(msg.params.clone()),
16274 )
16275 .map_err(|e| format!("invalid events: {}", e))?;
16276
16277 let inference = get_inference_engine(state).clone();
16278 let engine = car_memgine::MemgineEngine::new(None).with_inference(inference);
16279
16280 let skills = engine.distill_skills(&events).await;
16281 serde_json::to_value(&skills).map_err(|e| e.to_string())
16282}
16283
16284async fn handle_memory_consolidate(
16288 session: &crate::session::ClientSession,
16289) -> Result<Value, String> {
16290 let engine_arc = session.effective_memgine().await;
16291 let report = {
16292 let mut engine = engine_arc.lock().await;
16293 engine.consolidate().await
16294 };
16295 if let Some(id) = session.agent_id.lock().await.clone() {
16296 if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
16297 tracing::warn!(agent_id = %id, error = %e,
16298 "agent memgine persist after consolidate failed");
16299 }
16300 }
16301 serde_json::to_value(&report).map_err(|e| e.to_string())
16302}
16303
16304async fn handle_memory_utility_get(
16319 session: &crate::session::ClientSession,
16320) -> Result<Value, String> {
16321 let engine = session.memgine.lock().await;
16322 let (weight, exploration) = engine.utility_retrieval();
16323 Ok(serde_json::json!({
16324 "utility_weight": weight,
16325 "utility_exploration": exploration,
16326 }))
16327}
16328
16329async fn handle_memory_utility_set(
16346 msg: &JsonRpcMessage,
16347 session: &crate::session::ClientSession,
16348) -> Result<Value, String> {
16349 let (weight, exploration) = {
16350 let mut engine = session.memgine.lock().await;
16351 let (cur_weight, cur_exploration) = engine.utility_retrieval();
16355 let weight = msg
16356 .params
16357 .get("utility_weight")
16358 .and_then(|v| v.as_f64())
16359 .unwrap_or(cur_weight);
16360 let exploration = msg
16361 .params
16362 .get("utility_exploration")
16363 .and_then(|v| v.as_f64())
16364 .unwrap_or(cur_exploration);
16365 engine.set_utility_retrieval(weight, exploration);
16366 engine.utility_retrieval()
16367 };
16368 Ok(serde_json::json!({
16369 "utility_weight": weight,
16370 "utility_exploration": exploration,
16371 }))
16372}
16373
16374async fn handle_cascade_run(
16405 req: &JsonRpcMessage,
16406 session: &crate::session::ClientSession,
16407 state: &ServerState,
16408) -> Result<Value, String> {
16409 use car_memgine::cascade::{run_cascade_async, CascadePolicy, CascadeTier, TierResult};
16410
16411 #[derive(serde::Deserialize)]
16412 struct ObservedTier {
16413 confidence: f64,
16414 #[serde(default)]
16415 knowledge: Option<String>,
16416 }
16417
16418 let current_confidence = req
16419 .params
16420 .get("current_confidence")
16421 .and_then(|v| v.as_f64())
16422 .unwrap_or(0.0);
16423 let policy_val = req.params.get("policy").ok_or("missing 'policy'")?;
16424 let policy: CascadePolicy =
16425 serde_json::from_value(policy_val.clone()).map_err(|e| format!("invalid 'policy': {e}"))?;
16426 let observed: std::collections::HashMap<CascadeTier, ObservedTier> = req
16427 .params
16428 .get("observed")
16429 .map(|v| serde_json::from_value(v.clone()))
16430 .transpose()
16431 .map_err(|e| format!("invalid 'observed': {e}"))?
16432 .unwrap_or_default();
16433 let claim = req
16434 .params
16435 .get("claim")
16436 .and_then(|v| v.as_str())
16437 .unwrap_or("")
16438 .to_string();
16439 let dry_run = req
16444 .params
16445 .get("dry_run")
16446 .and_then(|v| v.as_bool())
16447 .unwrap_or(false);
16448 let fingerprint = format!("cascade:{}", claim.trim());
16449
16450 let has_human_tier = policy
16455 .tiers
16456 .iter()
16457 .any(|t| t.tier == CascadeTier::HumanExpert);
16458 if has_human_tier && claim.trim().is_empty() {
16459 return Err(
16460 "a 'human_expert' tier requires a non-empty 'claim' (it keys the durable approval)"
16461 .into(),
16462 );
16463 }
16464
16465 let engine_arc = session.effective_memgine().await;
16469
16470 let observed_ref = &observed;
16471 let fp_ref = fingerprint.as_str();
16472 let run = move |tier: CascadeTier| {
16473 let engine_arc = engine_arc.clone();
16474 async move {
16475 let obs = observed_ref.get(&tier);
16476 let caller_confidence = obs.map(|o| o.confidence).unwrap_or(0.0);
16477 let caller_knowledge = obs.and_then(|o| o.knowledge.clone());
16478 let (knowledge, confidence) = match tier {
16484 CascadeTier::SelfReflect => {
16485 let knowledge = if dry_run {
16486 caller_knowledge
16487 .unwrap_or_else(|| "self_reflect: dry_run (no reflect)".to_string())
16488 } else {
16489 let report = {
16491 let mut engine = engine_arc.lock().await;
16492 engine.reflect().await
16493 };
16494 caller_knowledge.unwrap_or_else(|| {
16495 format!(
16496 "self_reflect: {} insight(s) ingested ({} corrections, {} preferences, {} friction)",
16497 report.insights_ingested,
16498 report.corrections_found,
16499 report.preferences_found,
16500 report.friction_points_found,
16501 )
16502 })
16503 };
16504 (knowledge, caller_confidence)
16505 }
16506 CascadeTier::ToolVerify => {
16507 let knowledge = caller_knowledge
16508 .unwrap_or_else(|| "tool_verify: caller-supplied".to_string());
16509 (knowledge, caller_confidence)
16510 }
16511 CascadeTier::HumanExpert => {
16512 let decision = {
16515 let ledger = state.approval_ledger.read().await;
16516 ledger.lookup(fp_ref).map(|r| r.decision)
16517 };
16518 match decision {
16519 Some(car_policy::ApprovalDecision::Rejected) => {
16521 return Err(format!("human_expert rejected: {fp_ref}"));
16522 }
16523 Some(car_policy::ApprovalDecision::Approved) => {
16527 let knowledge = caller_knowledge
16528 .unwrap_or_else(|| format!("human_expert: approved ({fp_ref})"));
16529 (knowledge, caller_confidence)
16530 }
16531 None => {
16536 let knowledge = caller_knowledge.unwrap_or_else(|| {
16537 format!("human_expert: awaiting approval ({fp_ref})")
16538 });
16539 (knowledge, 0.0)
16540 }
16541 }
16542 }
16543 };
16544 Ok::<TierResult, String>(TierResult {
16545 knowledge,
16546 confidence,
16547 })
16548 }
16549 };
16550
16551 let run_result = run_cascade_async(current_confidence, &policy, run).await?;
16552
16553 let human_ran = run_result
16557 .steps
16558 .iter()
16559 .any(|s| s.tier == CascadeTier::HumanExpert);
16560 let pending = if human_ran {
16561 state
16562 .approval_ledger
16563 .read()
16564 .await
16565 .lookup(&fingerprint)
16566 .is_none()
16567 } else {
16568 false
16569 };
16570
16571 let mut resp = serde_json::json!({ "run": run_result });
16572 if pending {
16573 resp.as_object_mut().unwrap().insert(
16574 "pending_approval".into(),
16575 serde_json::json!({ "fingerprint": fingerprint, "claim": claim }),
16576 );
16577 }
16578 Ok(resp)
16579}
16580
16581async fn assemble_evolution_components(
16596 session: &crate::session::ClientSession,
16597 state: &Arc<ServerState>,
16598) -> Vec<car_memgine::self_evolution::ComponentState> {
16599 let mut components = {
16603 let engine_arc = session.effective_memgine().await;
16604 let engine = engine_arc.lock().await;
16605 engine.evolution_component_states()
16606 };
16607 {
16608 let log = session.runtime.log.lock().await;
16609 if let Some(h) = crate::evolution::harness_component_from_events(log.events()) {
16610 components.push(h);
16611 }
16612 }
16613 state.ensure_connectors_loaded().await;
16614 let connectors = state.connectors().list().await;
16615 if let Some(t) = crate::evolution::tools_component_from_connectors(&connectors) {
16616 components.push(t);
16617 }
16618 components
16619}
16620
16621async fn handle_selfheal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16625 serde_json::to_value(state.selfheal.status().await).map_err(|error| error.to_string())
16626}
16627
16628async fn handle_selfheal_detections(
16632 req: &JsonRpcMessage,
16633 state: &Arc<ServerState>,
16634) -> Result<Value, String> {
16635 let query = serde_json::from_value::<crate::selfheal::DetectionQuery>(req.params.clone())
16636 .map_err(|error| format!("invalid selfheal.detections params: {error}"))?;
16637 serde_json::to_value(state.selfheal.detections(query).await).map_err(|error| error.to_string())
16638}
16639
16640async fn handle_selfheal_dismiss(
16644 req: &JsonRpcMessage,
16645 state: &Arc<ServerState>,
16646) -> Result<Value, String> {
16647 let key = req
16648 .params
16649 .get("dedup_key")
16650 .and_then(Value::as_str)
16651 .ok_or_else(|| "selfheal.dismiss requires string param 'dedup_key'".to_string())?;
16652 serde_json::to_value(state.selfheal.dismiss(key).await?).map_err(|error| error.to_string())
16653}
16654
16655async fn handle_selfheal_fix(
16657 req: &JsonRpcMessage,
16658 state: &Arc<ServerState>,
16659) -> Result<Value, String> {
16660 let key = req
16661 .params
16662 .get("dedup_key")
16663 .and_then(Value::as_str)
16664 .ok_or_else(|| "selfheal.fix requires string param 'dedup_key'".to_string())?;
16665 serde_json::to_value(state.selfheal.fix(key).await?).map_err(|error| error.to_string())
16666}
16667
16668async fn handle_heal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16674 serde_json::to_value(state.heal.status(get_inference_engine(state)))
16675 .map_err(|error| error.to_string())
16676}
16677
16678async fn handle_heal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16684 let owned = state.clone();
16692 let handle = tokio::spawn(async move { owned.heal.run_tick(&owned).await });
16693 match handle.await {
16694 Ok(result) => serde_json::to_value(result?).map_err(|error| error.to_string()),
16695 Err(e) => Err(format!("heal sweep task failed: {e}")),
16696 }
16697}
16698
16699async fn handle_selfheal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16704 serde_json::to_value(state.selfheal.run_tick(state).await?).map_err(|error| error.to_string())
16705}
16706
16707async fn handle_evolution_plan(
16715 req: &JsonRpcMessage,
16716 session: &crate::session::ClientSession,
16717 state: &Arc<ServerState>,
16718) -> Result<Value, String> {
16719 let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16720 req.params
16721 .get("policy")
16722 .cloned()
16723 .unwrap_or_else(|| serde_json::json!({})),
16724 )
16725 .map_err(|e| format!("invalid policy: {e}"))?;
16726 let components = assemble_evolution_components(session, state).await;
16727 let plan = car_memgine::self_evolution::plan_evolution(&components, &policy);
16728 serde_json::to_value(plan).map_err(|e| e.to_string())
16729}
16730
16731enum CandidateSource {
16851 Supplied(car_eventlog::harness_metrics::HarnessMetrics),
16853 Measured(car_eventlog::harness_metrics::HarnessMetrics),
16855 Failed(String),
16858 Unavailable(String),
16861}
16862
16863async fn handle_evolution_run(
16864 req: &JsonRpcMessage,
16865 session: &crate::session::ClientSession,
16866 state: &Arc<ServerState>,
16867) -> Result<Value, String> {
16868 use car_memgine::harness_evolution::{
16869 mutation_fingerprint, EvolutionAgent, Governance, PromotionDecision,
16870 };
16871 use car_memgine::self_evolution::{EvolutionOutcome, EvolvableComponent};
16872
16873 let _cycle_token = session
16878 .evolution_guard
16879 .try_begin()
16880 .ok_or("evolution.run already in flight on this session")?;
16881
16882 let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16883 req.params
16884 .get("policy")
16885 .cloned()
16886 .unwrap_or_else(|| serde_json::json!({})),
16887 )
16888 .map_err(|e| format!("invalid policy: {e}"))?;
16889 let dry_run = req
16890 .params
16891 .get("dry_run")
16892 .and_then(|v| v.as_bool())
16893 .unwrap_or(false);
16894 let baseline_override: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16895 match req.params.get("harness_baseline_metrics") {
16896 Some(v) if !v.is_null() => Some(
16897 serde_json::from_value(v.clone())
16898 .map_err(|e| format!("invalid harness_baseline_metrics: {e}"))?,
16899 ),
16900 _ => None,
16901 };
16902 let candidate_metrics: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16903 match req.params.get("harness_candidate_metrics") {
16904 Some(v) if !v.is_null() => Some(
16905 serde_json::from_value(v.clone())
16906 .map_err(|e| format!("invalid harness_candidate_metrics: {e}"))?,
16907 ),
16908 _ => None,
16909 };
16910
16911 let measure_request = crate::evolution::parse_harness_measure_request(&req.params)?;
16914
16915 if measure_request.is_some() && (baseline_override.is_some() || candidate_metrics.is_some()) {
16919 let supplied = match (baseline_override.is_some(), candidate_metrics.is_some()) {
16920 (true, true) => "harness_baseline_metrics and harness_candidate_metrics",
16921 (true, false) => "harness_baseline_metrics",
16922 _ => "harness_candidate_metrics",
16923 };
16924 return Err(format!(
16925 "harness_measure cannot be combined with {supplied}: harness_measure makes the \
16926 daemon measure the baseline and every candidate itself, so supplied metrics would \
16927 have to be either ignored or silently preferred. Send one or the other."
16928 ));
16929 }
16930
16931 let measuring = measure_request.is_some() && !dry_run;
16934
16935 let measurer = if measuring {
16939 Some(state.harness_measurer().ok_or(
16940 "harness_measure requested but this daemon build has no in-process harness \
16941 evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16942 startup; embedders must call ServerState::set_harness_measurer)",
16943 )?)
16944 } else {
16945 None
16946 };
16947
16948 let context_measure_request = crate::evolution::parse_context_measure_request(&req.params)?;
16968 let context_measurer = match context_measure_request.as_ref() {
16969 Some(_) => Some(state.harness_measurer().ok_or(
16970 "context_measure requested but this daemon build has no in-process harness \
16971 evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16972 startup; embedders must call ServerState::set_harness_measurer)",
16973 )?),
16974 None => None,
16975 };
16976
16977 let live_harness_config = session.runtime.harness_config().await;
16983
16984 let mut baseline_measurement_error: Option<String> = None;
16988 let mut measured_baseline: Option<car_eventlog::harness_metrics::HarnessMetrics> = None;
16989 if let (Some(m), Some(rq)) = (measurer.as_ref(), measure_request.as_ref()) {
16990 match crate::evolution::measure_baseline(m.as_ref(), rq, live_harness_config.as_ref()).await
16991 {
16992 Ok(metrics) => measured_baseline = Some(metrics),
16993 Err(e) => baseline_measurement_error = Some(e),
16994 }
16995 }
16996
16997 let mut components = assemble_evolution_components(session, state).await;
16998 let engine_arc = session.effective_memgine().await;
17001
17002 let (failed_events, live_metrics) = {
17004 let log = session.runtime.log.lock().await;
17005 (
17006 crate::evolution::failed_trace_events(log.events()),
17007 car_eventlog::harness_metrics::compute_harness_metrics(log.events()),
17008 )
17009 };
17010 let has_baseline_override = baseline_override.is_some() || measured_baseline.is_some();
17014 let baseline_metrics = measured_baseline
17015 .clone()
17016 .or(baseline_override)
17017 .unwrap_or(live_metrics);
17018 if has_baseline_override {
17023 components
17024 .retain(|c| c.component != car_memgine::self_evolution::EvolvableComponent::Harness);
17025 if let Some(h) = crate::evolution::harness_component_from_metrics(&baseline_metrics) {
17026 components.push(h);
17027 }
17028 }
17029 let harness_mutations = EvolutionAgent::new().diagnose(&baseline_metrics);
17030
17031 let measurement_summary: Option<Value> = measure_request.as_ref().map(|rq| {
17041 let mut o = serde_json::json!({
17042 "split": rq.split,
17043 "model": rq.model,
17044 "split_seed": rq.split_seed,
17045 });
17046 let obj = o.as_object_mut().expect("object literal");
17047 if dry_run {
17048 obj.insert("status".into(), Value::from("skipped_dry_run"));
17049 } else if let Some(b) = &measured_baseline {
17050 obj.insert("status".into(), Value::from("measured"));
17051 obj.insert(
17052 "baseline_metrics".into(),
17053 serde_json::to_value(b).unwrap_or(Value::Null),
17054 );
17055 } else {
17056 obj.insert("status".into(), Value::from("measurement_failed"));
17057 obj.insert(
17058 "error".into(),
17059 baseline_measurement_error
17060 .clone()
17061 .map(Value::from)
17062 .unwrap_or(Value::Null),
17063 );
17064 }
17065 o
17066 });
17067
17068 let pending_approvals: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
17069 let failed_events = &failed_events;
17070 let harness_mutations = &harness_mutations;
17071 let baseline_metrics = &baseline_metrics;
17072 let candidate_metrics = &candidate_metrics;
17073 let pending_ref = &pending_approvals;
17074 let engine_ref = &engine_arc;
17075 let measurer_ref = &measurer;
17076 let measure_request_ref = &measure_request;
17077 let context_measurer_ref = &context_measurer;
17078 let context_measure_request_ref = &context_measure_request;
17079 let base_harness_config = live_harness_config.clone().unwrap_or_default();
17083 let base_harness_config = &base_harness_config;
17084 let baseline_measurement_error = &baseline_measurement_error;
17085 let measurement_summary = &measurement_summary;
17086
17087 let run = |component: EvolvableComponent| {
17088 let engine = engine_ref.clone();
17089 async move {
17090 match component {
17091 EvolvableComponent::Memory => {
17092 crate::evolution::run_memory_evolution(&engine, dry_run).await
17093 }
17094 EvolvableComponent::Skills => {
17095 crate::evolution::run_skills_evolution(&engine, failed_events, dry_run).await
17096 }
17097 EvolvableComponent::Harness => {
17098 if let Some(e) = baseline_measurement_error {
17102 return Err(e.clone());
17103 }
17104 if harness_mutations.is_empty() {
17105 return Ok(EvolutionOutcome::no_op(
17106 "no harness mutations diagnosed from session telemetry",
17107 ));
17108 }
17109 let agent = EvolutionAgent::new();
17110 let mut details: Vec<Value> = Vec::new();
17111 let mut applied = 0usize;
17112 let mut pending = 0usize;
17113 for m in harness_mutations {
17114 let fingerprint = mutation_fingerprint(m);
17119 let prior = {
17123 let ledger = state.approval_ledger.read().await;
17124 ledger.lookup(&fingerprint).map(|r| r.decision)
17125 };
17126 let status: Value = match prior {
17133 Some(car_policy::ApprovalDecision::Rejected) => {
17134 serde_json::json!({ "status": "rejected_by_operator" })
17135 }
17136 Some(car_policy::ApprovalDecision::Approved) => {
17137 if m.patch.is_none() {
17138 serde_json::json!({
17139 "status": "approved_no_patch",
17140 "note": "approved but carries no concrete config patch — a human designs this change",
17141 })
17142 } else if dry_run {
17143 serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
17144 } else {
17145 match session
17148 .runtime
17149 .update_harness_config(|cfg| {
17150 cfg.apply(m, Governance::HumanApproved)
17151 })
17152 .await
17153 {
17154 Ok(inverse) => {
17155 applied += 1;
17156 serde_json::json!({
17157 "status": "applied",
17158 "governance": "human_approved",
17159 "rollback_patch": inverse,
17160 })
17161 }
17162 Err(e) => serde_json::json!({
17163 "status": "apply_failed", "error": e,
17164 }),
17165 }
17166 }
17167 }
17168 None => {
17169 const NO_CANDIDATE_REASON: &str = "no held-out candidate telemetry supplied — the regression gate needs measured post-mutation metrics (pass harness_candidate_metrics, or harness_measure to have the daemon measure them), so activation requires operator approval";
17174 const DRY_RUN_REASON: &str = "harness_measure was requested with dry_run — a benchmark replay is a paid side effect and dry_run performs none, so nothing was measured and activation requires operator approval";
17175 const NO_PATCH_REASON: &str = "carries no concrete config patch, so there is nothing to apply and nothing to measure — a human designs this change";
17176 const SAFETY_SKIP_REASON: &str = "measurement skipped: the component is safety-affecting, so it can never auto-promote however it measures — spending a benchmark replay to reach an outcome already decided would be waste. Activation requires operator approval";
17177
17178 let source = if let Some(cand) = candidate_metrics {
17179 CandidateSource::Supplied(cand.clone())
17180 } else if let (Some(measurer), Some(rq)) =
17181 (measurer_ref.as_ref(), measure_request_ref.as_ref())
17182 {
17183 match m.patch.as_ref().filter(|p| !p.is_empty()) {
17184 None => CandidateSource::Unavailable(
17185 NO_PATCH_REASON.to_string(),
17186 ),
17187 Some(_) if m.requires_human_approval() => {
17188 CandidateSource::Unavailable(
17189 SAFETY_SKIP_REASON.to_string(),
17190 )
17191 }
17192 Some(patch) => {
17193 match crate::evolution::measure_candidate(
17194 measurer.as_ref(),
17195 rq,
17196 base_harness_config,
17197 patch,
17198 )
17199 .await
17200 {
17201 Ok(metrics) => CandidateSource::Measured(metrics),
17202 Err(e) => CandidateSource::Failed(e),
17203 }
17204 }
17205 }
17206 } else if measure_request_ref.is_some() {
17207 CandidateSource::Unavailable(DRY_RUN_REASON.to_string())
17208 } else {
17209 CandidateSource::Unavailable(NO_CANDIDATE_REASON.to_string())
17210 };
17211
17212 let measured_here = matches!(source, CandidateSource::Measured(_));
17216
17217 match source {
17218 CandidateSource::Supplied(ref cand)
17219 | CandidateSource::Measured(ref cand) => {
17220 let decision = agent.evaluate(m, baseline_metrics, cand);
17221 let mut status = match &decision {
17222 PromotionDecision::Promote { reason } => {
17223 let reason = reason.clone();
17224 if dry_run {
17225 serde_json::json!({ "status": "would_apply", "governance": "promoted", "reason": reason })
17226 } else {
17227 match session
17228 .runtime
17229 .update_harness_config(|cfg| {
17230 cfg.apply(
17231 m,
17232 Governance::Promoted(
17233 decision.clone(),
17234 ),
17235 )
17236 })
17237 .await
17238 {
17239 Ok(inverse) => {
17240 applied += 1;
17241 serde_json::json!({
17242 "status": "applied",
17243 "governance": "promoted",
17244 "reason": reason,
17245 "rollback_patch": inverse,
17246 })
17247 }
17248 Err(e) => serde_json::json!({
17249 "status": "apply_failed", "error": e,
17250 }),
17251 }
17252 }
17253 }
17254 PromotionDecision::NeedsApproval { reason } => {
17255 pending += 1;
17256 pending_ref.lock().unwrap().push(
17257 serde_json::json!({
17258 "fingerprint": fingerprint,
17259 "mutation": m.id,
17260 "component": m.contract.component,
17261 "safety_affecting": true,
17262 "rationale": m.rationale,
17263 "reason": reason,
17264 }),
17265 );
17266 serde_json::json!({ "status": "pending_approval", "reason": reason })
17267 }
17268 PromotionDecision::Reject { reason } => {
17269 serde_json::json!({ "status": "rejected_by_gate", "reason": reason })
17270 }
17271 PromotionDecision::Incomparable { reason } => {
17282 serde_json::json!({ "status": "incomparable", "reason": reason })
17283 }
17284 };
17285 if measured_here {
17286 if let Some(obj) = status.as_object_mut() {
17287 obj.insert(
17288 "candidate_metrics".into(),
17289 serde_json::to_value(cand)
17290 .unwrap_or(Value::Null),
17291 );
17292 }
17293 }
17294 status
17295 }
17296 CandidateSource::Failed(error) => serde_json::json!({
17301 "status": "measurement_failed",
17302 "error": error,
17303 }),
17304 CandidateSource::Unavailable(reason) => {
17310 pending += 1;
17311 pending_ref.lock().unwrap().push(serde_json::json!({
17312 "fingerprint": fingerprint,
17313 "mutation": m.id,
17314 "component": m.contract.component,
17315 "safety_affecting": m.requires_human_approval(),
17316 "rationale": m.rationale,
17317 "reason": reason,
17318 }));
17319 serde_json::json!({
17320 "status": "pending_approval",
17321 "reason": reason,
17322 })
17323 }
17324 }
17325 }
17326 };
17327 let mut d = serde_json::json!({
17328 "mutation": m.id,
17329 "component": m.contract.component,
17330 "fingerprint": fingerprint,
17331 "rationale": m.rationale,
17332 });
17333 if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
17334 for (k, v) in s {
17335 obj.insert(k.clone(), v.clone());
17336 }
17337 }
17338 details.push(d);
17339 }
17340 let mut summary_obj = serde_json::json!({
17341 "mechanism": "harness_evolution",
17342 "mutations": harness_mutations.len(),
17343 "applied": applied,
17344 "pending": pending,
17345 "details": details,
17346 });
17347 if let (Some(obj), Some(measurement)) =
17348 (summary_obj.as_object_mut(), measurement_summary.as_ref())
17349 {
17350 obj.insert("measurement".into(), measurement.clone());
17351 }
17352 let summary = serde_json::to_string(&summary_obj).map_err(|e| e.to_string())?;
17353 Ok(if applied > 0 {
17356 EvolutionOutcome::applied(summary)
17357 } else {
17358 EvolutionOutcome::no_op(summary)
17359 })
17360 }
17361 EvolvableComponent::Context => {
17362 let context_measure = match (
17373 context_measurer_ref.as_ref(),
17374 context_measure_request_ref.as_ref(),
17375 ) {
17376 (Some(m), Some(rq)) => {
17377 Some((m.as_ref() as &dyn crate::evolution::HarnessMeasurer, rq))
17378 }
17379 _ => None,
17380 };
17381 crate::evolution::run_context_evolution(
17382 &engine,
17383 state,
17384 dry_run,
17385 pending_ref,
17386 None,
17387 context_measure,
17388 )
17389 .await
17390 }
17391 EvolvableComponent::Tools => Ok(EvolutionOutcome::out_of_scope(
17396 crate::evolution::TOOLS_OUT_OF_SCOPE_REASON,
17397 )),
17398 }
17399 }
17400 };
17401
17402 let report = car_memgine::self_evolution::run_evolution_cycle(&components, &policy, run).await;
17403
17404 if !dry_run {
17405 let mut data: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
17406 data.insert("source".into(), Value::from("evolution.run"));
17407 data.insert(
17408 "evolve_now".into(),
17409 serde_json::to_value(&report.plan.evolve_now).unwrap_or(Value::Null),
17410 );
17411 data.insert(
17412 "evolved".into(),
17413 serde_json::to_value(&report.evolved).unwrap_or(Value::Null),
17414 );
17415 session.runtime.log.lock().await.append(
17416 car_eventlog::EventKind::EvolutionTriggered,
17417 None,
17418 None,
17419 data,
17420 );
17421 if !report.evolved.is_empty() {
17424 if let Some(id) = session.agent_id.lock().await.clone() {
17425 if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
17426 tracing::warn!(agent_id = %id, error = %e,
17427 "agent memgine persist after evolution.run failed; in-memory state is canonical");
17428 }
17429 }
17430 }
17431 }
17432
17433 let pending = pending_approvals.into_inner().unwrap();
17434 let mut resp = serde_json::json!({
17435 "plan": report.plan,
17436 "steps": report.steps,
17437 "evolved": report.evolved,
17438 "out_of_scope": report.out_of_scope,
17442 });
17443 if !pending.is_empty() {
17444 resp.as_object_mut()
17445 .unwrap()
17446 .insert("pending_approvals".into(), Value::Array(pending));
17447 }
17448 if let Some(measurement) = measurement_summary.as_ref() {
17469 resp.as_object_mut()
17470 .unwrap()
17471 .insert("measurement".into(), measurement.clone());
17472 }
17473 Ok(resp)
17474}
17475
17476async fn handle_skill_repair(
17480 msg: &JsonRpcMessage,
17481 session: &crate::session::ClientSession,
17482) -> Result<Value, String> {
17483 let name = msg
17484 .params
17485 .get("skill_name")
17486 .and_then(|v| v.as_str())
17487 .ok_or("missing 'skill_name' parameter")?;
17488 let mut engine = session.memgine.lock().await;
17489 let code = engine.repair_skill(name).await;
17490 Ok(match code {
17491 Some(c) => serde_json::json!({ "code": c }),
17492 None => Value::Null,
17493 })
17494}
17495
17496async fn handle_skills_ingest_distilled(
17499 msg: &JsonRpcMessage,
17500 session: &crate::session::ClientSession,
17501) -> Result<Value, String> {
17502 let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17503 msg.params
17504 .get("skills")
17505 .cloned()
17506 .unwrap_or(msg.params.clone()),
17507 )
17508 .map_err(|e| format!("invalid skills: {}", e))?;
17509 let mut engine = session.memgine.lock().await;
17510 let nodes = engine.ingest_distilled_skills(&skills);
17511 Ok(serde_json::json!({ "ingested": nodes.len() }))
17512}
17513
17514async fn handle_skills_evolve(
17517 msg: &JsonRpcMessage,
17518 session: &crate::session::ClientSession,
17519) -> Result<Value, String> {
17520 let domain = msg
17521 .params
17522 .get("domain")
17523 .and_then(|v| v.as_str())
17524 .ok_or("missing 'domain' parameter")?
17525 .to_string();
17526 let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
17527 msg.params
17528 .get("events")
17529 .cloned()
17530 .unwrap_or(Value::Array(vec![])),
17531 )
17532 .map_err(|e| format!("invalid events: {}", e))?;
17533 let mut engine = session.memgine.lock().await;
17534 let skills = engine.evolve_skills(&events, &domain).await;
17535 serde_json::to_value(&skills).map_err(|e| e.to_string())
17536}
17537
17538async fn handle_skills_domains_needing_evolution(
17540 msg: &JsonRpcMessage,
17541 session: &crate::session::ClientSession,
17542) -> Result<Value, String> {
17543 let threshold = msg
17544 .params
17545 .get("threshold")
17546 .and_then(|v| v.as_f64())
17547 .unwrap_or(0.6);
17548 let engine = session.memgine.lock().await;
17549 let domains = engine.domains_needing_evolution(threshold);
17550 serde_json::to_value(&domains).map_err(|e| e.to_string())
17551}
17552
17553async fn handle_skills_ingest_provisional(
17559 msg: &JsonRpcMessage,
17560 session: &crate::session::ClientSession,
17561) -> Result<Value, String> {
17562 let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17563 msg.params
17564 .get("skills")
17565 .cloned()
17566 .unwrap_or(msg.params.clone()),
17567 )
17568 .map_err(|e| format!("invalid skills: {}", e))?;
17569 let tenant = msg.params.get("tenant").and_then(|v| v.as_str());
17570 let mut engine = session.memgine.lock().await;
17571 let ingested = engine.ingest_provisional_candidates(&skills, tenant);
17572 Ok(serde_json::json!({ "ingested": ingested }))
17573}
17574
17575async fn handle_skills_gate(
17580 _msg: &JsonRpcMessage,
17581 session: &crate::session::ClientSession,
17582) -> Result<Value, String> {
17583 let mut engine = session.memgine.lock().await;
17584 let (promoted, rejected) = engine.gate_skill_candidates();
17585 Ok(serde_json::json!({ "promoted": promoted, "rejected": rejected }))
17586}
17587
17588async fn handle_skill_meta(
17592 msg: &JsonRpcMessage,
17593 session: &crate::session::ClientSession,
17594) -> Result<Value, String> {
17595 let key = msg
17596 .params
17597 .get("key")
17598 .and_then(|v| v.as_str())
17599 .ok_or("missing 'key' parameter")?;
17600 let engine = session.memgine.lock().await;
17601 match engine.skill_meta(key) {
17602 Some(meta) => serde_json::to_value(&meta).map_err(|e| e.to_string()),
17603 None => Ok(Value::Null),
17604 }
17605}
17606
17607async fn handle_skill_export(
17611 msg: &JsonRpcMessage,
17612 session: &crate::session::ClientSession,
17613) -> Result<Value, String> {
17614 let key = msg
17615 .params
17616 .get("key")
17617 .and_then(|v| v.as_str())
17618 .ok_or("missing 'key' parameter")?;
17619 let engine = session.memgine.lock().await;
17620 Ok(engine
17621 .export_skill(key)
17622 .map(Value::String)
17623 .unwrap_or(Value::Null))
17624}
17625
17626async fn handle_skill_import(
17630 msg: &JsonRpcMessage,
17631 session: &crate::session::ClientSession,
17632) -> Result<Value, String> {
17633 let md = msg
17634 .params
17635 .get("markdown")
17636 .and_then(|v| v.as_str())
17637 .ok_or("missing 'markdown' parameter")?;
17638 let mut engine = session.memgine.lock().await;
17639 engine.import_skill_markdown(md)?;
17640 Ok(serde_json::json!({ "imported": true }))
17641}
17642
17643async fn handle_rerank(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17645 let engine = get_inference_engine(state);
17646 let req: car_inference::RerankRequest = typed_params(&msg.params)?;
17647 let _permit = state.admission.acquire().await;
17648 let result = engine.rerank(req).await.map_err(|e| e.to_string())?;
17649 serde_json::to_value(&result).map_err(|e| e.to_string())
17650}
17651
17652async fn handle_search(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17661 let req: car_inference::search::SearchRequest =
17662 serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17663 let _permit = state.admission.acquire().await;
17664 let result = car_inference::search::web_search(&req.query, req.max_results)
17665 .await
17666 .map_err(|e| e.to_string())?;
17667 serde_json::to_value(&result).map_err(|e| e.to_string())
17668}
17669
17670async fn handle_web_fetch(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17673 let req: car_inference::search::FetchRequest =
17674 serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17675 let _permit = state.admission.acquire().await;
17676 let result = car_inference::search::web_fetch(&req.url)
17677 .await
17678 .map_err(|e| e.to_string())?;
17679 serde_json::to_value(&result).map_err(|e| e.to_string())
17680}
17681
17682async fn handle_transcribe(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17683 use base64::Engine as _;
17684 let engine = get_inference_engine(state);
17685
17686 let mut params = msg.params.clone();
17693 let audio_b64 = params
17694 .as_object_mut()
17695 .and_then(|m| m.remove("audio_b64"))
17696 .and_then(|v| v.as_str().map(str::to_string));
17697 let _tmp_audio = if let Some(b64) = audio_b64 {
17698 let bytes = base64::engine::general_purpose::STANDARD
17699 .decode(b64.as_bytes())
17700 .map_err(|e| format!("audio_b64 decode failed: {e}"))?;
17701 let tmp = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
17702 std::fs::write(tmp.path(), &bytes).map_err(|e| e.to_string())?;
17703 let path = tmp.path().to_string_lossy().into_owned();
17704 if let Some(obj) = params.as_object_mut() {
17705 obj.insert("audio_path".to_string(), Value::String(path));
17706 }
17707 Some(tmp)
17708 } else {
17709 None
17710 };
17711
17712 let req: car_inference::TranscribeRequest =
17713 serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17714 let _permit = state.admission.acquire().await;
17715 let result = engine.transcribe(req).await.map_err(|e| e.to_string())?;
17716 serde_json::to_value(&result).map_err(|e| e.to_string())
17717}
17718
17719async fn handle_synthesize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17725 use base64::Engine as _;
17726 let engine = get_inference_engine(state);
17727
17728 let mut params = msg.params.clone();
17729 let return_b64 = params
17730 .as_object_mut()
17731 .and_then(|m| m.remove("return_b64"))
17732 .and_then(|v| v.as_bool())
17733 .unwrap_or(false);
17734 let no_output_path = params
17735 .as_object()
17736 .map(|m| !m.contains_key("output_path"))
17737 .unwrap_or(true);
17738
17739 let req: car_inference::SynthesizeRequest =
17740 serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17741 let _permit = state.admission.acquire().await;
17742 let result = engine.synthesize(req).await.map_err(|e| e.to_string())?;
17743 let mut value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
17744
17745 if return_b64 || no_output_path {
17749 let bytes = std::fs::read(&result.audio_path).map_err(|e| {
17750 format!(
17751 "synthesize: failed to read rendered audio at {}: {e}",
17752 result.audio_path
17753 )
17754 })?;
17755 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
17756 if let Some(obj) = value.as_object_mut() {
17757 obj.insert("audio_b64".to_string(), Value::String(encoded));
17758 }
17759 }
17760 Ok(value)
17761}
17762
17763async fn handle_speech_prepare(state: &ServerState) -> Result<Value, String> {
17770 let engine = get_inference_engine(state);
17771 let status = engine
17772 .prepare_speech_runtime()
17773 .await
17774 .map_err(|e| e.to_string())?;
17775 serde_json::to_value(&status).map_err(|e| e.to_string())
17776}
17777
17778async fn handle_models_route(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17781 let prompt = msg
17782 .params
17783 .get("prompt")
17784 .and_then(|v| v.as_str())
17785 .ok_or("missing 'prompt' parameter")?;
17786 let intent: Option<car_inference::IntentHint> = match msg.params.get("intent") {
17789 Some(v) if !v.is_null() => {
17790 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid 'intent': {e}"))?)
17791 }
17792 _ => None,
17793 };
17794 let engine = get_inference_engine(state);
17795 let decision = engine.route_adaptive_with_intent(prompt, intent).await;
17796 serde_json::to_value(&decision).map_err(|e| e.to_string())
17797}
17798
17799async fn handle_models_stats(state: &ServerState) -> Result<Value, String> {
17801 let engine = get_inference_engine(state);
17802 let profiles = engine.export_profiles().await;
17803 Ok(serde_json::json!({ "profiles": models_stats_view(&profiles) }))
17804}
17805
17806async fn handle_outcomes_scoreboard(state: &ServerState) -> Result<Value, String> {
17814 let engine = get_inference_engine(state);
17815 let scoreboard = engine.outcome_scoreboard();
17816 serde_json::to_value(&scoreboard).map_err(|e| e.to_string())
17817}
17818
17819fn models_stats_view(profiles: &[car_inference::ModelProfile]) -> Vec<Value> {
17828 profiles
17829 .iter()
17830 .map(|p| {
17831 let mut v = serde_json::to_value(p).expect("ModelProfile is infallibly serializable");
17832 if let Some(obj) = v.as_object_mut() {
17833 obj.insert(
17834 "success_rate".into(),
17835 serde_json::json!(p.success_rate_resolved()),
17836 );
17837 obj.insert(
17838 "avg_latency_ms".into(),
17839 serde_json::json!(p.avg_latency_ms()),
17840 );
17841 }
17842 v
17843 })
17844 .collect()
17845}
17846
17847#[cfg(test)]
17848mod models_stats_view_tests {
17849 use super::models_stats_view;
17850
17851 #[test]
17852 fn success_rate_is_null_until_resolved_then_the_real_ratio() {
17853 let mut unmeasured = car_inference::ModelProfile::new("m-new".into());
17854 unmeasured.total_calls = 5; let mut measured = car_inference::ModelProfile::new("m-seen".into());
17857 measured.total_calls = 4;
17858 measured.success_count = 3;
17859 measured.fail_count = 1;
17860
17861 let view = models_stats_view(&[unmeasured, measured]);
17862
17863 assert!(view[0]["success_rate"].is_null());
17865 assert_eq!(view[0]["success_count"], 0);
17866 assert_eq!(view[0]["total_calls"], 5);
17867
17868 assert_eq!(view[1]["success_rate"], 0.75);
17870 assert_eq!(view[1]["fail_count"], 1);
17871 assert_eq!(view[1]["model_id"], "m-seen");
17872 }
17873
17874 #[test]
17875 fn view_is_non_lossy_and_round_trips_into_model_profile() {
17876 let mut p = car_inference::ModelProfile::new("m".into());
17882 p.total_calls = 2;
17883 p.success_count = 1;
17884 p.total_output_tokens = 4096;
17885
17886 let view = models_stats_view(&[p]);
17887 assert_eq!(view[0]["avg_latency_ms"], 0.0); let round: Vec<car_inference::ModelProfile> =
17889 serde_json::from_value(serde_json::Value::Array(view)).expect("round-trips");
17890 assert_eq!(round[0].total_output_tokens, 4096, "field not dropped");
17891 assert_eq!(round[0].model_id, "m");
17892 }
17893}
17894
17895#[derive(Deserialize)]
17896#[serde(rename_all = "camelCase")]
17897struct OutcomesResolvePendingParams {
17898 action_results: Vec<(String, bool, f64, String)>,
17903}
17904
17905async fn handle_outcomes_resolve_pending(
17925 req: &JsonRpcMessage,
17926 state: &ServerState,
17927) -> Result<Value, String> {
17928 let params: OutcomesResolvePendingParams =
17929 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17930 let engine = get_inference_engine(state);
17931 let mut tracker = engine.outcome_tracker.write().await;
17932 let inferred = tracker.infer_outcomes_from_action_sequence(¶ms.action_results);
17933 tracker.resolve_pending_from_signals(inferred);
17934 Ok(serde_json::json!({ "recorded": params.action_results.len() }))
17935}
17936
17937async fn handle_events_count(session: &crate::session::ClientSession) -> Result<Value, String> {
17939 let n = session.runtime.log.lock().await.len();
17940 Ok(Value::from(n as u64))
17941}
17942
17943async fn handle_events_query(
17953 msg: &JsonRpcMessage,
17954 session: &crate::session::ClientSession,
17955) -> Result<Value, String> {
17956 let query: car_eventlog::EventQuery = if msg.params.is_null() {
17957 Default::default()
17958 } else {
17959 serde_json::from_value(msg.params.clone())
17960 .map_err(|e| format!("invalid events.query params: {e}"))?
17961 };
17962 let log = session.runtime.log.lock().await;
17963 let events = log.query(&query);
17964 Ok(serde_json::json!({
17965 "count": events.len(),
17966 "events": events,
17967 }))
17968}
17969
17970enum NlpOp {
17972 IdentifyLanguage,
17973 Tokenize,
17974 ExtractEntities,
17975}
17976
17977fn handle_nlp(req: &JsonRpcMessage, op: NlpOp) -> Result<Value, String> {
17983 let text = req
17984 .params
17985 .get("text")
17986 .and_then(|v| v.as_str())
17987 .ok_or("missing 'text'")?;
17988 let json = match op {
17989 NlpOp::IdentifyLanguage => car_ffi_common::nlp::identify_language(text),
17990 NlpOp::Tokenize => car_ffi_common::nlp::tokenize_words(text),
17991 NlpOp::ExtractEntities => car_ffi_common::nlp::extract_named_entities(text),
17992 }?;
17993 serde_json::from_str(&json).map_err(|e| format!("nlp result parse: {e}"))
17994}
17995
17996async fn handle_metrics_summary(session: &crate::session::ClientSession) -> Result<Value, String> {
18001 let log = session.runtime.log.lock().await;
18002 let summary = car_eventlog::summarize_log(&log);
18005 serde_json::to_value(&summary).map_err(|e| format!("serialize metrics.summary: {e}"))
18006}
18007
18008async fn handle_metrics_alerts(
18017 req: &JsonRpcMessage,
18018 session: &crate::session::ClientSession,
18019) -> Result<Value, String> {
18020 let thresholds: car_eventlog::AlertThresholds = if req.params.is_null() {
18021 Default::default()
18022 } else {
18023 serde_json::from_value(req.params.clone())
18024 .map_err(|e| format!("invalid metrics.alerts thresholds: {e}"))?
18025 };
18026 let log = session.runtime.log.lock().await;
18027 let summary = car_eventlog::summarize_log(&log);
18030 let alerts = car_eventlog::evaluate_alerts(&summary, &thresholds);
18031 for a in &alerts {
18032 tracing::warn!(target: "car::alerts", kind = ?a.kind, "operational alert: {}", a.message);
18033 }
18034 Ok(serde_json::json!({ "summary": summary, "alerts": alerts }))
18035}
18036
18037async fn handle_events_cost_by_agent(
18043 session: &crate::session::ClientSession,
18044) -> Result<Value, String> {
18045 let log = session.runtime.log.lock().await;
18046 let report = log.cost_by_agent();
18047 serde_json::to_value(&report).map_err(|e| format!("serialize cost_by_agent: {e}"))
18048}
18049
18050async fn handle_events_retention(
18056 msg: &JsonRpcMessage,
18057 session: &crate::session::ClientSession,
18058) -> Result<Value, String> {
18059 let mut log = session.runtime.log.lock().await;
18060 if let Some(p) = msg.params.get("policy") {
18061 let policy: car_eventlog::RetentionPolicy = serde_json::from_value(p.clone())
18062 .map_err(|e| format!("invalid retention policy: {e}"))?;
18063 let removed = log.enforce_retention(&policy, chrono::Utc::now());
18064 log.set_retention(Some(policy.clone()));
18065 Ok(serde_json::json!({ "policy": policy, "removed": removed }))
18066 } else {
18067 Ok(serde_json::json!({ "policy": log.retention() }))
18068 }
18069}
18070
18071async fn handle_events_chain_enable(
18076 session: &crate::session::ClientSession,
18077) -> Result<Value, String> {
18078 session.runtime.enable_event_log_hash_chaining().await;
18079 Ok(serde_json::json!({ "ok": true }))
18080}
18081
18082async fn handle_events_chain_verify(
18088 session: &crate::session::ClientSession,
18089) -> Result<Value, String> {
18090 match session.runtime.verify_event_log_chain().await {
18091 Ok(n) => Ok(serde_json::json!({ "verified": n })),
18092 Err(i) => Ok(serde_json::json!({ "tampered_at": i })),
18093 }
18094}
18095
18096async fn handle_events_stats(session: &crate::session::ClientSession) -> Result<Value, String> {
18097 let stats = session.runtime.log.lock().await.stats();
18098 serde_json::to_value(stats).map_err(|e| e.to_string())
18099}
18100
18101#[derive(Deserialize)]
18102#[serde(rename_all = "camelCase")]
18103struct EventsTruncateParams {
18104 #[serde(default)]
18105 max_events: Option<usize>,
18106 #[serde(default)]
18107 max_spans: Option<usize>,
18108}
18109
18110async fn handle_events_truncate(
18111 msg: &JsonRpcMessage,
18112 session: &crate::session::ClientSession,
18113) -> Result<Value, String> {
18114 let params: EventsTruncateParams =
18115 serde_json::from_value(msg.params.clone()).unwrap_or(EventsTruncateParams {
18116 max_events: None,
18117 max_spans: None,
18118 });
18119 let mut log = session.runtime.log.lock().await;
18120 let removed_events = params
18121 .max_events
18122 .map(|max| log.truncate_events_keep_last(max))
18123 .unwrap_or(0);
18124 let removed_spans = params
18125 .max_spans
18126 .map(|max| log.truncate_spans_keep_last(max))
18127 .unwrap_or(0);
18128 let stats = log.stats();
18129 Ok(serde_json::json!({
18130 "removedEvents": removed_events,
18131 "removedSpans": removed_spans,
18132 "stats": stats,
18133 }))
18134}
18135
18136async fn handle_events_clear(session: &crate::session::ClientSession) -> Result<Value, String> {
18137 let mut log = session.runtime.log.lock().await;
18138 let removed = log.clear();
18139 Ok(serde_json::json!({ "removed": removed, "stats": log.stats() }))
18140}
18141
18142async fn resolve_run_agent_id(
18163 session: &crate::session::ClientSession,
18164 req: &car_proto::RunStartRequest,
18165) -> Result<String, String> {
18166 if let Some(bound) = session.agent_id.lock().await.clone() {
18174 if let Some(id) = req.agent_id.as_deref() {
18175 let id = id.trim();
18176 if !id.is_empty() && id != bound {
18177 return Err(format!(
18178 "runs.start `agent_id` (`{id}`) does not match this session's bound \
18179 agent: a bound connection can only record runs under its own agent"
18180 ));
18181 }
18182 }
18183 return Ok(bound);
18184 }
18185 if let Some(id) = req.agent_id.as_deref() {
18189 let id = id.trim();
18190 if !id.is_empty() {
18191 return Ok(id.to_string());
18192 }
18193 }
18194 if let Ok(env_id) = std::env::var("CAR_AGENT_ID") {
18196 let env_id = env_id.trim().to_string();
18197 if !env_id.is_empty() {
18198 return Ok(env_id);
18199 }
18200 }
18201 if let Some(name) = req.agent_name.as_deref() {
18204 if let Some(synth) = synthesize_agent_id(name) {
18205 return Ok(synth);
18206 }
18207 }
18208 Err(
18209 "runs.start could not resolve an agent_id: no `agent_id` param, no bound \
18210 session.auth {agent_id}, no CAR_AGENT_ID env, and no usable `agent_name` \
18211 to synthesize one from"
18212 .to_string(),
18213 )
18214}
18215
18216fn synthesize_agent_id(name: &str) -> Option<String> {
18224 let mut slug = String::new();
18225 let mut prev_dash = false;
18226 for ch in name.chars() {
18227 if ch.is_ascii_alphanumeric() {
18228 slug.push(ch.to_ascii_lowercase());
18229 prev_dash = false;
18230 } else if !prev_dash {
18231 slug.push('-');
18232 prev_dash = true;
18233 }
18234 }
18235 let slug = slug.trim_matches('-');
18236 if slug.is_empty() {
18237 return None;
18238 }
18239 Some(format!("name:{slug}"))
18240}
18241
18242async fn release_new_run_reservation_after_error(
18243 state: &ServerState,
18244 session: &crate::session::ClientSession,
18245 requested: &crate::session::RunMeta,
18246 error: String,
18247) -> String {
18248 match state
18249 .release_unpersisted_run_reservation(session, requested)
18250 .await
18251 {
18252 Ok(()) => error,
18253 Err(cleanup) => format!("{error}; failed to release new run reservation: {cleanup}"),
18254 }
18255}
18256
18257async fn handle_runs_start(
18258 req: &JsonRpcMessage,
18259 session: &crate::session::ClientSession,
18260 state: &Arc<ServerState>,
18261) -> Result<Value, String> {
18262 let params: car_proto::RunStartRequest = serde_json::from_value(req.params.clone())
18263 .map_err(|e| format!("runs.start requires {{ intent, agent_id?, agent_name?, outcome_description?, idempotency_key? }}: {e}"))?;
18264 if params.intent.trim().is_empty() {
18265 return Err("runs.start requires a non-empty `intent`".to_string());
18266 }
18267
18268 let agent_id = resolve_run_agent_id(session, ¶ms).await?;
18269 let _run_guard = session.run_lifecycle_guard.lock().await;
18270 let idempotency_key = params
18271 .idempotency_key
18272 .as_deref()
18273 .map(str::trim)
18274 .filter(|k| !k.is_empty())
18275 .map(str::to_string);
18276 let run_id = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
18277 let current_run = session.current_run_id.lock().await.clone();
18278 let requested_meta = crate::session::RunMeta {
18279 run_id: run_id.clone(),
18280 agent_id: agent_id.clone(),
18281 client_id: session.client_id.clone(),
18282 active_client_id: session.client_id.clone(),
18283 resume_predecessor_client_id: None,
18284 resume_lease: None,
18285 intent: params.intent.clone(),
18286 outcome_description: params.outcome_description.clone(),
18287 started_at: chrono::Utc::now(),
18288 termination: None,
18289 ended_at: None,
18290 turns: Vec::new(),
18291 start_committed: false,
18292 pending_terminal: None,
18293 cancellation_pending: None,
18294 cancellation_receipt: None,
18295 trace_corruption: None,
18296 durability_generation: 0,
18297 };
18298 if let Some(current) = current_run.as_deref() {
18299 if let Some(pending) = state
18300 .run_store
18301 .pending_proposal(current)
18302 .map_err(|error| proposal_durability_quarantine(current, "finalization", &error))?
18303 {
18304 return Err(format!(
18305 "proposal finalization pending for run_id `{current}` / original submission `{}`",
18306 pending.original_proposal_id
18307 ));
18308 }
18309 if let Some(marker) = state
18310 .run_store
18311 .execution_marker(current)
18312 .map_err(|error| proposal_durability_quarantine(current, "execution", &error))?
18313 {
18314 return Err(format!(
18315 "proposal execution outcome unknown for run_id `{current}` / original submission `{}`; CAR will not replace or terminalize the quarantined run",
18316 marker.original_proposal_id
18317 ));
18318 }
18319 }
18320 if let Some(pending) = state
18321 .run_store
18322 .pending_proposal(&run_id)
18323 .map_err(|error| proposal_durability_quarantine(&run_id, "finalization", &error))?
18324 {
18325 return Err(format!(
18326 "proposal finalization pending for run_id `{run_id}` / original submission `{}`",
18327 pending.original_proposal_id
18328 ));
18329 }
18330 if let Some(marker) = state
18331 .run_store
18332 .execution_marker(&run_id)
18333 .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))?
18334 {
18335 return Err(format!(
18336 "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not reopen or redispatch automatically",
18337 marker.original_proposal_id
18338 ));
18339 }
18340 let reservation = state.reserve_run(requested_meta.clone()).await?;
18344 let reservation_is_new = matches!(&reservation, crate::session::RunReservation::New);
18345
18346 if let crate::session::RunReservation::Existing(existing) = &reservation {
18347 if let Some(error) = &existing.trace_corruption {
18348 return Err(error.clone());
18349 }
18350 if existing.is_terminal() {
18351 return Err(format!(
18352 "{} run `{run_id}` is already terminal",
18353 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18354 ));
18355 }
18356 }
18357
18358 let current_resolution: Result<Option<Value>, String> = async {
18359 if let Some(current) = current_run.as_deref() {
18360 if current == run_id {
18361 let (owner, terminal, committed, pending) = state
18362 .run_lifecycle_state(current)
18363 .await
18364 .ok_or_else(|| {
18365 format!("reserved run `{current}` is absent from CAR registry")
18366 })?;
18367 if owner != session.client_id || terminal {
18368 return Err(format!(
18369 "{} run `{current}` is not resumable by this client",
18370 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18371 ));
18372 }
18373 session.require_run_journal_binding(current).await?;
18374 if pending {
18375 let ended = state
18376 .prepare_run_incomplete(current)
18377 .await
18378 .ok_or_else(|| {
18379 format!("active run `{current}` could not resume its pending terminal")
18380 })?;
18381 session.append_run_terminal_event(&ended).await?;
18382 state.commit_run_completion(&ended).await?;
18383 session.clear_run_journal_binding(current).await?;
18384 *session.current_run_id.lock().await = None;
18385 return Err(format!(
18386 "{} run `{current}` is already terminal",
18387 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18388 ));
18389 }
18390 if committed {
18391 return serde_json::to_value(car_proto::RunStartResponse {
18392 run_id: current.to_string(),
18393 agent_id: agent_id.clone(),
18394 client_id: session.client_id.clone(),
18395 })
18396 .map(Some)
18397 .map_err(|e| e.to_string());
18398 }
18399 } else {
18400 let (owner, terminal, committed, _pending) = state
18401 .run_lifecycle_state(current)
18402 .await
18403 .ok_or_else(|| {
18404 format!("active run `{current}` is absent from CAR registry")
18405 })?;
18406 if owner != session.client_id {
18407 return Err(format!(
18408 "{} active run `{current}` belongs to another client",
18409 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18410 ));
18411 }
18412 session.require_run_journal_binding(current).await?;
18413 if !terminal {
18414 if !committed {
18415 return Err(format!(
18416 "{} active run `{current}` has an unacknowledged durable start; retry its exact idempotency key and occurrence-defining payload before starting `{run_id}`",
18417 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18418 ));
18419 }
18420 let ended = state
18423 .prepare_run_incomplete(current)
18424 .await
18425 .ok_or_else(|| {
18426 format!("active run `{current}` could not be terminalized")
18427 })?;
18428 session.append_run_terminal_event(&ended).await?;
18429 state.commit_run_completion(&ended).await?;
18430 }
18431 session.clear_run_journal_binding(current).await?;
18432 *session.current_run_id.lock().await = None;
18433 }
18434 }
18435 Ok(None)
18436 }
18437 .await;
18438 match current_resolution {
18439 Ok(Some(response)) => return Ok(response),
18440 Ok(None) => {}
18441 Err(error) => {
18442 if reservation_is_new {
18443 return Err(release_new_run_reservation_after_error(
18444 state,
18445 session,
18446 &requested_meta,
18447 error,
18448 )
18449 .await);
18450 }
18451 return Err(error);
18452 }
18453 }
18454
18455 let binding_preexisting = current_run.as_deref() == Some(run_id.as_str());
18456 if !binding_preexisting {
18457 if let Err(error) = session.bind_run_journal(&run_id).await {
18458 if reservation_is_new {
18459 return Err(release_new_run_reservation_after_error(
18460 state,
18461 session,
18462 &requested_meta,
18463 error,
18464 )
18465 .await);
18466 }
18467 return Err(error);
18468 }
18469 }
18470
18471 *session.current_run_id.lock().await = Some(run_id.clone());
18472 let start_result = async {
18473 let started = state.persist_run_start(&run_id).await?;
18474 session.append_run_started_event(&started).await?;
18475 state.commit_run_start(&run_id).await
18476 }
18477 .await;
18478 if let Err(error) = start_result {
18479 match state.run_store.run_started(&run_id) {
18480 Ok(None) => {
18481 match state
18482 .reconcile_or_release_unacknowledged_start(session, &run_id)
18483 .await
18484 {
18485 Ok(true) => return Err(error),
18486 Ok(false) => {
18487 return Err(format!(
18488 "{error}; run `{run_id}` became durable during rollback; retry the exact idempotency key and occurrence-defining payload"
18489 ));
18490 }
18491 Err(cleanup) => {
18492 return Err(format!(
18493 "{error}; failed to release rejected start reservation: {cleanup}"
18494 ));
18495 }
18496 }
18497 }
18498 Ok(Some(_)) => {
18499 return Err(format!(
18500 "{error}; {} run `{run_id}` has an unacknowledged durable start; retry the exact idempotency key and occurrence-defining payload",
18501 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18502 ));
18503 }
18504 Err(read_error) => {
18505 return Err(format!(
18506 "{error}; durable start state for run `{run_id}` is unreadable ({read_error}); retry only the exact idempotency key and occurrence-defining payload"
18507 ));
18508 }
18509 }
18510 }
18511
18512 serde_json::to_value(car_proto::RunStartResponse {
18513 run_id,
18514 agent_id,
18515 client_id: session.client_id.clone(),
18516 })
18517 .map_err(|e| e.to_string())
18518}
18519
18520async fn handle_runs_resume(
18521 req: &JsonRpcMessage,
18522 session: &crate::session::ClientSession,
18523 state: &Arc<ServerState>,
18524) -> Result<Value, String> {
18525 if !session_has_capability(session, car_proto::RUNS_RESUME_CAPABILITY) {
18526 return Err(format!(
18527 "runs.resume requires negotiated capability `{}`",
18528 car_proto::RUNS_RESUME_CAPABILITY
18529 ));
18530 }
18531 let params: car_proto::RunResumeRequest = serde_json::from_value(req.params.clone())
18532 .map_err(|error| format!("runs.resume requires exactly {{ run_id }}: {error}"))?;
18533 if params.run_id.trim().is_empty() {
18534 return Err("runs.resume run_id must be non-empty".into());
18535 }
18536 if !session.authenticated.load(Ordering::Acquire) {
18537 return Err("runs.resume requires an authenticated agent session".into());
18538 }
18539 let agent_id = session
18540 .agent_id
18541 .lock()
18542 .await
18543 .clone()
18544 .ok_or_else(|| "runs.resume requires session.auth with an agent_id".to_string())?;
18545
18546 let _run_guard = session.run_lifecycle_guard.lock().await;
18547 let current = session.current_run_id.lock().await.clone();
18548 if current
18549 .as_deref()
18550 .is_some_and(|current| current != params.run_id)
18551 {
18552 return Err(format!(
18553 "runs.resume cannot replace this socket's active run `{}`",
18554 current.expect("checked as present")
18555 ));
18556 }
18557
18558 let binding_preexisting = current.as_deref() == Some(params.run_id.as_str());
18559 if !binding_preexisting {
18560 session.bind_run_journal(¶ms.run_id).await?;
18561 }
18562 let resumed = match state
18563 .resume_run(¶ms.run_id, &agent_id, &session.client_id)
18564 .await
18565 {
18566 Ok(binding) => binding,
18567 Err(error) => {
18568 if !binding_preexisting {
18569 let _ = session.clear_run_journal_binding(¶ms.run_id).await;
18570 }
18571 return Err(error);
18572 }
18573 };
18574 *session.current_run_id.lock().await = Some(params.run_id.clone());
18575
18576 serde_json::to_value(car_proto::RunResumeResponse {
18577 run_id: resumed.run_id,
18578 agent_id: resumed.agent_id,
18579 client_id: resumed.active_client_id,
18580 resumed_from_client_id: resumed.resumed_from_client_id,
18581 })
18582 .map_err(|error| error.to_string())
18583}
18584
18585async fn handle_runs_complete(
18586 req: &JsonRpcMessage,
18587 session: &crate::session::ClientSession,
18588 state: &Arc<ServerState>,
18589) -> Result<Value, String> {
18590 let params: car_proto::RunCompleteRequest = serde_json::from_value(req.params.clone())
18591 .map_err(|e| format!("runs.complete requires {{ run_id, outcome }}: {e}"))?;
18592
18593 let _run_guard = session.run_lifecycle_guard.lock().await;
18594 let current = session.current_run_id.lock().await.clone();
18595 if current.as_deref() != Some(params.run_id.as_str()) {
18596 return Err(format!(
18597 "runs.complete run_id `{}` is not this client's active run",
18598 params.run_id
18599 ));
18600 }
18601 let (owner_client, _terminal, start_committed, _completion_pending) = state
18602 .run_lifecycle_state(¶ms.run_id)
18603 .await
18604 .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18605 let (durable_client, active_client) = state
18606 .run_owner_binding(¶ms.run_id)
18607 .await
18608 .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18609 if owner_client != session.client_id || !start_committed {
18610 return Err("runs.complete client/run binding mismatch".into());
18611 }
18612 if active_client != session.client_id {
18613 return Err("runs.complete active owner mismatch".into());
18614 }
18615 session.require_run_journal_binding(¶ms.run_id).await?;
18616 if let Some(pending) = state
18617 .run_store
18618 .pending_proposal(¶ms.run_id)
18619 .map_err(|error| proposal_durability_quarantine(¶ms.run_id, "finalization", &error))?
18620 {
18621 return Err(format!(
18622 "proposal finalization pending for run_id `{}` / original submission `{}`",
18623 params.run_id, pending.original_proposal_id
18624 ));
18625 }
18626 if let Some(marker) = state
18627 .run_store
18628 .execution_marker(¶ms.run_id)
18629 .map_err(|error| proposal_durability_quarantine(¶ms.run_id, "execution", &error))?
18630 {
18631 return Err(format!(
18632 "proposal execution outcome unknown for run_id `{}` / original submission `{}`; runs.complete is quarantined",
18633 params.run_id, marker.original_proposal_id
18634 ));
18635 }
18636
18637 let termination = car_proto::RunTermination::Outcome {
18638 status: params.outcome.status,
18639 outcome: params.outcome.clone(),
18640 };
18641 let ended = state
18642 .prepare_run_completion_for_active_owner(
18643 ¶ms.run_id,
18644 termination,
18645 Some(&session.client_id),
18646 )
18647 .await?;
18648 if durable_client == session.client_id {
18649 session.append_run_terminal_event(&ended).await?;
18650 } else {
18651 session
18652 .append_resumed_run_terminal_event(&ended, &durable_client)
18653 .await?;
18654 }
18655 state.commit_run_completion(&ended).await?;
18656 let completion_digest = ended
18657 .completion_digest
18658 .clone()
18659 .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18660
18661 session.clear_run_journal_binding(¶ms.run_id).await?;
18665 *session.current_run_id.lock().await = None;
18666
18667 serde_json::to_value(car_proto::RunCompleteResponse {
18668 run_id: params.run_id,
18669 ok: true,
18670 completion_digest,
18671 })
18672 .map_err(|e| e.to_string())
18673}
18674
18675#[derive(Serialize)]
18676struct RunCancelReceiptPreimage<'a> {
18677 receipt_version: u32,
18678 run_id: &'a str,
18679 idempotency_key: &'a str,
18680 reason_digest: &'a str,
18681 principal: &'a str,
18682 status: car_proto::RunCancellationStatus,
18683 terminal_digest: &'a Option<String>,
18684 action_id: &'a Option<String>,
18685 request_id: &'a Option<String>,
18686}
18687
18688fn run_cancel_receipt(
18689 requested: &car_proto::RunCancellationRequested,
18690 status: car_proto::RunCancellationStatus,
18691 terminal_digest: Option<String>,
18692) -> Result<car_proto::RunCancelResponse, String> {
18693 let preimage = RunCancelReceiptPreimage {
18694 receipt_version: requested.receipt_version,
18695 run_id: &requested.run_id,
18696 idempotency_key: &requested.idempotency_key,
18697 reason_digest: &requested.reason_digest,
18698 principal: &requested.principal,
18699 status,
18700 terminal_digest: &terminal_digest,
18701 action_id: &requested.action_id,
18702 request_id: &requested.request_id,
18703 };
18704 let receipt_digest = car_proto::canonical_sha256(&preimage)?;
18705 Ok(car_proto::RunCancelResponse {
18706 receipt_version: requested.receipt_version,
18707 run_id: requested.run_id.clone(),
18708 idempotency_key: requested.idempotency_key.clone(),
18709 reason_digest: requested.reason_digest.clone(),
18710 principal: requested.principal.clone(),
18711 status,
18712 terminal_digest,
18713 action_id: requested.action_id.clone(),
18714 request_id: requested.request_id.clone(),
18715 receipt_digest,
18716 })
18717}
18718
18719fn run_cancel_terminal_receipt(
18720 params: &car_proto::RunCancelRequest,
18721 reason_digest: &str,
18722 principal: &str,
18723 ended: &car_proto::RunEnded,
18724) -> Result<car_proto::RunCancelResponse, String> {
18725 let terminal_digest = ended
18726 .completion_digest
18727 .clone()
18728 .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18729 let (requested, status) = match &ended.termination {
18730 car_proto::RunTermination::Cancelled { cancellation }
18731 if cancellation.idempotency_key == params.idempotency_key
18732 && cancellation.reason_digest == reason_digest
18733 && cancellation.principal == principal =>
18734 {
18735 (
18736 car_proto::RunCancellationRequested {
18737 receipt_version: cancellation.receipt_version,
18738 run_id: cancellation.run_id.clone(),
18739 idempotency_key: cancellation.idempotency_key.clone(),
18740 reason_digest: cancellation.reason_digest.clone(),
18741 principal: cancellation.principal.clone(),
18742 action_id: cancellation.action_id.clone(),
18743 request_id: cancellation.request_id.clone(),
18744 },
18745 car_proto::RunCancellationStatus::CancelledConfirmed,
18746 )
18747 }
18748 car_proto::RunTermination::Cancelled { cancellation } => (
18749 car_proto::RunCancellationRequested {
18750 receipt_version: 1,
18751 run_id: params.run_id.clone(),
18752 idempotency_key: params.idempotency_key.clone(),
18753 reason_digest: reason_digest.to_string(),
18754 principal: principal.to_string(),
18755 action_id: cancellation.action_id.clone(),
18756 request_id: cancellation.request_id.clone(),
18757 },
18758 car_proto::RunCancellationStatus::AlreadyTerminal,
18759 ),
18760 _ => (
18761 car_proto::RunCancellationRequested {
18762 receipt_version: 1,
18763 run_id: params.run_id.clone(),
18764 idempotency_key: params.idempotency_key.clone(),
18765 reason_digest: reason_digest.to_string(),
18766 principal: principal.to_string(),
18767 action_id: None,
18768 request_id: None,
18769 },
18770 car_proto::RunCancellationStatus::AlreadyTerminal,
18771 ),
18772 };
18773 run_cancel_receipt(&requested, status, Some(terminal_digest))
18774}
18775
18776async fn recover_host_orphan_cancellation(
18777 is_host: bool,
18778 state: &Arc<ServerState>,
18779 owning_agent: &str,
18780 params: &car_proto::RunCancelRequest,
18781 reason_digest: &str,
18782 principal: &str,
18783) -> Result<Value, String> {
18784 if !is_host {
18785 return Err("run cancellation control is unavailable".into());
18786 }
18787 let requested = car_proto::RunCancellationRequested {
18788 receipt_version: 1,
18789 run_id: params.run_id.clone(),
18790 idempotency_key: params.idempotency_key.clone(),
18791 reason_digest: reason_digest.to_string(),
18792 principal: principal.to_string(),
18793 action_id: None,
18794 request_id: None,
18795 };
18796 let terminal_digest =
18800 crate::session::run_completion_digest(&car_proto::RunTermination::Incomplete)?;
18801 let response = run_cancel_receipt(
18802 &requested,
18803 car_proto::RunCancellationStatus::AlreadyTerminal,
18804 Some(terminal_digest),
18805 )?;
18806 state
18807 .persist_recovered_run_cancellation_result(owning_agent, &response)
18808 .await?;
18809 serde_json::to_value(response).map_err(|error| error.to_string())
18810}
18811
18812async fn handle_runs_cancel(
18813 req: &JsonRpcMessage,
18814 session: &crate::session::ClientSession,
18815 state: &Arc<ServerState>,
18816) -> Result<Value, String> {
18817 if !session_has_capability(session, car_proto::RUNS_CANCEL_CAPABILITY) {
18818 return Err(format!(
18819 "runs.cancel requires negotiated capability `{}`",
18820 car_proto::RUNS_CANCEL_CAPABILITY
18821 ));
18822 }
18823 let params: car_proto::RunCancelRequest =
18824 serde_json::from_value(req.params.clone()).map_err(|error| {
18825 format!("runs.cancel requires exactly {{ run_id, idempotency_key, reason }}: {error}")
18826 })?;
18827 if params.run_id.trim().is_empty()
18828 || params.idempotency_key.trim().is_empty()
18829 || params.reason.trim().is_empty()
18830 {
18831 return Err("runs.cancel fields must be non-empty".into());
18832 }
18833 if params.idempotency_key.len() > 128 {
18834 return Err("runs.cancel idempotency_key exceeds 128 bytes".into());
18835 }
18836 if params.reason.len() > 1024 {
18837 return Err("runs.cancel reason exceeds 1024 bytes".into());
18838 }
18839
18840 let meta = state.run_meta(¶ms.run_id).await;
18841 let owning_agent = meta
18842 .as_ref()
18843 .map(|meta| meta.agent_id.clone())
18844 .or_else(|| state.run_store.agent_for_run(¶ms.run_id));
18845 let Some(owning_agent) = owning_agent else {
18846 return Err("run not found or not authorized".into());
18847 };
18848 if authorize_run_access(session, state, &owning_agent)
18849 .await
18850 .is_err()
18851 {
18852 return Err("run not found or not authorized".into());
18853 }
18854 let is_host = session.is_host.load(Ordering::Acquire);
18859 let principal = if is_host {
18860 "host".to_string()
18861 } else {
18862 format!("agent:{owning_agent}")
18863 };
18864 let reason_digest = format!("{:x}", Sha256::digest(params.reason.as_bytes()));
18865
18866 let records = state
18867 .run_store
18868 .get_run_trace_for_checked(&owning_agent, ¶ms.run_id)
18869 .map_err(|error| format!("runs.cancel trace read failed: {error}"))?
18870 .ok_or_else(|| "run not found or not authorized".to_string())?;
18871 let existing_result = records.iter().find_map(|record| match record {
18872 car_proto::RunRecord::CancellationResult(result) => Some(result),
18873 _ => None,
18874 });
18875 if let Some(existing) = existing_result {
18876 if existing.idempotency_key != params.idempotency_key
18877 || existing.reason_digest != reason_digest
18878 || existing.principal != principal
18879 {
18880 return Err("run already has a different cancellation request".into());
18881 }
18882 }
18883 if let Some(ended) = records.iter().find_map(|record| match record {
18884 car_proto::RunRecord::Ended(ended) => Some(ended),
18885 _ => None,
18886 }) {
18887 return serde_json::to_value(run_cancel_terminal_receipt(
18888 ¶ms,
18889 &reason_digest,
18890 &principal,
18891 ended,
18892 )?)
18893 .map_err(|error| error.to_string());
18894 }
18895
18896 let existing_requested = records.iter().find_map(|record| match record {
18897 car_proto::RunRecord::CancellationRequested(requested) => Some(requested),
18898 _ => None,
18899 });
18900 if let Some(existing) = existing_result {
18901 let owner_session = match meta.as_ref() {
18902 Some(meta) => state
18903 .sessions
18904 .lock()
18905 .await
18906 .get(&meta.active_client_id)
18907 .cloned(),
18908 None => None,
18909 };
18910 match owner_session {
18911 Some(owner_session) => {
18912 state
18913 .persist_run_cancellation_result(&owner_session, existing)
18914 .await?;
18915 }
18916 None => {
18917 state
18918 .persist_recovered_run_cancellation_result(&owning_agent, existing)
18919 .await?;
18920 }
18921 }
18922 return serde_json::to_value(existing).map_err(|error| error.to_string());
18923 }
18924 let Some(meta) = meta else {
18925 let Some(requested) = existing_requested else {
18926 return recover_host_orphan_cancellation(
18927 is_host,
18928 state,
18929 &owning_agent,
18930 ¶ms,
18931 &reason_digest,
18932 &principal,
18933 )
18934 .await;
18935 };
18936 if requested.idempotency_key != params.idempotency_key
18937 || requested.reason_digest != reason_digest
18938 || requested.principal != principal
18939 {
18940 return Err("run already has a different cancellation request".into());
18941 }
18942 if is_host
18943 && requested.principal == "host"
18944 && requested.action_id.is_none()
18945 && requested.request_id.is_none()
18946 {
18947 return recover_host_orphan_cancellation(
18948 is_host,
18949 state,
18950 &owning_agent,
18951 ¶ms,
18952 &reason_digest,
18953 &principal,
18954 )
18955 .await;
18956 }
18957 let response = run_cancel_receipt(
18958 requested,
18959 car_proto::RunCancellationStatus::TerminationUnconfirmed,
18960 None,
18961 )?;
18962 state
18963 .persist_recovered_run_cancellation_result(&owning_agent, &response)
18964 .await?;
18965 return serde_json::to_value(response).map_err(|error| error.to_string());
18966 };
18967 let owner_session = state
18968 .sessions
18969 .lock()
18970 .await
18971 .get(&meta.active_client_id)
18972 .cloned();
18973 let Some(owner_session) = owner_session else {
18974 let Some(requested) = existing_requested else {
18975 return recover_host_orphan_cancellation(
18976 is_host,
18977 state,
18978 &owning_agent,
18979 ¶ms,
18980 &reason_digest,
18981 &principal,
18982 )
18983 .await;
18984 };
18985 if requested.idempotency_key != params.idempotency_key
18986 || requested.reason_digest != reason_digest
18987 || requested.principal != principal
18988 {
18989 return Err("run already has a different cancellation request".into());
18990 }
18991 if is_host
18992 && requested.principal == "host"
18993 && requested.action_id.is_none()
18994 && requested.request_id.is_none()
18995 {
18996 return recover_host_orphan_cancellation(
18997 is_host,
18998 state,
18999 &owning_agent,
19000 ¶ms,
19001 &reason_digest,
19002 &principal,
19003 )
19004 .await;
19005 }
19006 let response = run_cancel_receipt(
19007 requested,
19008 car_proto::RunCancellationStatus::TerminationUnconfirmed,
19009 None,
19010 )?;
19011 state
19012 .persist_recovered_run_cancellation_result(&owning_agent, &response)
19013 .await?;
19014 return serde_json::to_value(response).map_err(|error| error.to_string());
19015 };
19016
19017 let _run_guard = owner_session.run_lifecycle_guard.lock().await;
19023 let guarded_records = state
19024 .run_store
19025 .get_run_trace_for_checked(&owning_agent, ¶ms.run_id)
19026 .map_err(|error| format!("runs.cancel guarded trace read failed: {error}"))?
19027 .ok_or_else(|| "run not found or not authorized".to_string())?;
19028 if let Some(ended) = guarded_records.iter().find_map(|record| match record {
19029 car_proto::RunRecord::Ended(ended) => Some(ended),
19030 _ => None,
19031 }) {
19032 return serde_json::to_value(run_cancel_terminal_receipt(
19033 ¶ms,
19034 &reason_digest,
19035 &principal,
19036 ended,
19037 )?)
19038 .map_err(|error| error.to_string());
19039 }
19040
19041 let mut active: Vec<(String, String)> = owner_session
19042 .channel
19043 .active_actions
19044 .lock()
19045 .await
19046 .iter()
19047 .map(|(request_id, action_id)| (request_id.clone(), action_id.clone()))
19048 .collect();
19049 active.sort();
19050 let active_inferences = owner_session
19051 .inference_control
19052 .active_for_run(¶ms.run_id);
19053 let (request_id, action_id) = active
19054 .first()
19055 .map(|(request_id, action_id)| (Some(request_id.clone()), Some(action_id.clone())))
19056 .or_else(|| {
19057 active_inferences.first().map(|(inference_id, request_id)| {
19058 (
19059 request_id.clone().or_else(|| Some(inference_id.clone())),
19060 None,
19061 )
19062 })
19063 })
19064 .unwrap_or((None, None));
19065 let requested = if let Some(existing) = existing_requested {
19066 if existing.idempotency_key != params.idempotency_key
19067 || existing.reason_digest != reason_digest
19068 || existing.principal != principal
19069 {
19070 return Err("run already has a different cancellation request".into());
19071 }
19072 existing.clone()
19073 } else {
19074 car_proto::RunCancellationRequested {
19075 receipt_version: 1,
19076 run_id: params.run_id.clone(),
19077 idempotency_key: params.idempotency_key.clone(),
19078 reason_digest,
19079 principal,
19080 action_id,
19081 request_id,
19082 }
19083 };
19084 state
19085 .persist_run_cancellation_requested(&owner_session, &requested)
19086 .await?;
19087
19088 for (request_id, action_id) in &active {
19089 crate::session::write_tool_cancel(
19090 &owner_session.channel,
19091 request_id.clone(),
19092 action_id.clone(),
19093 params.reason.clone(),
19094 )
19095 .await;
19096 }
19097 let mut inference_stopped = true;
19098 for (inference_id, _) in &active_inferences {
19099 let _pending = owner_session
19100 .inference_control
19101 .try_acquire_control()
19102 .map_err(|error| format!("runs.cancel inference control rejected: {error:?}"))?;
19103 let status = owner_session
19104 .inference_control
19105 .control(
19106 inference_id,
19107 crate::inference_control::ControlCause::Cancel,
19108 exact_backend_termination_ack(inference_id),
19109 )
19110 .await;
19111 if !matches!(
19112 status,
19113 car_proto::InferenceControlStatus::CancelledConfirmed
19114 | car_proto::InferenceControlStatus::AlreadyTerminal
19115 ) {
19116 inference_stopped = false;
19117 }
19118 }
19119 let callbacks_stopped = if active.is_empty() {
19120 true
19121 } else {
19122 tokio::time::timeout(std::time::Duration::from_secs(5), async {
19123 loop {
19124 let pending = owner_session.channel.pending.lock().await;
19125 let any_active = active
19126 .iter()
19127 .any(|(request_id, _)| pending.contains_key(request_id));
19128 drop(pending);
19129 if !any_active {
19130 break;
19131 }
19132 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
19133 }
19134 })
19135 .await
19136 .is_ok()
19137 };
19138 let stopped = callbacks_stopped && inference_stopped;
19139
19140 if !stopped {
19141 let response = run_cancel_receipt(
19142 &requested,
19143 car_proto::RunCancellationStatus::TerminationUnconfirmed,
19144 None,
19145 )?;
19146 state
19147 .persist_run_cancellation_result(&owner_session, &response)
19148 .await?;
19149 return serde_json::to_value(response).map_err(|error| error.to_string());
19150 }
19151
19152 let cancellation = car_proto::RunCancellationIdentity {
19153 receipt_version: requested.receipt_version,
19154 run_id: requested.run_id.clone(),
19155 idempotency_key: requested.idempotency_key.clone(),
19156 reason_digest: requested.reason_digest.clone(),
19157 principal: requested.principal.clone(),
19158 action_id: requested.action_id.clone(),
19159 request_id: requested.request_id.clone(),
19160 };
19161 let ended = state
19162 .prepare_run_completion(
19163 ¶ms.run_id,
19164 car_proto::RunTermination::Cancelled { cancellation },
19165 )
19166 .await?;
19167 owner_session.append_run_terminal_event(&ended).await?;
19168 state.commit_run_completion(&ended).await?;
19169 let terminal_digest = ended
19170 .completion_digest
19171 .clone()
19172 .ok_or_else(|| "cancelled terminal is missing completion_digest".to_string())?;
19173 let response = run_cancel_receipt(
19174 &requested,
19175 car_proto::RunCancellationStatus::CancelledConfirmed,
19176 Some(terminal_digest),
19177 )?;
19178 owner_session
19179 .append_run_cancellation_result_event(&response)
19180 .await?;
19181 owner_session
19182 .clear_run_journal_binding(¶ms.run_id)
19183 .await?;
19184 let mut current = owner_session.current_run_id.lock().await;
19185 if current.as_deref() == Some(params.run_id.as_str()) {
19186 *current = None;
19187 }
19188 serde_json::to_value(response).map_err(|error| error.to_string())
19189}
19190
19191const RECORD_TURN_FIELD_CAP_BYTES: usize = 16 * 1024;
19202
19203const RECORD_TURN_TRUNC_MARKER: &str = "…[truncated]";
19206
19207const RECORD_TURN_MAX_BYTES: usize = 256 * 1024;
19216
19217const RECORD_TURN_OVERSIZE_MARKER: &str = "…[truncated: turn exceeded 256 KiB]";
19221
19222const RECORD_TURNS_MAX_BATCH: usize = 256;
19226
19227const RECORD_TURNS_RUN_CEILING: usize = crate::session::RECORD_TURNS_RUN_CEILING;
19242
19243fn truncate_turn_string(s: &mut String) {
19247 if s.len() > RECORD_TURN_FIELD_CAP_BYTES {
19248 let mut end = RECORD_TURN_FIELD_CAP_BYTES;
19250 while end > 0 && !s.is_char_boundary(end) {
19251 end -= 1;
19252 }
19253 s.truncate(end);
19254 s.push_str(RECORD_TURN_TRUNC_MARKER);
19255 }
19256}
19257
19258fn truncate_turn_value(value: &mut Value) {
19264 match value {
19265 Value::String(s) => truncate_turn_string(s),
19266 Value::Array(items) => {
19267 for item in items.iter_mut() {
19268 truncate_turn_value(item);
19269 }
19270 }
19271 Value::Object(map) => {
19272 for (_k, v) in map.iter_mut() {
19273 truncate_turn_value(v);
19274 }
19275 }
19276 _ => {}
19277 }
19278}
19279
19280const RECORD_TURN_REINDEX_HEADROOM: usize = 16;
19290
19291const RECORD_TURN_MEASURE_CAP: usize = RECORD_TURN_MAX_BYTES - RECORD_TURN_REINDEX_HEADROOM;
19295
19296fn turn_encoded_len(turn: &car_proto::RunTurn) -> usize {
19300 serde_json::to_vec(turn).map_or(usize::MAX, |v| v.len())
19301}
19302
19303pub(crate) fn enforce_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19324 let car_proto::RunTurn {
19329 index: _, proposal_id: _, action_id: _, action_status: _, action_duration_ms: _, action_completed_at: _, depends_on: _, state_dependencies: _, prompt: _, tool: _, parameters: _, output: _, cli_outcome: _, verifier_verdict: _, policy_rejected: _, } = turn;
19345
19346 if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
19347 return true;
19348 }
19349 if !turn.parameters.is_null() {
19351 turn.parameters = Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string());
19352 }
19353 if turn.output.is_some() {
19354 turn.output = Some(Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string()));
19355 }
19356 if turn.prompt.is_some() {
19357 turn.prompt = Some(RECORD_TURN_OVERSIZE_MARKER.to_string());
19358 }
19359 if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
19360 return true;
19361 }
19362 if let Some(tool) = turn.tool.as_mut() {
19365 truncate_turn_string(tool);
19366 }
19367 if let Some(pr) = turn.policy_rejected.as_mut() {
19368 truncate_turn_string(&mut pr.rule);
19369 if let Some(param) = pr.param.as_mut() {
19370 truncate_turn_string(param);
19371 }
19372 }
19373 turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19380}
19381
19382pub(crate) fn enforce_proposal_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19386 let parameters = turn.parameters.clone();
19387 if !enforce_turn_byte_cap(turn) {
19388 return false;
19389 }
19390 turn.parameters = parameters;
19391 turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19392}
19393
19394fn record_turns_dropped(run_id: &str, reason: &str) -> Result<Value, String> {
19398 serde_json::to_value(car_proto::RunRecordTurnsResponse {
19399 run_id: run_id.to_string(),
19400 base_index: 0,
19401 count: 0,
19402 ok: false,
19403 dropped: Some(reason.to_string()),
19404 })
19405 .map_err(|e| e.to_string())
19406}
19407
19408async fn handle_runs_record_turns(
19437 req: &JsonRpcMessage,
19438 session: &crate::session::ClientSession,
19439 state: &Arc<ServerState>,
19440) -> Result<Value, String> {
19441 let raw_run_id = req
19448 .params
19449 .get("run_id")
19450 .and_then(Value::as_str)
19451 .map(str::to_string);
19452 let run_id = match raw_run_id {
19453 Some(id) if !id.trim().is_empty() => id,
19454 _ => return Err("runs.record_turns requires { run_id, turns: [RunTurn] }".to_string()),
19455 };
19456
19457 let raw_turns =
19458 match req.params.get("turns").and_then(Value::as_array) {
19459 Some(arr) => arr,
19460 None => return Err(
19461 "runs.record_turns requires { run_id, turns: [RunTurn] }: `turns` must be an array"
19462 .to_string(),
19463 ),
19464 };
19465 if raw_turns.is_empty() {
19466 return Err("runs.record_turns requires a non-empty `turns` array".to_string());
19467 }
19468 if raw_turns.len() > RECORD_TURNS_MAX_BATCH {
19469 return Err(format!(
19470 "runs.record_turns batch too large: {} turns exceeds the per-call cap of {}",
19471 raw_turns.len(),
19472 RECORD_TURNS_MAX_BATCH
19473 ));
19474 }
19475
19476 let mut turns: Vec<car_proto::RunTurn> = Vec::with_capacity(raw_turns.len());
19481 for (i, raw) in raw_turns.iter().enumerate() {
19482 let mut obj = raw.clone();
19483 match obj.as_object_mut() {
19484 Some(map) => {
19485 map.insert("index".to_string(), Value::Number(0.into()));
19494 map.entry("verifier_verdict")
19495 .or_insert_with(|| Value::String("not_run".to_string()));
19496 if let Some(p) = map.get_mut("prompt") {
19499 truncate_turn_value(p);
19500 }
19501 if let Some(o) = map.get_mut("output") {
19502 truncate_turn_value(o);
19503 }
19504 if let Some(params) = map.get_mut("parameters") {
19505 truncate_turn_value(params);
19506 }
19507 }
19508 None => {
19509 return Err(format!(
19510 "runs.record_turns requires {{ run_id, turns: [RunTurn] }}: \
19511 turn {i} is not an object"
19512 ));
19513 }
19514 }
19515 let mut turn: car_proto::RunTurn = serde_json::from_value(obj).map_err(|e| {
19516 format!("runs.record_turns requires {{ run_id, turns: [RunTurn] }}: turn {i}: {e}")
19517 })?;
19518 if !enforce_turn_byte_cap(&mut turn) {
19525 tracing::warn!(
19526 run_id = %run_id,
19527 turn = i,
19528 "runs.record_turns: turn could not be bounded under the per-turn byte cap; dropping batch"
19529 );
19530 return record_turns_dropped(&run_id, "turn_too_large");
19531 }
19532 turns.push(turn);
19533 }
19534
19535 let header = state.run_header(&run_id).await;
19545 let owning_agent = match &header {
19546 Some((agent_id, _terminal, _len, _corruption)) => agent_id.clone(),
19547 None => match state.run_store.agent_for_run(&run_id) {
19548 Some(a) => a,
19549 None => return record_turns_dropped(&run_id, "run_not_found"),
19552 },
19553 };
19554
19555 let bound = session.agent_id.lock().await.clone();
19561 if bound.as_deref() != Some(owning_agent.as_str()) {
19562 tracing::debug!(
19563 run_id = %run_id,
19564 owning_agent = %owning_agent,
19565 client_id = %session.client_id,
19566 "runs.record_turns denied (not the owning agent); returning uniform not-found"
19567 );
19568 return record_turns_dropped(&run_id, "run_not_found");
19569 }
19570
19571 if let Some((_agent_id, _terminal, _len, Some(error))) = &header {
19572 return Err(error.clone());
19573 }
19574
19575 if let Some((_agent_id, terminal, len, _corruption)) = &header {
19583 if *terminal {
19584 return record_turns_dropped(&run_id, "run_terminal");
19585 }
19586 if *len + turns.len() > RECORD_TURNS_RUN_CEILING {
19594 return record_turns_dropped(&run_id, "run_turn_limit");
19595 }
19596 }
19597
19598 let count = turns.len();
19605 let records: Vec<car_proto::RunRecord> =
19606 turns.into_iter().map(car_proto::RunRecord::Turn).collect();
19607 let new_total = match state
19608 .record_run_turns_for_active_owner(&run_id, &session.client_id, records)
19609 .await
19610 {
19611 crate::session::RecordRunTurnsOutcome::Appended { new_total } => new_total,
19612 crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
19613 return record_turns_dropped(&run_id, "run_turn_limit");
19617 }
19618 crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {
19619 return record_turns_dropped(&run_id, "run_terminal");
19624 }
19625 crate::session::RecordRunTurnsOutcome::PersistenceFailed(error) => {
19626 if error.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
19627 return Err(error);
19628 }
19629 return Err(format!(
19630 "runs.record_turns persistence failed; retry exact batch safely: {error}"
19631 ));
19632 }
19633 };
19634
19635 serde_json::to_value(car_proto::RunRecordTurnsResponse {
19636 run_id,
19637 base_index: new_total - count,
19638 count,
19639 ok: true,
19640 dropped: None,
19641 })
19642 .map_err(|e| e.to_string())
19643}
19644
19645async fn authorize_run_access(
19681 session: &crate::session::ClientSession,
19682 _state: &Arc<ServerState>,
19683 owning_agent_id: &str,
19684) -> Result<(), String> {
19685 if let Some(bound) = session.agent_id.lock().await.clone() {
19687 if bound == owning_agent_id {
19688 return Ok(());
19689 }
19690 }
19691 if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
19699 return Ok(());
19700 }
19701 tracing::debug!(
19706 owning_agent = %owning_agent_id,
19707 client_id = %session.client_id,
19708 "run access denied: connection neither owns the agent nor is the host client"
19709 );
19710 Err(
19711 "not authorized to access this run: this connection neither owns the \
19712 owning agent (via session.auth) nor is the host management client"
19713 .to_string(),
19714 )
19715}
19716
19717async fn handle_runs_subscribe(
19733 req: &JsonRpcMessage,
19734 session: &crate::session::ClientSession,
19735 state: &Arc<ServerState>,
19736) -> Result<Value, String> {
19737 const MAX_LIMIT: usize = 500;
19738 #[derive(Deserialize)]
19739 struct LegacyRequest {
19740 run_id: String,
19741 }
19742 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19743 let (run_id, cursor, limit) = if paginated {
19744 let params: car_proto::RunSubscribeRequest = serde_json::from_value(req.params.clone())
19745 .map_err(|e| format!("runs.subscribe requires {{ run_id, cursor, limit }}: {e}"))?;
19746 (params.run_id, params.cursor, params.limit)
19747 } else {
19748 let params: LegacyRequest = serde_json::from_value(req.params.clone())
19749 .map_err(|e| format!("runs.subscribe requires {{ run_id }}: {e}"))?;
19750 (params.run_id, 0, crate::session::RECORD_TURNS_RUN_CEILING)
19751 };
19752 if !(1..=MAX_LIMIT).contains(&limit) && paginated {
19753 return Err(format!(
19754 "runs.subscribe limit must be between 1 and {MAX_LIMIT}"
19755 ));
19756 }
19757
19758 let not_found = || {
19761 serde_json::to_value(serde_json::json!({
19762 "run_id": run_id,
19763 "not_found": true,
19764 }))
19765 .map_err(|e| e.to_string())
19766 };
19767
19768 let owning_agent = match state.run_header(&run_id).await {
19772 Some((agent_id, _, _, _)) => agent_id,
19773 None => {
19774 let run_store = state.run_store.clone();
19775 let durable_run_id = run_id.clone();
19776 match run_store_blocking("runs.subscribe owner lookup", move || {
19777 Ok(run_store.agent_for_run(&durable_run_id))
19778 })
19779 .await?
19780 {
19781 Some(agent_id) => agent_id,
19782 None => return not_found(),
19783 }
19784 }
19785 };
19786
19787 if authorize_run_access(session, state, &owning_agent)
19791 .await
19792 .is_err()
19793 {
19794 tracing::debug!(
19795 run_id = %run_id,
19796 owning_agent = %owning_agent,
19797 client_id = %session.client_id,
19798 "runs.subscribe denied (unauthorized); returning uniform not-found"
19799 );
19800 return not_found();
19801 }
19802
19803 let page = match state
19804 .subscribe_run_page(
19805 &run_id,
19806 &session.client_id,
19807 session.channel.clone(),
19808 cursor,
19809 limit,
19810 )
19811 .await?
19812 {
19813 Some(crate::session::RunSubscribePageResult::Ready(page)) => page,
19814 Some(crate::session::RunSubscribePageResult::Durable { agent_id, status }) => {
19815 let run_store = state.run_store.clone();
19816 let durable_run_id = run_id.clone();
19817 let page = strict_run_trace_read_blocking(
19818 "runs.subscribe durable page",
19819 state,
19820 run_id.clone(),
19821 move || run_store.get_run_turn_page_for(&agent_id, &durable_run_id, cursor, limit),
19822 )
19823 .await?;
19824 let Some((turns, next_cursor, live_cursor, durable_status)) = page else {
19825 return not_found();
19826 };
19827 let status = match durable_status {
19828 crate::run_store::RunStatus::InProgress => status,
19829 crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19830 crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19831 crate::run_store::RunStatus::CancellationPending => {
19832 car_proto::RunLiveStatus::CancellationPending
19833 }
19834 crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19835 };
19836 car_proto::RunSubscribeResponse {
19837 run_id: run_id.clone(),
19838 agent_id: owning_agent.clone(),
19839 turns,
19840 cursor,
19841 limit,
19842 next_cursor,
19843 live_cursor,
19844 subscribed: next_cursor.is_none(),
19845 status,
19846 }
19847 }
19848 None => {
19853 let run_store = state.run_store.clone();
19854 let durable_run_id = run_id.clone();
19855 let durable_agent = owning_agent.clone();
19856 let page = strict_run_trace_read_blocking(
19857 "runs.subscribe retained durable page",
19858 state,
19859 run_id.clone(),
19860 move || {
19861 run_store.get_run_turn_page_for(&durable_agent, &durable_run_id, cursor, limit)
19862 },
19863 )
19864 .await?;
19865 if let Some((turns, next_cursor, live_cursor, durable_status)) = page {
19866 let status = match durable_status {
19867 crate::run_store::RunStatus::InProgress => car_proto::RunLiveStatus::InProgress,
19868 crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19869 crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19870 crate::run_store::RunStatus::CancellationPending => {
19871 car_proto::RunLiveStatus::CancellationPending
19872 }
19873 crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19874 };
19875 car_proto::RunSubscribeResponse {
19876 run_id: run_id.clone(),
19877 agent_id: owning_agent.clone(),
19878 turns,
19879 cursor,
19880 limit,
19881 next_cursor,
19882 live_cursor,
19883 subscribed: next_cursor.is_none(),
19884 status,
19885 }
19886 } else if !paginated {
19887 let run_store = state.run_store.clone();
19893 let durable_run_id = run_id.clone();
19894 let durable_agent = owning_agent.clone();
19895 let records = strict_run_trace_read_blocking(
19896 "runs.subscribe pre-v3 durable trace",
19897 state,
19898 run_id.clone(),
19899 move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19900 )
19901 .await?;
19902 let Some(records) = records else {
19903 return not_found();
19904 };
19905 let mut status = car_proto::RunLiveStatus::InProgress;
19906 let mut turns = Vec::new();
19907 for record in records {
19908 match record {
19909 turn @ car_proto::RunRecord::Turn(_) => turns.push(turn),
19910 car_proto::RunRecord::CancellationRequested(_)
19911 | car_proto::RunRecord::CancellationResult(_) => {
19912 if status == car_proto::RunLiveStatus::InProgress {
19913 status = car_proto::RunLiveStatus::CancellationPending;
19914 }
19915 }
19916 car_proto::RunRecord::Ended(ended) => {
19917 status = match ended.termination {
19918 car_proto::RunTermination::Outcome { .. } => {
19919 car_proto::RunLiveStatus::Completed
19920 }
19921 car_proto::RunTermination::Incomplete => {
19922 car_proto::RunLiveStatus::Incomplete
19923 }
19924 car_proto::RunTermination::Cancelled { .. } => {
19925 car_proto::RunLiveStatus::Cancelled
19926 }
19927 };
19928 }
19929 car_proto::RunRecord::Started(_) => {}
19930 }
19931 }
19932 let live_cursor = turns.len();
19933 car_proto::RunSubscribeResponse {
19934 run_id: run_id.clone(),
19935 agent_id: owning_agent.clone(),
19936 turns,
19937 cursor: 0,
19938 limit,
19939 next_cursor: None,
19940 live_cursor,
19941 subscribed: false,
19942 status,
19943 }
19944 } else {
19945 return not_found();
19946 }
19947 }
19948 };
19949 if paginated {
19950 serde_json::to_value(page).map_err(|e| e.to_string())
19951 } else {
19952 serde_json::to_value(serde_json::json!({
19953 "run_id": page.run_id,
19954 "agent_id": page.agent_id,
19955 "turns_so_far": page.turns,
19956 "cursor": page.live_cursor,
19957 "status": page.status,
19958 }))
19959 .map_err(|e| e.to_string())
19960 }
19961}
19962
19963async fn handle_runs_unsubscribe(
19968 req: &JsonRpcMessage,
19969 session: &crate::session::ClientSession,
19970 state: &Arc<ServerState>,
19971) -> Result<Value, String> {
19972 let params: car_proto::RunUnsubscribeRequest = serde_json::from_value(req.params.clone())
19973 .map_err(|e| format!("runs.unsubscribe requires {{ run_id }}: {e}"))?;
19974 let removed = state
19975 .unsubscribe_run(¶ms.run_id, &session.client_id)
19976 .await;
19977 serde_json::to_value(car_proto::RunUnsubscribeResponse {
19978 run_id: params.run_id,
19979 removed,
19980 })
19981 .map_err(|e| e.to_string())
19982}
19983
19984async fn handle_runs_list(
19996 req: &JsonRpcMessage,
19997 session: &crate::session::ClientSession,
19998 state: &Arc<ServerState>,
19999) -> Result<Value, String> {
20000 #[derive(Deserialize)]
20001 struct LegacyRequest {
20002 agent_id: String,
20003 }
20004 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
20005 let (agent_id, page) = if paginated {
20006 let params: car_proto::RunListRequest = serde_json::from_value(req.params.clone())
20007 .map_err(|e| format!("runs.list requires {{ agent_id, cursor, limit }}: {e}"))?;
20008 (params.agent_id, Some((params.cursor, params.limit)))
20009 } else {
20010 let params: LegacyRequest = serde_json::from_value(req.params.clone())
20011 .map_err(|e| format!("runs.list requires {{ agent_id }}: {e}"))?;
20012 (params.agent_id, None)
20013 };
20014
20015 authorize_run_access(session, state, &agent_id).await?;
20018
20019 const MAX_LIMIT: usize = 200;
20020 if let Some((_, limit)) = page {
20021 if !(1..=MAX_LIMIT).contains(&limit) {
20022 return Err(format!("runs.list limit must be between 1 and {MAX_LIMIT}"));
20023 }
20024 }
20025 let run_store = state.run_store.clone();
20026 let durable_agent_id = agent_id.clone();
20027 let Some((cursor, limit)) = page else {
20028 let runs = run_store_blocking("runs.list legacy complete read", move || {
20029 Ok(run_store.list_runs(&durable_agent_id))
20030 })
20031 .await?;
20032 return Ok(serde_json::json!({"agent_id": agent_id, "runs": runs}));
20033 };
20034 let (runs, next_cursor) = run_store_blocking("runs.list page", move || {
20035 run_store
20036 .list_runs_page(&durable_agent_id, cursor, limit)
20037 .map_err(|error| format!("runs.list durable index read failed: {error}"))
20038 })
20039 .await?;
20040 let mut response = serde_json::json!({
20041 "agent_id": agent_id,
20042 "runs": runs,
20043 "cursor": cursor,
20044 "limit": limit,
20045 });
20046 if let Some(next_cursor) = next_cursor {
20047 response["next_cursor"] = serde_json::json!(next_cursor);
20048 }
20049 Ok(response)
20050}
20051
20052async fn handle_runs_get_trace(
20067 req: &JsonRpcMessage,
20068 session: &crate::session::ClientSession,
20069 state: &Arc<ServerState>,
20070) -> Result<Value, String> {
20071 #[derive(Deserialize)]
20072 struct LegacyRequest {
20073 run_id: String,
20074 #[serde(default)]
20075 cursor: Option<usize>,
20076 }
20077 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
20078 let (run_id, cursor, limit) = if paginated {
20079 let params: car_proto::RunGetTraceRequest = serde_json::from_value(req.params.clone())
20080 .map_err(|e| format!("runs.get_trace requires {{ run_id, cursor, limit }}: {e}"))?;
20081 (params.run_id, params.cursor, Some(params.limit))
20082 } else {
20083 let params: LegacyRequest = serde_json::from_value(req.params.clone())
20084 .map_err(|e| format!("runs.get_trace requires {{ run_id }}: {e}"))?;
20085 (params.run_id, params.cursor.unwrap_or(0), None)
20086 };
20087 const MAX_LIMIT: usize = 500;
20088 if let Some(limit) = limit {
20089 if !(1..=MAX_LIMIT).contains(&limit) {
20090 return Err(format!(
20091 "runs.get_trace limit must be between 1 and {MAX_LIMIT}"
20092 ));
20093 }
20094 }
20095
20096 let run_store = state.run_store.clone();
20103 let durable_run_id = run_id.clone();
20104 let owning_agent = run_store_blocking("runs.get_trace owner lookup", move || {
20105 Ok(run_store.agent_for_run(&durable_run_id))
20106 })
20107 .await?;
20108 let Some(owning_agent) = owning_agent else {
20109 return serde_json::to_value(serde_json::json!({
20110 "run_id": run_id,
20111 "not_found": true,
20112 }))
20113 .map_err(|e| e.to_string());
20114 };
20115
20116 authorize_run_access(session, state, &owning_agent).await?;
20118
20119 let run_store = state.run_store.clone();
20122 let durable_agent = owning_agent.clone();
20123 let durable_run_id = run_id.clone();
20124 if limit.is_none() {
20125 let records = strict_run_trace_read_blocking(
20126 "runs.get_trace legacy complete read",
20127 state,
20128 run_id.clone(),
20129 move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
20130 )
20131 .await?;
20132 let Some(records) = records else {
20133 return Ok(serde_json::json!({"run_id": run_id, "not_found": true}));
20134 };
20135 let records = records.into_iter().skip(cursor).collect::<Vec<_>>();
20136 return Ok(serde_json::json!({
20137 "run_id": run_id,
20138 "agent_id": owning_agent,
20139 "records": records,
20140 "cursor": cursor,
20141 }));
20142 }
20143 let limit = limit.expect("paginated path has a limit");
20144 let page =
20145 strict_run_trace_read_blocking("runs.get_trace page", state, run_id.clone(), move || {
20146 run_store.get_run_trace_page_for(&durable_agent, &durable_run_id, cursor, limit)
20147 })
20148 .await?;
20149 let Some((paged, next_cursor)) = page else {
20150 return serde_json::to_value(serde_json::json!({
20151 "run_id": run_id,
20152 "not_found": true,
20153 }))
20154 .map_err(|e| e.to_string());
20155 };
20156
20157 let mut response = serde_json::json!({
20158 "run_id": run_id,
20159 "agent_id": owning_agent,
20160 "records": paged,
20161 "cursor": cursor,
20162 "limit": limit,
20163 });
20164 if let Some(next_cursor) = next_cursor {
20165 response["next_cursor"] = serde_json::json!(next_cursor);
20166 }
20167 Ok(response)
20168}
20169
20170fn run_trace_read_error(run_id: &str, error: std::io::Error) -> String {
20171 if crate::run_store::is_trace_corruption_error(&error) {
20172 format!(
20173 "{} run `{run_id}`: {error}",
20174 car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX
20175 )
20176 } else {
20177 format!("run trace read failed for `{run_id}`: {error}")
20178 }
20179}
20180
20181async fn handle_replan_set_config(
20186 msg: &JsonRpcMessage,
20187 session: &crate::session::ClientSession,
20188) -> Result<Value, String> {
20189 let max_replans = msg
20190 .params
20191 .get("max_replans")
20192 .and_then(|v| v.as_u64())
20193 .unwrap_or(0) as u32;
20194 let delay_ms = msg
20195 .params
20196 .get("delay_ms")
20197 .and_then(|v| v.as_u64())
20198 .unwrap_or(0);
20199 let verify_before_execute = msg
20200 .params
20201 .get("verify_before_execute")
20202 .and_then(|v| v.as_bool())
20203 .unwrap_or(true);
20204 let replan_on_rejected = msg
20205 .params
20206 .get("replan_on_rejected")
20207 .and_then(|v| v.as_bool())
20208 .unwrap_or(false);
20209 let cfg = car_engine::ReplanConfig {
20210 max_replans,
20211 delay_ms,
20212 verify_before_execute,
20213 replan_on_rejected,
20214 };
20215 session.runtime.set_replan_config(cfg).await;
20216 Ok(Value::Null)
20217}
20218
20219async fn handle_skills_list(
20220 msg: &JsonRpcMessage,
20221 session: &crate::session::ClientSession,
20222) -> Result<Value, String> {
20223 let domain = msg.params.get("domain").and_then(|v| v.as_str());
20224 let engine = session.memgine.lock().await;
20225 let skills: Vec<serde_json::Value> = engine
20226 .graph
20227 .inner
20228 .node_indices()
20229 .filter_map(|nix| {
20230 let node = engine.graph.inner.node_weight(nix)?;
20231 if node.kind != car_memgine::MemKind::Skill {
20232 return None;
20233 }
20234 let meta = car_memgine::SkillMeta::from_node(node)?;
20235 if let Some(d) = domain {
20236 match &meta.scope {
20237 car_memgine::SkillScope::Global => {}
20238 car_memgine::SkillScope::Domain(sd) if sd == d => {}
20239 _ => return None,
20240 }
20241 }
20242 Some(serde_json::to_value(&meta).unwrap_or_default())
20243 })
20244 .collect();
20245 serde_json::to_value(&skills).map_err(|e| e.to_string())
20246}
20247
20248#[derive(serde::Deserialize)]
20249struct SecretParams {
20250 #[serde(default)]
20251 service: Option<String>,
20252 key: String,
20253 #[serde(default)]
20254 value: Option<String>,
20255 #[serde(default)]
20259 operator_broker: bool,
20260}
20261
20262fn canonical_secret_key(key: &str) -> &str {
20263 if key.eq_ignore_ascii_case("openrouter") {
20264 car_inference::openrouter::API_KEY_ENV
20265 } else {
20266 key
20267 }
20268}
20269
20270fn reject_reserved_oauth_secret(service: Option<&str>, key: &str) -> Result<(), String> {
20271 if car_inference::openrouter::is_reserved_oauth_secret(service, key) {
20272 Err("reserved_private_secret: the OpenRouter OAuth credential is managed only by openrouter.auth_start/openrouter.disconnect".to_string())
20273 } else {
20274 Ok(())
20275 }
20276}
20277
20278fn is_openrouter_pasted_authority_slot(service: Option<&str>, key: &str) -> bool {
20279 let is_default_service = service.is_none() || service == Some(car_secrets::DEFAULT_SERVICE);
20280 is_default_service && key == car_inference::openrouter::API_KEY_ENV
20281}
20282
20283async fn handle_secret_put(req: &JsonRpcMessage) -> Result<Value, String> {
20284 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20285 let value = p.value.ok_or_else(|| "missing 'value'".to_string())?;
20286 let key = canonical_secret_key(&p.key);
20287 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20288 if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20289 return car_ffi_common::secrets::put(p.service.as_deref(), key, &value);
20290 }
20291 let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(true, || {
20292 car_ffi_common::secrets::put(p.service.as_deref(), key, &value)
20293 })
20294 .await;
20295 result.map(|value| with_openrouter_authority_generation(value, &final_status))
20296}
20297
20298async fn handle_secret_get(
20299 req: &JsonRpcMessage,
20300 session: &crate::session::ClientSession,
20301 state: &crate::session::ServerState,
20302) -> Result<Value, String> {
20303 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20304 let key = canonical_secret_key(&p.key);
20305 let agent_id = session.agent_id.lock().await.clone();
20306
20307 if p.operator_broker {
20308 if state.auth_token.get().is_none()
20314 || !session
20315 .authenticated
20316 .load(std::sync::atomic::Ordering::Acquire)
20317 || !session.is_host.load(std::sync::atomic::Ordering::Acquire)
20318 || agent_id.is_some()
20319 {
20320 return Err(
20321 "operator_broker_denied: secret.get operator broker requires an authenticated local host session"
20322 .to_string(),
20323 );
20324 }
20325 let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
20326 let action = car_ir::Action::tool_call("secret.get")
20327 .with_param("service", Value::String(service.to_string()))
20328 .with_param("key", Value::String(key.to_string()))
20329 .with_param("caller_role", Value::String("operator".to_string()));
20330 let result =
20331 car_ffi_common::secrets::get_for_daemon_operator_broker(p.service.as_deref(), key);
20332 let (kind, outcome) = if result.is_ok() {
20333 (car_eventlog::EventKind::ActionSucceeded, "read")
20334 } else {
20335 (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20336 };
20337 session.runtime.log.lock().await.append(
20338 kind,
20339 Some(&action.id),
20340 None,
20341 operator_secret_read_audit_data(service, key, outcome),
20342 );
20343 return result;
20344 }
20345
20346 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20347
20348 let Some(agent_id) = agent_id else {
20349 return car_ffi_common::secrets::get(p.service.as_deref(), key);
20354 };
20355 let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
20356 let action = car_ir::Action::tool_call("secret.get")
20357 .with_param("service", Value::String(service.to_string()))
20358 .with_param("key", Value::String(key.to_string()))
20359 .with_param("agent_id", Value::String(agent_id.clone()));
20360 let violations = session
20361 .runtime
20362 .policies
20363 .read()
20364 .await
20365 .check(&action, &session.runtime.state);
20366 if !violations.is_empty() {
20367 let policies: Vec<Value> = violations
20368 .iter()
20369 .map(|violation| Value::String(violation.policy_name.clone()))
20370 .collect();
20371 let mut data = secret_read_audit_data(service, key, &agent_id, "policy_denied");
20372 data.insert("policies".to_string(), Value::Array(policies.clone()));
20373 session.runtime.log.lock().await.append(
20374 car_eventlog::EventKind::PolicyViolation,
20375 Some(&action.id),
20376 None,
20377 data,
20378 );
20379 return Err(serde_json::json!({
20380 "code": "policy_denied",
20381 "message": "secret.get was denied by the daemon policy engine",
20382 "context": {"service": service, "key": key, "agent_id": agent_id},
20383 "policies": policies,
20384 })
20385 .to_string());
20386 }
20387
20388 let result = car_ffi_common::secrets::get(p.service.as_deref(), key);
20389 let (kind, outcome) = if result.is_ok() {
20390 (car_eventlog::EventKind::ActionSucceeded, "read")
20391 } else {
20392 (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20393 };
20394 session.runtime.log.lock().await.append(
20395 kind,
20396 Some(&action.id),
20397 None,
20398 secret_read_audit_data(service, key, &agent_id, outcome),
20399 );
20400 result
20401}
20402
20403fn secret_read_audit_data(
20404 service: &str,
20405 key: &str,
20406 agent_id: &str,
20407 outcome: &str,
20408) -> HashMap<String, Value> {
20409 HashMap::from([
20410 ("tool".to_string(), Value::String("secret.get".to_string())),
20411 ("service".to_string(), Value::String(service.to_string())),
20412 ("key".to_string(), Value::String(key.to_string())),
20413 ("agent_id".to_string(), Value::String(agent_id.to_string())),
20414 ("outcome".to_string(), Value::String(outcome.to_string())),
20415 ])
20416}
20417
20418fn operator_secret_read_audit_data(
20419 service: &str,
20420 key: &str,
20421 outcome: &str,
20422) -> HashMap<String, Value> {
20423 HashMap::from([
20424 ("tool".to_string(), Value::String("secret.get".to_string())),
20425 ("service".to_string(), Value::String(service.to_string())),
20426 ("key".to_string(), Value::String(key.to_string())),
20427 (
20428 "caller_role".to_string(),
20429 Value::String("operator".to_string()),
20430 ),
20431 ("outcome".to_string(), Value::String(outcome.to_string())),
20432 ])
20433}
20434
20435async fn handle_secret_delete(req: &JsonRpcMessage) -> Result<Value, String> {
20436 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20437 let key = canonical_secret_key(&p.key);
20438 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20439 if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20440 return car_ffi_common::secrets::delete(p.service.as_deref(), key);
20441 }
20442 let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(false, || {
20443 car_ffi_common::secrets::delete(p.service.as_deref(), key)
20444 })
20445 .await;
20446 result.map(|value| with_openrouter_authority_generation(value, &final_status))
20447}
20448
20449fn with_openrouter_authority_generation(
20450 mut value: Value,
20451 status: &crate::openrouter_auth::Status,
20452) -> Value {
20453 if let Value::Object(fields) = &mut value {
20454 fields.insert(
20455 "authority_generation".to_string(),
20456 Value::from(status.authority_generation),
20457 );
20458 return value;
20459 }
20460 serde_json::json!({
20461 "ok": true,
20462 "authority_generation": status.authority_generation,
20463 "result": value,
20464 })
20465}
20466
20467fn handle_secret_status(req: &JsonRpcMessage) -> Result<Value, String> {
20468 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20469 car_ffi_common::secrets::status(p.service.as_deref(), canonical_secret_key(&p.key))
20470}
20471
20472#[cfg(test)]
20473mod openrouter_secret_service_authority_tests {
20474 use super::{handle_secret_delete, handle_secret_put, handle_secret_status, JsonRpcMessage};
20475 use serde_json::{json, Value};
20476
20477 static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
20478
20479 struct FileSecretEnvironment(Option<std::ffi::OsString>);
20480
20481 impl FileSecretEnvironment {
20482 fn install(path: &std::path::Path) -> Self {
20483 let previous = std::env::var_os("CAR_SECRETS_FILE_DIR");
20484 unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", path) };
20485 Self(previous)
20486 }
20487 }
20488
20489 impl Drop for FileSecretEnvironment {
20490 fn drop(&mut self) {
20491 match self.0.take() {
20492 Some(previous) => unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", previous) },
20493 None => unsafe { std::env::remove_var("CAR_SECRETS_FILE_DIR") },
20494 }
20495 }
20496 }
20497
20498 fn req(params: Value) -> JsonRpcMessage {
20499 JsonRpcMessage {
20500 jsonrpc: "2.0".into(),
20501 method: None,
20502 params,
20503 id: json!(1),
20504 result: None,
20505 error: None,
20506 }
20507 }
20508
20509 #[tokio::test(flavor = "current_thread")]
20510 async fn non_default_service_put_is_generic_and_does_not_publish_pasted_authority() {
20511 let _env_lock = ENV_LOCK.lock().await;
20512 let temp = tempfile::TempDir::new().unwrap();
20513 let _environment = FileSecretEnvironment::install(temp.path());
20514 let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20515
20516 let result = handle_secret_put(&req(json!({
20517 "service": "integration-test-vault",
20518 "key": "openrouter",
20519 "value": "custom-service-only"
20520 })))
20521 .await
20522 .unwrap();
20523 assert_eq!(result["service"], "integration-test-vault");
20524 assert_eq!(result["key"], car_inference::openrouter::API_KEY_ENV);
20525 assert!(
20526 result.get("authority_generation").is_none(),
20527 "a generic service write must not claim it changed OpenRouter authority: {result}"
20528 );
20529 let generic_status = handle_secret_status(&req(json!({
20530 "service": "integration-test-vault",
20531 "key": "openrouter"
20532 })))
20533 .unwrap();
20534 assert_eq!(generic_status["exists"], true);
20535 assert_eq!(
20536 serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20537 authority_before,
20538 "a non-default service cannot supersede OAuth/default-service resolution"
20539 );
20540 }
20541
20542 #[tokio::test(flavor = "current_thread")]
20543 async fn non_default_service_delete_is_generic_and_does_not_publish_false_absence() {
20544 let _env_lock = ENV_LOCK.lock().await;
20545 let temp = tempfile::TempDir::new().unwrap();
20546 let _environment = FileSecretEnvironment::install(temp.path());
20547 car_ffi_common::secrets::put(
20548 Some("integration-test-vault-delete"),
20549 car_inference::openrouter::API_KEY_ENV,
20550 "custom-service-only",
20551 )
20552 .unwrap();
20553 let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20554
20555 let result = handle_secret_delete(&req(json!({
20556 "service": "integration-test-vault-delete",
20557 "key": "openrouter"
20558 })))
20559 .await
20560 .unwrap();
20561 assert_eq!(result["service"], "integration-test-vault-delete");
20562 assert!(
20563 result.get("authority_generation").is_none(),
20564 "a generic service delete must not claim it changed OpenRouter authority: {result}"
20565 );
20566 let generic_status = handle_secret_status(&req(json!({
20567 "service": "integration-test-vault-delete",
20568 "key": "openrouter"
20569 })))
20570 .unwrap();
20571 assert_eq!(generic_status["exists"], false);
20572 assert_eq!(
20573 serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20574 authority_before,
20575 "deleting another service's key cannot publish false default-service absence"
20576 );
20577 }
20578}
20579
20580#[derive(serde::Deserialize)]
20581struct PermParams {
20582 domain: String,
20583 #[serde(default)]
20584 target_bundle_id: Option<String>,
20585}
20586
20587fn handle_perm_status(req: &JsonRpcMessage) -> Result<Value, String> {
20588 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20589 car_ffi_common::permissions::status(&p.domain, p.target_bundle_id.as_deref())
20590}
20591
20592fn handle_perm_request(req: &JsonRpcMessage) -> Result<Value, String> {
20593 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20594 car_ffi_common::permissions::request(&p.domain, p.target_bundle_id.as_deref())
20595}
20596
20597fn handle_perm_explain(req: &JsonRpcMessage) -> Result<Value, String> {
20598 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20599 car_ffi_common::permissions::explain(&p.domain, p.target_bundle_id.as_deref())
20600}
20601
20602fn handle_calendar_events(req: &JsonRpcMessage) -> Result<Value, String> {
20603 #[derive(serde::Deserialize)]
20604 struct P {
20605 start: String,
20606 end: String,
20607 #[serde(default)]
20608 calendar_ids: Vec<String>,
20609 }
20610 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20611 let start = chrono::DateTime::parse_from_rfc3339(&p.start)
20612 .map_err(|e| format!("parse start: {}", e))?
20613 .with_timezone(&chrono::Utc);
20614 let end = chrono::DateTime::parse_from_rfc3339(&p.end)
20615 .map_err(|e| format!("parse end: {}", e))?
20616 .with_timezone(&chrono::Utc);
20617 car_ffi_common::integrations::calendar_events(start, end, &p.calendar_ids)
20618}
20619
20620fn handle_calendar_create_event(req: &JsonRpcMessage) -> Result<Value, String> {
20621 let raw = req.params.to_string();
20622 car_ffi_common::integrations::calendar_create_event(&raw)
20623}
20624
20625fn handle_calendar_update_event(req: &JsonRpcMessage) -> Result<Value, String> {
20626 let raw = req.params.to_string();
20627 car_ffi_common::integrations::calendar_update_event(&raw)
20628}
20629
20630fn handle_calendar_delete_event(req: &JsonRpcMessage) -> Result<Value, String> {
20631 #[derive(serde::Deserialize)]
20632 struct P {
20633 event_id: String,
20634 }
20635 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20636 car_ffi_common::integrations::calendar_delete_event(&p.event_id)
20637}
20638
20639fn handle_contacts_find(req: &JsonRpcMessage) -> Result<Value, String> {
20640 #[derive(serde::Deserialize)]
20641 struct P {
20642 query: String,
20643 #[serde(default = "default_limit")]
20644 limit: usize,
20645 #[serde(default)]
20646 container_ids: Vec<String>,
20647 }
20648 fn default_limit() -> usize {
20649 50
20650 }
20651 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20652 car_ffi_common::integrations::contacts_list(&p.query, &p.container_ids, p.limit)
20653}
20654
20655fn handle_mail_inbox(req: &JsonRpcMessage) -> Result<Value, String> {
20656 #[derive(serde::Deserialize, Default)]
20657 struct P {
20658 #[serde(default)]
20659 account_ids: Vec<String>,
20660 }
20661 let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20662 car_ffi_common::integrations::mail_inbox(&p.account_ids)
20663}
20664
20665fn handle_mail_mailboxes(req: &JsonRpcMessage) -> Result<Value, String> {
20666 #[derive(serde::Deserialize, Default)]
20667 struct P {
20668 #[serde(default)]
20669 account_ids: Vec<String>,
20670 }
20671 let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20672 car_ffi_common::integrations::mail_mailboxes(&p.account_ids)
20673}
20674
20675fn handle_mail_messages(req: &JsonRpcMessage) -> Result<Value, String> {
20680 let raw = if req.params.is_null() {
20681 "{}".to_string()
20682 } else {
20683 req.params.to_string()
20684 };
20685 car_ffi_common::integrations::mail_messages(&raw)
20686}
20687
20688fn handle_mail_message_body(req: &JsonRpcMessage) -> Result<Value, String> {
20689 #[derive(serde::Deserialize)]
20690 struct P {
20691 message_id: String,
20692 }
20693 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20694 car_ffi_common::integrations::mail_message_body(&p.message_id)
20695}
20696
20697fn handle_mail_send(req: &JsonRpcMessage) -> Result<Value, String> {
20698 let raw = req.params.to_string();
20699 car_ffi_common::integrations::mail_send(&raw)
20700}
20701
20702fn handle_messages_chats(req: &JsonRpcMessage) -> Result<Value, String> {
20703 #[derive(serde::Deserialize)]
20704 struct P {
20705 #[serde(default = "default_limit")]
20706 limit: usize,
20707 }
20708 fn default_limit() -> usize {
20709 50
20710 }
20711 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20712 car_ffi_common::integrations::messages_chats(p.limit)
20713}
20714
20715fn handle_messages_read(req: &JsonRpcMessage) -> Result<Value, String> {
20718 let raw = if req.params.is_null() {
20719 "{}".to_string()
20720 } else {
20721 req.params.to_string()
20722 };
20723 car_ffi_common::integrations::messages_read(&raw)
20724}
20725
20726fn handle_messages_send(req: &JsonRpcMessage) -> Result<Value, String> {
20727 let raw = req.params.to_string();
20728 car_ffi_common::integrations::messages_send(&raw)
20729}
20730
20731#[cfg(test)]
20732mod personal_message_read_limit_tests {
20733 use super::{
20734 handle_mail_messages, handle_messages_read, json_rpc_response_for_handler_result,
20735 HandlerFailure, JsonRpcMessage,
20736 };
20737 use serde_json::{json, to_value};
20738
20739 #[test]
20740 fn daemon_rejects_over_cap_reads_as_invalid_params() {
20741 let request = JsonRpcMessage {
20742 jsonrpc: "2.0".into(),
20743 method: None,
20744 params: json!({"limit": car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP + 1}),
20745 id: json!(7),
20746 result: None,
20747 error: None,
20748 };
20749
20750 for (method, result) in [
20751 ("mail.messages", handle_mail_messages(&request)),
20752 ("messages.read", handle_messages_read(&request)),
20753 ] {
20754 let failure = HandlerFailure::from_dispatch(
20755 result.expect_err("an over-cap daemon read must be rejected"),
20756 );
20757 let response = to_value(json_rpc_response_for_handler_result(
20758 request.id.clone(),
20759 Err(failure),
20760 ))
20761 .expect("serialize JSON-RPC response");
20762
20763 assert_eq!(response["error"]["code"], -32602, "{method}");
20764 assert_eq!(
20765 response["error"]["message"],
20766 format!(
20767 "invalid params: {method} limit must not exceed {}",
20768 car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP
20769 )
20770 );
20771 }
20772 }
20773}
20774
20775fn handle_notes_find(req: &JsonRpcMessage) -> Result<Value, String> {
20776 #[derive(serde::Deserialize)]
20777 struct P {
20778 query: String,
20779 #[serde(default = "default_limit")]
20780 limit: usize,
20781 }
20782 fn default_limit() -> usize {
20783 50
20784 }
20785 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20786 car_ffi_common::integrations::notes_find(&p.query, p.limit)
20787}
20788
20789fn handle_reminders_items(req: &JsonRpcMessage) -> Result<Value, String> {
20790 #[derive(serde::Deserialize)]
20791 struct P {
20792 #[serde(default = "default_limit")]
20793 limit: usize,
20794 }
20795 fn default_limit() -> usize {
20796 50
20797 }
20798 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20799 car_ffi_common::integrations::reminders_items(p.limit)
20800}
20801
20802fn handle_bookmarks_list(req: &JsonRpcMessage) -> Result<Value, String> {
20803 #[derive(serde::Deserialize)]
20804 struct P {
20805 #[serde(default = "default_limit")]
20806 limit: usize,
20807 }
20808 fn default_limit() -> usize {
20809 100
20810 }
20811 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 100 });
20812 car_ffi_common::integrations::bookmarks_list(p.limit)
20813}
20814
20815fn handle_health_sleep(req: &JsonRpcMessage) -> Result<Value, String> {
20816 #[derive(serde::Deserialize)]
20817 struct P {
20818 start: String,
20819 end: String,
20820 }
20821 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20822 let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20823 .map_err(|e| format!("parse start: {}", e))?
20824 .with_timezone(&chrono::Utc);
20825 let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20826 .map_err(|e| format!("parse end: {}", e))?
20827 .with_timezone(&chrono::Utc);
20828 car_ffi_common::health::sleep_windows(s, e)
20829}
20830
20831fn handle_health_workouts(req: &JsonRpcMessage) -> Result<Value, String> {
20832 #[derive(serde::Deserialize)]
20833 struct P {
20834 start: String,
20835 end: String,
20836 }
20837 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20838 let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20839 .map_err(|e| format!("parse start: {}", e))?
20840 .with_timezone(&chrono::Utc);
20841 let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20842 .map_err(|e| format!("parse end: {}", e))?
20843 .with_timezone(&chrono::Utc);
20844 car_ffi_common::health::workouts(s, e)
20845}
20846
20847fn handle_health_activity(req: &JsonRpcMessage) -> Result<Value, String> {
20848 #[derive(serde::Deserialize)]
20849 struct P {
20850 start: String,
20851 end: String,
20852 }
20853 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20854 let s = chrono::NaiveDate::parse_from_str(&p.start, "%Y-%m-%d")
20855 .map_err(|e| format!("parse start: {}", e))?;
20856 let e = chrono::NaiveDate::parse_from_str(&p.end, "%Y-%m-%d")
20857 .map_err(|e| format!("parse end: {}", e))?;
20858 car_ffi_common::health::activity(s, e)
20859}
20860
20861async fn handle_browser_close(session: &crate::session::ClientSession) -> Result<Value, String> {
20862 let closed = session.browser.close().await?;
20863 Ok(serde_json::json!({"closed": closed}))
20864}
20865
20866async fn handle_browser_run(
20867 req: &JsonRpcMessage,
20868 session: &crate::session::ClientSession,
20869) -> Result<Value, String> {
20870 #[derive(serde::Deserialize)]
20871 struct BrowserRunParams {
20872 script: Value,
20874 #[serde(default)]
20875 width: Option<u32>,
20876 #[serde(default)]
20877 height: Option<u32>,
20878 #[serde(default)]
20883 headed: Option<bool>,
20884 #[serde(default)]
20887 extra_args: Option<Vec<String>>,
20888 }
20889 let params: BrowserRunParams =
20890 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20891
20892 let script_json = browser_script_json(params.script)?;
20897
20898 let browser_session = session
20899 .browser
20900 .get_or_launch(car_ffi_common::browser::BrowserLaunchOptions {
20901 width: params.width.unwrap_or(1280),
20902 height: params.height.unwrap_or(720),
20903 headless: !params.headed.unwrap_or(false),
20904 extra_args: params.extra_args.unwrap_or_default(),
20905 profile_dir: None,
20910 })
20911 .await?;
20912
20913 let trace_json = browser_session.run(&script_json).await?;
20914 serde_json::from_str(&trace_json).map_err(|e| e.to_string())
20915}
20916
20917fn browser_script_json(script: Value) -> Result<String, String> {
20918 match script {
20919 Value::String(script) => Ok(script),
20920 script @ Value::Object(_) => Ok(script.to_string()),
20921 _ => Err("browser.run script must be a string or object".to_string()),
20922 }
20923}
20924
20925#[cfg(test)]
20926mod browser_run_script_tests {
20927 use super::browser_script_json;
20928 use serde_json::{json, Value};
20929
20930 #[test]
20931 fn accepts_only_the_documented_script_shapes_before_browser_launch() {
20932 assert_eq!(
20933 browser_script_json(Value::String(r#"{"operations":[]}"#.to_string()))
20934 .expect("inline JSON string"),
20935 r#"{"operations":[]}"#
20936 );
20937 assert_eq!(
20938 serde_json::from_str::<Value>(
20939 &browser_script_json(json!({"operations": []})).expect("structured object")
20940 )
20941 .expect("serialized object"),
20942 json!({"operations": []})
20943 );
20944
20945 for unsupported in [Value::Null, json!(1), json!(true), json!([])] {
20946 assert_eq!(
20947 browser_script_json(unsupported).expect_err("unsupported JSON shape"),
20948 "browser.run script must be a string or object"
20949 );
20950 }
20951 }
20952}
20953
20954#[derive(Deserialize)]
20967struct VoiceStartParams {
20968 session_id: String,
20969 audio_source: Value,
20970 #[serde(default)]
20971 options: Option<Value>,
20972}
20973
20974async fn handle_voice_transcribe_stream_start(
20975 req: &JsonRpcMessage,
20976 state: &Arc<ServerState>,
20977 session: &Arc<crate::session::ClientSession>,
20978) -> Result<Value, String> {
20979 let params: VoiceStartParams =
20980 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20981 let audio_source_json =
20982 serde_json::to_string(¶ms.audio_source).map_err(|e| e.to_string())?;
20983 let options_json = params
20984 .options
20985 .as_ref()
20986 .map(|v| serde_json::to_string(v).map_err(|e| e.to_string()))
20987 .transpose()?;
20988 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20989 channel: session.channel.clone(),
20990 });
20991 let json = car_ffi_common::voice::transcribe_stream_start(
20992 ¶ms.session_id,
20993 &audio_source_json,
20994 options_json.as_deref(),
20995 state.voice_sessions.clone(),
20996 sink,
20997 )
20998 .await?;
20999 serde_json::from_str(&json).map_err(|e| e.to_string())
21000}
21001
21002#[derive(Deserialize)]
21003struct VoiceStopParams {
21004 session_id: String,
21005}
21006
21007async fn handle_voice_transcribe_stream_stop(
21008 req: &JsonRpcMessage,
21009 state: &Arc<ServerState>,
21010) -> Result<Value, String> {
21011 let params: VoiceStopParams =
21012 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21013 let json = car_ffi_common::voice::transcribe_stream_stop(
21014 ¶ms.session_id,
21015 state.voice_sessions.clone(),
21016 )
21017 .await?;
21018 serde_json::from_str(&json).map_err(|e| e.to_string())
21019}
21020
21021#[derive(Deserialize)]
21022struct VoicePushParams {
21023 session_id: String,
21024 pcm_b64: String,
21028}
21029
21030async fn handle_voice_transcribe_stream_push(
21031 req: &JsonRpcMessage,
21032 state: &Arc<ServerState>,
21033) -> Result<Value, String> {
21034 use base64::Engine;
21035 let params: VoicePushParams =
21036 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21037 let pcm = base64::engine::general_purpose::STANDARD
21038 .decode(¶ms.pcm_b64)
21039 .map_err(|e| format!("invalid pcm_b64: {}", e))?;
21040 let json = car_ffi_common::voice::transcribe_stream_push(
21041 ¶ms.session_id,
21042 &pcm,
21043 state.voice_sessions.clone(),
21044 )
21045 .await?;
21046 serde_json::from_str(&json).map_err(|e| e.to_string())
21047}
21048
21049fn handle_voice_sessions_list(state: &Arc<ServerState>) -> Value {
21050 let json = car_ffi_common::voice::list_voice_sessions(state.voice_sessions.clone());
21051 serde_json::from_str(&json).unwrap_or(Value::Null)
21052}
21053
21054#[derive(Deserialize)]
21055struct VoiceTtsStreamStartParams {
21056 stream_id: String,
21060 text: String,
21063 #[serde(default)]
21066 options: Option<Value>,
21067}
21068
21069async fn handle_voice_tts_stream_start(
21070 req: &JsonRpcMessage,
21071 session: &Arc<crate::session::ClientSession>,
21072) -> Result<Value, String> {
21073 let params: VoiceTtsStreamStartParams =
21074 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21075 let opts_str = params
21076 .options
21077 .as_ref()
21078 .map(|v| v.to_string())
21079 .filter(|s| !s.is_empty());
21080 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
21081 channel: session.channel.clone(),
21082 });
21083 let json = car_ffi_common::voice::tts_stream_start(
21084 ¶ms.stream_id,
21085 ¶ms.text,
21086 opts_str.as_deref(),
21087 sink,
21088 )
21089 .await?;
21090 serde_json::from_str(&json).map_err(|e| e.to_string())
21091}
21092
21093#[derive(Deserialize)]
21094struct VoiceTtsStreamCancelParams {
21095 stream_id: String,
21096}
21097
21098async fn handle_voice_tts_stream_cancel(req: &JsonRpcMessage) -> Result<Value, String> {
21099 let params: VoiceTtsStreamCancelParams =
21100 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21101 let json = car_ffi_common::voice::tts_stream_cancel(¶ms.stream_id).await?;
21102 serde_json::from_str(&json).map_err(|e| e.to_string())
21103}
21104
21105fn handle_voice_tts_stream_list() -> Value {
21106 let json = car_ffi_common::voice::list_tts_streams();
21107 serde_json::from_str(&json).unwrap_or(Value::Null)
21108}
21109
21110async fn handle_voice_dispatch_turn(
21111 req: &JsonRpcMessage,
21112 state: &Arc<ServerState>,
21113 session: &Arc<crate::session::ClientSession>,
21114) -> Result<Value, String> {
21115 let req_value = req.params.clone();
21116 let request: crate::voice_turn::DispatchVoiceTurnRequest =
21117 serde_json::from_value(req_value).map_err(|e| e.to_string())?;
21118 let engine = get_inference_engine(state).clone();
21119 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
21120 channel: session.channel.clone(),
21121 });
21122 let resp = crate::voice_turn::dispatch(engine, request, sink).await?;
21123 serde_json::to_value(resp).map_err(|e| e.to_string())
21124}
21125
21126async fn handle_voice_cancel_turn() -> Result<Value, String> {
21127 crate::voice_turn::cancel().await;
21128 Ok(serde_json::json!({"cancelled": true}))
21129}
21130
21131async fn handle_voice_prewarm_turn(state: &Arc<ServerState>) -> Result<Value, String> {
21132 let engine = get_inference_engine(state).clone();
21133 crate::voice_turn::prewarm(engine).await;
21134 Ok(serde_json::json!({"prewarmed": true}))
21135}
21136
21137fn ws_runner_sessions() -> &'static dashmap::DashMap<String, Arc<crate::session::WsChannel>> {
21167 static MAP: std::sync::OnceLock<dashmap::DashMap<String, Arc<crate::session::WsChannel>>> =
21168 std::sync::OnceLock::new();
21169 MAP.get_or_init(dashmap::DashMap::new)
21170}
21171
21172fn ws_runner_fallback() -> &'static std::sync::RwLock<Option<Arc<crate::session::WsChannel>>> {
21179 static SLOT: std::sync::OnceLock<std::sync::RwLock<Option<Arc<crate::session::WsChannel>>>> =
21180 std::sync::OnceLock::new();
21181 SLOT.get_or_init(|| std::sync::RwLock::new(None))
21182}
21183
21184fn resolve_runner_channel(caller: Option<&str>) -> Option<Arc<crate::session::WsChannel>> {
21187 if let Some(id) = caller {
21188 if let Some(entry) = ws_runner_sessions().get(id) {
21189 return Some(entry.value().clone());
21190 }
21191 }
21192 ws_runner_fallback().read().ok().and_then(|g| g.clone())
21193}
21194
21195fn ws_runner_calls() -> &'static dashmap::DashMap<String, car_inference::EventEmitter> {
21196 static MAP: std::sync::OnceLock<dashmap::DashMap<String, car_inference::EventEmitter>> =
21197 std::sync::OnceLock::new();
21198 MAP.get_or_init(dashmap::DashMap::new)
21199}
21200
21201fn ws_runner_completions() -> &'static dashmap::DashMap<
21202 String,
21203 tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
21204> {
21205 static MAP: std::sync::OnceLock<
21206 dashmap::DashMap<
21207 String,
21208 tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
21209 >,
21210 > = std::sync::OnceLock::new();
21211 MAP.get_or_init(dashmap::DashMap::new)
21212}
21213
21214struct WsInferenceRunner;
21215
21216#[async_trait::async_trait]
21217impl car_inference::InferenceRunner for WsInferenceRunner {
21218 async fn run(
21219 &self,
21220 request: car_inference::tasks::generate::GenerateRequest,
21221 emitter: car_inference::EventEmitter,
21222 ) -> std::result::Result<car_inference::RunnerResult, car_inference::RunnerError> {
21223 let channel = resolve_runner_channel(request.caller.as_deref()).ok_or_else(|| {
21226 car_inference::RunnerError::Declined(
21227 "no WebSocket inference runner registered — call inference.register_runner first"
21228 .into(),
21229 )
21230 })?;
21231
21232 let call_id = uuid::Uuid::new_v4().to_string();
21233 let request_json = serde_json::to_value(&request)
21234 .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
21235 let (tx, rx) = tokio::sync::oneshot::channel();
21236 ws_runner_calls().insert(call_id.clone(), emitter);
21237 ws_runner_completions().insert(call_id.clone(), tx);
21238
21239 use futures::SinkExt;
21241 let notification = serde_json::json!({
21242 "jsonrpc": "2.0",
21243 "method": "inference.runner.invoke",
21244 "params": {
21245 "call_id": call_id,
21246 "request": request_json,
21247 },
21248 });
21249 let text = serde_json::to_string(¬ification)
21250 .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
21251 let _ = channel
21252 .write
21253 .lock()
21254 .await
21255 .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
21256 .await;
21257
21258 let result = rx.await.map_err(|_| {
21259 car_inference::RunnerError::Failed("runner completion channel dropped".into())
21260 })?;
21261 ws_runner_calls().remove(&call_id);
21262 result.map_err(car_inference::RunnerError::Failed)
21263 }
21264}
21265
21266async fn handle_inference_register_runner(
21267 session: &Arc<crate::session::ClientSession>,
21268) -> Result<Value, String> {
21269 ws_runner_sessions().insert(session.client_id.clone(), session.channel.clone());
21270 let mut guard = ws_runner_fallback()
21274 .write()
21275 .map_err(|e| format!("ws runner slot poisoned: {e}"))?;
21276 let displaced = guard.is_some();
21277 *guard = Some(session.channel.clone());
21278 drop(guard);
21279 car_inference::set_inference_runner(Some(Arc::new(WsInferenceRunner)));
21280 Ok(serde_json::json!({
21284 "registered": true,
21285 "scope": "session",
21286 "runners_registered": ws_runner_sessions().len(),
21287 "became_fallback": true,
21288 "displaced_fallback": displaced,
21289 }))
21290}
21291
21292#[derive(serde::Deserialize)]
21293struct InferenceRunnerEventParams {
21294 call_id: String,
21295 event: Value,
21296}
21297
21298async fn handle_inference_runner_event(req: &JsonRpcMessage) -> Result<Value, String> {
21299 let params: InferenceRunnerEventParams =
21300 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21301 let stream_event = match parse_runner_event_value(¶ms.event) {
21302 Some(e) => e,
21303 None => return Err("unrecognised runner event shape".into()),
21304 };
21305 if let Some(entry) = ws_runner_calls().get(¶ms.call_id) {
21306 let emitter = entry.value().clone();
21307 tokio::spawn(async move { emitter.emit(stream_event).await });
21308 }
21309 Ok(serde_json::json!({"emitted": true}))
21310}
21311
21312#[derive(serde::Deserialize)]
21313struct InferenceRunnerCompleteParams {
21314 call_id: String,
21315 result: Value,
21316}
21317
21318async fn handle_inference_runner_complete(req: &JsonRpcMessage) -> Result<Value, String> {
21319 let params: InferenceRunnerCompleteParams =
21320 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21321 let result: std::result::Result<car_inference::RunnerResult, String> =
21322 serde_json::from_value(params.result)
21323 .map_err(|e| format!("invalid RunnerResult JSON: {e}"));
21324 if let Some((_, tx)) = ws_runner_completions().remove(¶ms.call_id) {
21325 let _ = tx.send(result);
21326 }
21327 Ok(serde_json::json!({"completed": true}))
21328}
21329
21330#[derive(serde::Deserialize)]
21331struct InferenceRunnerFailParams {
21332 call_id: String,
21333 error: String,
21334}
21335
21336async fn handle_inference_runner_fail(req: &JsonRpcMessage) -> Result<Value, String> {
21337 let params: InferenceRunnerFailParams =
21338 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21339 if let Some((_, tx)) = ws_runner_completions().remove(¶ms.call_id) {
21340 let _ = tx.send(Err(params.error));
21341 }
21342 Ok(serde_json::json!({"failed": true}))
21343}
21344
21345fn parse_runner_event_value(v: &Value) -> Option<car_inference::StreamEvent> {
21346 let ty = v.get("type").and_then(|t| t.as_str())?;
21347 match ty {
21348 "text" => Some(car_inference::StreamEvent::TextDelta(
21349 v.get("data")?.as_str()?.to_string(),
21350 )),
21351 "tool_start" => Some(car_inference::StreamEvent::ToolCallStart {
21352 name: v.get("name")?.as_str()?.to_string(),
21353 index: v.get("index")?.as_u64()? as usize,
21354 id: v.get("id").and_then(|i| i.as_str()).map(str::to_string),
21355 }),
21356 "tool_delta" => Some(car_inference::StreamEvent::ToolCallDelta {
21357 index: v.get("index")?.as_u64()? as usize,
21358 arguments_delta: v.get("data")?.as_str()?.to_string(),
21359 }),
21360 "usage" => Some(car_inference::StreamEvent::Usage {
21361 input_tokens: v.get("input_tokens")?.as_u64()?,
21362 output_tokens: v.get("output_tokens")?.as_u64()?,
21363 cache_read_input_tokens: v
21364 .get("cache_read_input_tokens")
21365 .and_then(|x| x.as_u64())
21366 .unwrap_or(0),
21367 cache_creation_input_tokens: v
21368 .get("cache_creation_input_tokens")
21369 .and_then(|x| x.as_u64())
21370 .unwrap_or(0),
21371 }),
21372 "provider_output_item" => Some(car_inference::StreamEvent::ProviderOutputItem(
21373 v.get("item")?.clone(),
21374 )),
21375 "error" => Some(car_inference::StreamEvent::Error(
21376 v.get("message")?.as_str()?.to_string(),
21377 )),
21378 "done" => Some(car_inference::StreamEvent::Done {
21379 text: v.get("text")?.as_str()?.to_string(),
21380 tool_calls: v
21381 .get("tool_calls")
21382 .and_then(|tc| serde_json::from_value(tc.clone()).ok())
21383 .unwrap_or_default(),
21384 }),
21385 _ => None,
21386 }
21387}
21388
21389#[derive(Deserialize)]
21390struct EnrollSpeakerParams {
21391 label: String,
21392 audio: Value,
21393}
21394
21395async fn handle_enroll_speaker(req: &JsonRpcMessage) -> Result<Value, String> {
21396 let params: EnrollSpeakerParams =
21397 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21398 let audio_json = serde_json::to_string(¶ms.audio).map_err(|e| e.to_string())?;
21399 let json = car_ffi_common::voice::enroll_speaker(¶ms.label, &audio_json).await?;
21400 serde_json::from_str(&json).map_err(|e| e.to_string())
21401}
21402
21403#[derive(Deserialize)]
21404struct RemoveEnrollmentParams {
21405 label: String,
21406}
21407
21408fn handle_remove_enrollment(req: &JsonRpcMessage) -> Result<Value, String> {
21409 let params: RemoveEnrollmentParams =
21410 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21411 let json = car_ffi_common::voice::remove_enrollment(¶ms.label)?;
21412 serde_json::from_str(&json).map_err(|e| e.to_string())
21413}
21414
21415#[derive(Deserialize)]
21416struct WorkflowRunParams {
21417 workflow: Value,
21418 #[serde(default)]
21422 initial_state: Option<std::collections::HashMap<String, Value>>,
21423}
21424
21425async fn handle_workflow_run(
21426 req: &JsonRpcMessage,
21427 session: &Arc<crate::session::ClientSession>,
21428) -> Result<Value, String> {
21429 let params: WorkflowRunParams =
21430 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21431 let workflow_json = serde_json::to_string(¶ms.workflow).map_err(|e| e.to_string())?;
21432 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21433 channel: session.channel.clone(),
21434 host: session.host.clone(),
21435 client_id: session.client_id.clone(),
21436 });
21437 let json = car_ffi_common::workflow::run_workflow(&workflow_json, params.initial_state, runner)
21438 .await?;
21439 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21440 persist_if_paused(&result)?;
21443 Ok(result)
21444}
21445
21446#[derive(Deserialize)]
21447struct WorkflowChainParams {
21448 workflows: Vec<Value>,
21452 #[serde(default)]
21456 initial_state: Option<std::collections::HashMap<String, Value>>,
21457}
21458
21459async fn handle_workflow_chain(
21471 req: &JsonRpcMessage,
21472 session: &Arc<crate::session::ClientSession>,
21473) -> Result<Value, String> {
21474 let params: WorkflowChainParams =
21475 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21476 let workflows_json = serde_json::to_string(¶ms.workflows).map_err(|e| e.to_string())?;
21477 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21478 channel: session.channel.clone(),
21479 host: session.host.clone(),
21480 client_id: session.client_id.clone(),
21481 });
21482 let json =
21483 car_ffi_common::workflow::chain_workflows(&workflows_json, params.initial_state, runner)
21484 .await?;
21485 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21486 if let Some(last) = result
21489 .get("results")
21490 .and_then(|r| r.as_array())
21491 .and_then(|r| r.last())
21492 {
21493 persist_if_paused(last)?;
21494 }
21495 Ok(result)
21496}
21497
21498pub fn recover_workflow_checkpoints() {
21503 let dir = match workflow_runs_dir() {
21504 Ok(d) => d,
21505 Err(e) => {
21506 tracing::debug!(error = %e, "no workflow checkpoint dir; skipping recovery");
21507 return;
21508 }
21509 };
21510 match car_workflow::CheckpointStore::open(&dir).and_then(|s| s.recover_orphaned()) {
21511 Ok(0) => {}
21512 Ok(n) => tracing::info!(rearmed = n, "recovered orphaned workflow checkpoints"),
21513 Err(e) => tracing::warn!(error = %e, "workflow checkpoint recovery failed"),
21514 }
21515}
21516
21517fn workflow_runs_dir() -> Result<std::path::PathBuf, String> {
21520 let root = car_home::root()
21521 .ok_or_else(|| "cannot resolve home directory for workflow checkpoints".to_string())?;
21522 Ok(root.join("workflow-runs"))
21523}
21524
21525fn persist_if_paused(result: &Value) -> Result<(), String> {
21527 if result.get("status").and_then(|s| s.as_str()) != Some("paused") {
21528 return Ok(());
21529 }
21530 let Some(paused_value) = result.get("paused") else {
21531 return Err("paused result missing checkpoint".to_string());
21532 };
21533 let paused: car_workflow::PausedWorkflow =
21534 serde_json::from_value(paused_value.clone()).map_err(|e| e.to_string())?;
21535 let store =
21536 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21537 store.save(&paused).map_err(|e| e.to_string())
21538}
21539
21540#[derive(Deserialize)]
21541struct WorkflowResumeParams {
21542 run_id: String,
21543 #[serde(default)]
21544 input: Value,
21545}
21546
21547async fn handle_workflow_list_paused() -> Result<Value, String> {
21554 let store =
21555 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21556 let summaries = store.list_summaries().map_err(|e| e.to_string())?;
21557 serde_json::to_value(&summaries).map_err(|e| format!("serialize list_paused: {e}"))
21558}
21559
21560async fn handle_workflow_resume(
21561 req: &JsonRpcMessage,
21562 session: &Arc<crate::session::ClientSession>,
21563) -> Result<Value, String> {
21564 let params: WorkflowResumeParams =
21565 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21566
21567 let store =
21568 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21569 let paused = store
21572 .claim(¶ms.run_id)
21573 .map_err(|e| e.to_string())?
21574 .ok_or_else(|| {
21575 format!(
21576 "no paused workflow run '{}' (already resumed or unknown)",
21577 params.run_id
21578 )
21579 })?;
21580 let paused_json = serde_json::to_string(&paused).map_err(|e| e.to_string())?;
21581 let input_json = if params.input.is_null() {
21582 "{}".to_string()
21583 } else {
21584 serde_json::to_string(¶ms.input).map_err(|e| e.to_string())?
21585 };
21586
21587 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21588 channel: session.channel.clone(),
21589 host: session.host.clone(),
21590 client_id: session.client_id.clone(),
21591 });
21592
21593 let json = car_ffi_common::workflow::resume_workflow(&paused_json, &input_json, runner).await;
21594 let result = match json {
21595 Ok(j) => serde_json::from_str::<Value>(&j).map_err(|e| e.to_string())?,
21596 Err(e) => {
21597 let _ = store.save(&paused);
21600 let _ = store.complete(¶ms.run_id);
21601 return Err(e);
21602 }
21603 };
21604 persist_if_paused(&result)?;
21607 store.complete(¶ms.run_id).map_err(|e| e.to_string())?;
21608 Ok(result)
21609}
21610
21611#[derive(Deserialize)]
21612struct BuilderBuildParams {
21613 goal: String,
21614 #[serde(default)]
21615 existing: Value,
21616 #[serde(default = "default_builder_attempts")]
21617 max_attempts: u32,
21618}
21619
21620fn default_builder_attempts() -> u32 {
21621 3
21622}
21623
21624async fn handle_builder_build(
21629 req: &JsonRpcMessage,
21630 state: &Arc<ServerState>,
21631 session: &Arc<crate::session::ClientSession>,
21632) -> Result<Value, String> {
21633 let params: BuilderBuildParams =
21634 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21635 if params.goal.trim().is_empty() {
21636 return Err("missing 'goal'".to_string());
21637 }
21638
21639 let engine = get_inference_engine(state).clone();
21640
21641 let tools: Vec<car_builder::ToolInfo> = session
21642 .runtime
21643 .registry
21644 .schemas()
21645 .await
21646 .into_iter()
21647 .map(|s| car_builder::ToolInfo {
21648 name: s.name,
21649 description: s.description,
21650 })
21651 .collect();
21652 let models: Vec<String> = engine
21653 .list_models_unified()
21654 .into_iter()
21655 .map(|m| m.id)
21656 .collect();
21657 let catalog = car_builder::ToolCatalog {
21658 tools,
21659 models,
21660 agents: Vec::new(),
21661 };
21662
21663 let existing = if params.existing.is_null() {
21664 None
21665 } else {
21666 serde_json::from_value::<car_workflow::Workflow>(params.existing.clone()).ok()
21667 };
21668
21669 let build_req = car_builder::BuildRequest {
21670 goal: params.goal,
21671 catalog,
21672 existing,
21673 feedback: None,
21674 max_attempts: params.max_attempts,
21675 };
21676
21677 let result = car_builder::build_workflow(
21678 |prompt: String| {
21679 let engine = engine.clone();
21680 async move {
21681 let greq = car_inference::GenerateRequest {
21682 prompt,
21683 model: None,
21684 expected_row_digest: None,
21685 expected_catalog_revision: None,
21686 params: car_inference::GenerateParams {
21687 temperature: 0.2,
21688 max_tokens: 4096,
21689 ..Default::default()
21690 },
21691 context: None,
21692 context_stable_prefix: None,
21693 tools: None,
21694 images: None,
21695 messages: None,
21696 cache_control: false,
21697 response_format: None,
21698 intent: None,
21699 client_ref: None,
21700 caller: None,
21701 };
21702 engine
21703 .generate_tracked(greq)
21704 .await
21705 .map(|r| r.text)
21706 .map_err(|e| e.to_string())
21707 }
21708 },
21709 &build_req,
21710 )
21711 .await;
21712
21713 Ok(serde_json::json!({
21714 "valid": result.valid,
21715 "workflow": result.workflow,
21716 "issues": result.issues,
21717 "warnings": result.warnings,
21718 "attempts": result.attempts,
21719 }))
21720}
21721
21722#[derive(Deserialize)]
21723struct WorkflowVerifyParams {
21724 workflow: Value,
21725}
21726
21727fn handle_workflow_verify(req: &JsonRpcMessage) -> Result<Value, String> {
21728 let params: WorkflowVerifyParams =
21729 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21730 let workflow_json = serde_json::to_string(¶ms.workflow).map_err(|e| e.to_string())?;
21731 let json = car_ffi_common::workflow::verify_workflow(&workflow_json)?;
21732 serde_json::from_str(&json).map_err(|e| e.to_string())
21733}
21734
21735#[derive(Deserialize)]
21736struct WorkflowBuildAutomationParams {
21737 spec: Value,
21738}
21739
21740fn handle_workflow_build_automation(req: &JsonRpcMessage) -> Result<Value, String> {
21744 let params: WorkflowBuildAutomationParams =
21745 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21746 let spec_json = serde_json::to_string(¶ms.spec).map_err(|e| e.to_string())?;
21747 let json = car_ffi_common::workflow::build_automation_workflow(&spec_json)?;
21748 serde_json::from_str(&json).map_err(|e| e.to_string())
21749}
21750
21751async fn handle_meeting_start(
21756 req: &JsonRpcMessage,
21757 state: &Arc<ServerState>,
21758 session: &Arc<crate::session::ClientSession>,
21759) -> Result<Value, String> {
21760 let mut req_value = req.params.clone();
21766 let meeting_id = req_value
21767 .get("id")
21768 .and_then(|v| v.as_str())
21769 .map(str::to_string)
21770 .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
21771 if let Some(map) = req_value.as_object_mut() {
21772 map.insert("id".into(), Value::String(meeting_id.clone()));
21773 }
21774 let request_json = serde_json::to_string(&req_value).map_err(|e| e.to_string())?;
21775
21776 let ws_upstream: Arc<dyn car_voice::VoiceEventSink> =
21777 Arc::new(crate::session::WsVoiceEventSink {
21778 channel: session.channel.clone(),
21779 });
21780
21781 let upstream: Arc<dyn car_voice::VoiceEventSink> =
21786 Arc::new(crate::session::WsMemgineIngestSink {
21787 meeting_id,
21788 engine: session.memgine.clone(),
21789 upstream: ws_upstream,
21790 });
21791
21792 let cwd = std::env::current_dir().ok();
21793 let json = crate::meeting::start_meeting(
21794 &request_json,
21795 state.meetings.clone(),
21796 state.voice_sessions.clone(),
21797 upstream,
21798 None,
21799 cwd,
21800 )
21801 .await?;
21802 serde_json::from_str(&json).map_err(|e| e.to_string())
21803}
21804
21805#[derive(Deserialize)]
21806struct MeetingStopParams {
21807 meeting_id: String,
21808 #[serde(default = "default_summarize")]
21809 summarize: bool,
21810}
21811
21812fn default_summarize() -> bool {
21813 true
21814}
21815
21816async fn handle_meeting_stop(
21817 req: &JsonRpcMessage,
21818 state: &Arc<ServerState>,
21819 _session: &Arc<crate::session::ClientSession>,
21820) -> Result<Value, String> {
21821 let params: MeetingStopParams =
21822 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21823 let inference = if params.summarize {
21824 Some(state.inference.get().cloned()).flatten()
21825 } else {
21826 None
21827 };
21828 let json = crate::meeting::stop_meeting(
21829 ¶ms.meeting_id,
21830 params.summarize,
21831 state.meetings.clone(),
21832 state.voice_sessions.clone(),
21833 inference,
21834 )
21835 .await?;
21836 serde_json::from_str(&json).map_err(|e| e.to_string())
21837}
21838
21839#[derive(Deserialize, Default)]
21840struct MeetingListParams {
21841 #[serde(default)]
21842 root: Option<std::path::PathBuf>,
21843}
21844
21845fn handle_meeting_list(req: &JsonRpcMessage) -> Result<Value, String> {
21846 let params: MeetingListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21847 let cwd = std::env::current_dir().ok();
21848 let json = crate::meeting::list_meetings(params.root, cwd)?;
21849 serde_json::from_str(&json).map_err(|e| e.to_string())
21850}
21851
21852#[derive(Deserialize)]
21853struct MeetingGetParams {
21854 meeting_id: String,
21855 #[serde(default)]
21856 root: Option<std::path::PathBuf>,
21857}
21858
21859fn handle_meeting_get(req: &JsonRpcMessage) -> Result<Value, String> {
21860 let params: MeetingGetParams =
21861 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21862 let cwd = std::env::current_dir().ok();
21863 let json = crate::meeting::get_meeting(¶ms.meeting_id, params.root, cwd)?;
21864 serde_json::from_str(&json).map_err(|e| e.to_string())
21865}
21866
21867#[derive(Deserialize, Default)]
21872struct RegistryRegisterParams {
21873 entry: Value,
21877 #[serde(default)]
21878 registry_path: Option<std::path::PathBuf>,
21879}
21880
21881fn handle_registry_register(req: &JsonRpcMessage) -> Result<Value, String> {
21882 let params: RegistryRegisterParams =
21883 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21884 let entry_json = serde_json::to_string(¶ms.entry).map_err(|e| e.to_string())?;
21885 car_ffi_common::registry::register_agent(&entry_json, params.registry_path)?;
21886 Ok(Value::Null)
21887}
21888
21889#[derive(Deserialize, Default)]
21890struct RegistryNameParams {
21891 name: String,
21892 #[serde(default)]
21893 registry_path: Option<std::path::PathBuf>,
21894}
21895
21896fn handle_registry_heartbeat(req: &JsonRpcMessage) -> Result<Value, String> {
21897 let params: RegistryNameParams =
21898 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21899 let json = car_ffi_common::registry::agent_heartbeat(¶ms.name, params.registry_path)?;
21900 serde_json::from_str(&json).map_err(|e| e.to_string())
21901}
21902
21903fn handle_registry_unregister(req: &JsonRpcMessage) -> Result<Value, String> {
21904 let params: RegistryNameParams =
21905 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21906 car_ffi_common::registry::unregister_agent(¶ms.name, params.registry_path)?;
21907 Ok(Value::Null)
21908}
21909
21910#[derive(Deserialize, Default)]
21911struct RegistryListParams {
21912 #[serde(default)]
21913 registry_path: Option<std::path::PathBuf>,
21914}
21915
21916fn handle_registry_list(req: &JsonRpcMessage) -> Result<Value, String> {
21917 let params: RegistryListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21918 let json = car_ffi_common::registry::list_agents(params.registry_path)?;
21919 serde_json::from_str(&json).map_err(|e| e.to_string())
21920}
21921
21922#[derive(Deserialize, Default)]
21923struct RegistryReapParams {
21924 #[serde(default = "default_reap_age")]
21927 max_age_secs: u64,
21928 #[serde(default)]
21929 registry_path: Option<std::path::PathBuf>,
21930}
21931
21932fn default_reap_age() -> u64 {
21933 60
21934}
21935
21936fn handle_registry_reap(req: &JsonRpcMessage) -> Result<Value, String> {
21937 let params: RegistryReapParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21938 let json =
21939 car_ffi_common::registry::reap_stale_agents(params.max_age_secs, params.registry_path)?;
21940 serde_json::from_str(&json).map_err(|e| e.to_string())
21941}
21942
21943async fn handle_a2a_start(
21950 req: &JsonRpcMessage,
21951 state: &Arc<ServerState>,
21952 session: &crate::session::ClientSession,
21953) -> Result<Value, String> {
21954 let params_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21955 let responder: Arc<dyn car_a2a::ChatResponder> = Arc::new(WsChatResponder {
21967 state: Arc::downgrade(state),
21968 host_client_id: session.client_id.clone(),
21969 });
21970 let json =
21971 crate::a2a::start_a2a(¶ms_json, Some(session.runtime.clone()), Some(responder)).await?;
21972 serde_json::from_str(&json).map_err(|e| e.to_string())
21973}
21974
21975fn handle_a2a_stop() -> Result<Value, String> {
21976 let json = crate::a2a::stop_a2a()?;
21977 serde_json::from_str(&json).map_err(|e| e.to_string())
21978}
21979
21980fn handle_a2a_status() -> Result<Value, String> {
21981 let json = crate::a2a::a2a_status()?;
21982 serde_json::from_str(&json).map_err(|e| e.to_string())
21983}
21984
21985#[derive(serde::Deserialize)]
21989struct A2aPeerAddParams {
21990 url: String,
21991 #[serde(default)]
21992 label: Option<String>,
21993 #[serde(default)]
21995 allow_untrusted: bool,
21996}
21997
21998fn handle_a2a_peers_add(req: &JsonRpcMessage) -> Result<Value, String> {
21999 let params: A2aPeerAddParams =
22000 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
22001 if !params.allow_untrusted && !is_loopback_http_endpoint(¶ms.url) {
22006 return Err(
22007 "a2a.peers.add endpoint must be loopback unless allowUntrusted is true".to_string(),
22008 );
22009 }
22010 let reg = car_a2a::peers::PeerRegistry::user_default()?;
22011 let entry = reg.add(¶ms.url, params.label)?;
22012 serde_json::to_value(entry).map_err(|e| e.to_string())
22013}
22014
22015fn handle_a2a_peers_list() -> Result<Value, String> {
22016 let reg = car_a2a::peers::PeerRegistry::user_default()?;
22017 Ok(serde_json::json!({ "peers": reg.list() }))
22018}
22019
22020#[derive(serde::Deserialize)]
22021struct A2aPeerRemoveParams {
22022 slug: String,
22023}
22024
22025fn handle_a2a_peers_remove(req: &JsonRpcMessage) -> Result<Value, String> {
22026 let params: A2aPeerRemoveParams =
22027 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
22028 let reg = car_a2a::peers::PeerRegistry::user_default()?;
22029 Ok(serde_json::json!({ "removed": reg.remove(¶ms.slug)? }))
22030}
22031
22032#[derive(Deserialize)]
22033#[serde(rename_all = "camelCase")]
22034struct A2aSendParams {
22035 endpoint: String,
22036 message: car_a2a::Message,
22037 #[serde(default)]
22038 blocking: bool,
22039 #[serde(default = "default_true")]
22040 ingest_a2ui: bool,
22041 #[serde(default)]
22042 route_auth: Option<A2aRouteAuth>,
22043 #[serde(default)]
22044 allow_untrusted_endpoint: bool,
22045}
22046
22047fn default_true() -> bool {
22048 true
22049}
22050
22051async fn handle_a2a_dispatch(
22061 method: &str,
22062 req: &JsonRpcMessage,
22063 state: &Arc<ServerState>,
22064) -> Result<Value, String> {
22065 let dispatcher = state.a2a_dispatcher().await;
22066 dispatcher
22067 .dispatch(method, req.params.clone())
22068 .await
22069 .map_err(|e| e.to_string())
22070}
22071
22072async fn handle_a2a_send(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
22073 let params: A2aSendParams =
22074 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
22075 let endpoint = trusted_route_endpoint(
22076 Some(params.endpoint.clone()),
22077 params.allow_untrusted_endpoint,
22078 )
22079 .ok_or_else(|| {
22080 "`a2a.send` endpoint must be loopback unless allowUntrustedEndpoint is true".to_string()
22081 })?;
22082 let client = match params.route_auth.clone() {
22083 Some(auth) => {
22084 car_a2a::A2aClient::new(endpoint.clone()).with_auth(client_auth_from_route_auth(auth))
22085 }
22086 None => car_a2a::A2aClient::new(endpoint.clone()),
22087 };
22088 let result = client
22089 .send_message(params.message, params.blocking)
22090 .await
22091 .map_err(|e| e.to_string())?;
22092 let result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
22093 let mut applied = Vec::new();
22094 if params.ingest_a2ui {
22095 state
22096 .a2ui
22097 .validate_payload(&result_value)
22098 .map_err(|e| e.to_string())?;
22099 let routed_endpoint = Some(endpoint.clone());
22100 for envelope in car_a2ui::envelopes_from_value(&result_value).map_err(|e| e.to_string())? {
22101 let owner = car_a2ui::owner_from_value(&result_value).map(|owner| {
22102 if owner.endpoint.is_none() {
22103 owner.with_endpoint(routed_endpoint.clone())
22104 } else {
22105 owner
22106 }
22107 });
22108 applied.push(
22109 apply_a2ui_envelope(state, envelope, owner, params.route_auth.clone()).await?,
22110 );
22111 }
22112 }
22113 Ok(serde_json::json!({
22114 "result": result,
22115 "a2ui": {
22116 "applied": applied,
22117 }
22118 }))
22119}
22120
22121async fn handle_run_applescript(req: &JsonRpcMessage) -> Result<Value, String> {
22129 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22130 let json = car_ffi_common::automation::run_applescript(&args_json).await?;
22131 serde_json::from_str(&json).map_err(|e| e.to_string())
22132}
22133
22134async fn handle_run_powershell(req: &JsonRpcMessage) -> Result<Value, String> {
22135 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22136 let json = car_ffi_common::automation::run_powershell(&args_json).await?;
22137 serde_json::from_str(&json).map_err(|e| e.to_string())
22138}
22139
22140async fn handle_list_shortcuts(req: &JsonRpcMessage) -> Result<Value, String> {
22141 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22142 let json = car_ffi_common::automation::list_shortcuts(&args_json).await?;
22143 serde_json::from_str(&json).map_err(|e| e.to_string())
22144}
22145
22146async fn handle_run_shortcut(req: &JsonRpcMessage) -> Result<Value, String> {
22147 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22148 let json = car_ffi_common::automation::run_shortcut(&args_json).await?;
22149 serde_json::from_str(&json).map_err(|e| e.to_string())
22150}
22151
22152async fn handle_local_notification(req: &JsonRpcMessage) -> Result<Value, String> {
22153 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22154 let json = car_ffi_common::notifications::local(&args_json).await?;
22155 serde_json::from_str(&json).map_err(|e| e.to_string())
22156}
22157
22158async fn handle_vision_ocr(req: &JsonRpcMessage) -> Result<Value, String> {
22159 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22160 let json = car_ffi_common::vision::ocr(&args_json).await?;
22161 serde_json::from_str(&json).map_err(|e| e.to_string())
22162}
22163
22164fn agent_to_wire(agent: &car_registry::supervisor::ManagedAgent) -> Result<Value, String> {
22188 serde_json::to_value(crate::wire_schema::ManagedAgentWire::from_managed(agent))
22189 .map_err(|e| e.to_string())
22190}
22191
22192async fn attached_agent_chat_tools(
22197 state: &Arc<ServerState>,
22198 agent_id: &str,
22199) -> Option<Vec<String>> {
22200 let client_id = state.attached_agents.lock().await.get(agent_id)?.clone();
22201 let channel = {
22202 let sessions = state.sessions.lock().await;
22203 sessions.get(&client_id)?.channel.clone()
22204 };
22205 query_agent_chat_tools(channel).await
22206}
22207
22208async fn query_agent_chat_tools(channel: Arc<crate::session::WsChannel>) -> Option<Vec<String>> {
22209 use futures::SinkExt;
22210 use tokio::sync::oneshot;
22211 use tokio_tungstenite::tungstenite::Message;
22212
22213 let request_id = channel.next_request_id();
22214 let (tx, rx) = oneshot::channel();
22215 channel.pending.lock().await.insert(request_id.clone(), tx);
22216
22217 let request = serde_json::json!({
22218 "jsonrpc": "2.0",
22219 "method": "agent.chat.tools",
22220 "params": {},
22221 "id": request_id,
22222 });
22223 let message = Message::Text(serde_json::to_string(&request).ok()?.into());
22224 if channel.write.lock().await.send(message).await.is_err() {
22225 channel.pending.lock().await.remove(&request_id);
22226 return None;
22227 }
22228
22229 let response = match tokio::time::timeout(std::time::Duration::from_secs(1), rx).await {
22230 Ok(Ok(response)) => response,
22231 _ => {
22232 channel.pending.lock().await.remove(&request_id);
22233 return None;
22234 }
22235 };
22236 if response.error.is_some() {
22237 return None;
22238 }
22239 let mut tools: Vec<String> = response
22240 .output?
22241 .get("tools")?
22242 .as_array()?
22243 .iter()
22244 .filter_map(Value::as_str)
22245 .map(str::to_string)
22246 .collect();
22247 tools.sort();
22248 tools.dedup();
22249 Some(tools)
22250}
22251
22252#[cfg(test)]
22253mod agent_chat_tools_surface {
22254 use super::query_agent_chat_tools;
22255 use crate::session::WsChannel;
22256 use car_proto::ToolExecuteResponse;
22257 use serde_json::{json, Value};
22258 use std::sync::Arc;
22259
22260 #[tokio::test]
22261 async fn reverse_query_returns_sorted_deduplicated_model_visible_tools() {
22262 let (channel, frames) = WsChannel::test_capture();
22263 let channel = Arc::new(channel);
22264 let responder = channel.clone();
22265 let response = tokio::spawn(async move {
22266 loop {
22267 let claimed = {
22268 let mut pending = responder.pending.lock().await;
22269 let key = pending.keys().next().cloned();
22270 key.and_then(|key| pending.remove(&key).map(|tx| (key, tx)))
22271 };
22272 if let Some((request_id, tx)) = claimed {
22273 let _ = tx.send(ToolExecuteResponse {
22274 action_id: request_id,
22275 output: Some(json!({
22276 "tools": ["mail_search", "calendar_events", "mail_search"]
22277 })),
22278 error: None,
22279 terminal: false,
22280 });
22281 return;
22282 }
22283 tokio::task::yield_now().await;
22284 }
22285 });
22286
22287 assert_eq!(
22288 query_agent_chat_tools(channel).await,
22289 Some(vec![
22290 "calendar_events".to_string(),
22291 "mail_search".to_string()
22292 ])
22293 );
22294 response.await.expect("responder task finishes");
22295
22296 let frames = frames.lock().expect("captured frames lock");
22297 let request: Value = serde_json::from_str(frames.first().expect("reverse query frame"))
22298 .expect("valid JSON-RPC request");
22299 assert_eq!(request["method"], "agent.chat.tools");
22300 assert_eq!(request["params"], json!({}));
22301 }
22302}
22303
22304async fn handle_agents_list(state: &Arc<ServerState>) -> Result<Value, String> {
22305 let (agents, manifest_path, log_dir) = match state.observer_manifest_path() {
22314 Some(p) => {
22315 let agents = car_registry::supervisor::Supervisor::list_from_manifest(p)
22316 .map_err(|e| e.to_string())?;
22317 let log_dir = p
22318 .parent()
22319 .map(|parent| parent.join("logs"))
22320 .unwrap_or_else(|| std::path::PathBuf::from("logs"));
22321 (agents, p.clone(), log_dir)
22322 }
22323 None => {
22324 let supervisor = state.supervisor()?;
22325 let agents = supervisor.list().await;
22326 (
22327 agents,
22328 supervisor.manifest_path().to_path_buf(),
22329 supervisor.log_dir().to_path_buf(),
22330 )
22331 }
22332 };
22333 let agents_dir = manifest_path
22334 .parent()
22335 .map(|parent| parent.join("agents"))
22336 .unwrap_or_else(|| std::path::PathBuf::from("agents"));
22337 let attached = state.attached_agents.lock().await.clone();
22344 let mut decorated: Vec<Value> = Vec::with_capacity(agents.len());
22349 for a in agents {
22350 let session_id = attached.get(&a.spec.id).cloned();
22351 let tools =
22352 if session_id.is_some() && crate::assistant::register::is_assistant_alias(&a.spec.id) {
22353 attached_agent_chat_tools(state, &a.spec.id).await
22354 } else {
22355 None
22356 };
22357 let row = crate::wire_schema::ManagedAgentListRow {
22358 agent: crate::wire_schema::ManagedAgentWire::from_managed(&a),
22359 attached: session_id.is_some(),
22360 tools,
22361 manifest_path: agents_dir
22362 .join(&a.spec.id)
22363 .join("manifest.toml")
22364 .to_string_lossy()
22365 .into_owned(),
22366 log_path: log_dir
22367 .join(format!("{}.stdout.log", a.spec.id))
22368 .to_string_lossy()
22369 .into_owned(),
22370 stderr_log_path: log_dir
22371 .join(format!("{}.stderr.log", a.spec.id))
22372 .to_string_lossy()
22373 .into_owned(),
22374 session_id,
22375 };
22376 decorated.push(serde_json::to_value(row).map_err(|e| e.to_string())?);
22377 }
22378 decorated.extend(crate::coder::rpc::declarative_agent_rows(state).await);
22382 Ok(Value::Array(decorated))
22383}
22384
22385async fn handle_agents_upsert(
22386 req: &JsonRpcMessage,
22387 state: &Arc<ServerState>,
22388 session: &crate::session::ClientSession,
22389) -> Result<Value, String> {
22390 require_host_lifecycle_authority(session, state).await?;
22391 let mut params = req.params.clone();
22392 if let Some(name) = params
22401 .get("interpreter")
22402 .and_then(|v| v.as_str())
22403 .map(str::to_string)
22404 {
22405 let resolved =
22406 car_registry::supervisor::resolve_interpreter(&name).map_err(|e| e.to_string())?;
22407 params["command"] = Value::String(resolved.to_string_lossy().into_owned());
22408 }
22409 let spec: car_registry::supervisor::AgentSpec =
22410 serde_json::from_value(params).map_err(|e| e.to_string())?;
22411 let supervisor = state.supervisor()?;
22412 let agent = supervisor.upsert(spec).await.map_err(|e| e.to_string())?;
22413 agent_to_wire(&agent)
22414}
22415
22416async fn handle_agents_install(
22430 req: &JsonRpcMessage,
22431 state: &Arc<ServerState>,
22432 session: &crate::session::ClientSession,
22433) -> Result<Value, String> {
22434 require_host_lifecycle_authority(session, state).await?;
22435 let manifest: car_registry::manifest::AgentManifest =
22436 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
22437 let host = car_registry::install::HostCapabilities::daemon_default(env!("CARGO_PKG_VERSION"));
22438 let supervisor = state.supervisor()?;
22439 let (report, managed) = supervisor
22440 .install_manifest(manifest, &host)
22441 .await
22442 .map_err(|e| e.to_string())?;
22443 Ok(serde_json::json!({
22444 "report": {
22445 "missingOptional": report
22446 .missing_optional
22447 .iter()
22448 .map(|(ns, feat)| serde_json::json!({ "namespace": ns, "feature": feat }))
22449 .collect::<Vec<_>>(),
22450 },
22451 "agent": managed,
22452 }))
22453}
22454
22455async fn handle_agents_health(state: &Arc<ServerState>) -> Result<Value, String> {
22456 let entries = match state.observer_manifest_path() {
22462 Some(p) => car_registry::supervisor::Supervisor::health_from_manifest(p)
22463 .map_err(|e| e.to_string())?,
22464 None => {
22465 let supervisor = state.supervisor()?;
22466 supervisor.health().await
22467 }
22468 };
22469 serde_json::to_value(entries).map_err(|e| e.to_string())
22470}
22471
22472fn extract_agent_id(req: &JsonRpcMessage) -> Result<String, String> {
22473 req.params
22474 .get("id")
22475 .and_then(Value::as_str)
22476 .map(str::to_string)
22477 .ok_or_else(|| "missing required `id` parameter".to_string())
22478}
22479
22480async fn handle_agents_remove(
22481 req: &JsonRpcMessage,
22482 state: &Arc<ServerState>,
22483 session: &crate::session::ClientSession,
22484) -> Result<Value, String> {
22485 require_host_lifecycle_authority(session, state).await?;
22486 let id = extract_agent_id(req)?;
22487 let supervisor = state.supervisor()?;
22488 let removed = supervisor.remove(&id).await.map_err(|e| e.to_string())?;
22489 Ok(serde_json::json!({ "removed": removed }))
22490}
22491
22492async fn handle_agents_start(
22493 req: &JsonRpcMessage,
22494 state: &Arc<ServerState>,
22495 session: &crate::session::ClientSession,
22496) -> Result<Value, String> {
22497 let id = extract_agent_id(req)?;
22498 require_own_agent_or_host(session, state, "agents.start", &id).await?;
22499 let supervisor = state.supervisor()?;
22500 let agent = supervisor.start(&id).await.map_err(|e| e.to_string())?;
22501 agent_to_wire(&agent)
22502}
22503
22504async fn handle_agents_stop(
22505 req: &JsonRpcMessage,
22506 state: &Arc<ServerState>,
22507 session: &crate::session::ClientSession,
22508) -> Result<Value, String> {
22509 let id = extract_agent_id(req)?;
22510 require_own_agent_or_host(session, state, "agents.stop", &id).await?;
22511 let signal: car_registry::supervisor::StopSignal = req
22512 .params
22513 .get("signal")
22514 .map(|v| serde_json::from_value(v.clone()))
22515 .transpose()
22516 .map_err(|e| e.to_string())?
22517 .unwrap_or_default();
22518 let supervisor = state.supervisor()?;
22519 let agent = supervisor
22520 .stop(&id, signal)
22521 .await
22522 .map_err(|e| e.to_string())?;
22523 agent_to_wire(&agent)
22524}
22525
22526async fn handle_agents_restart(
22527 req: &JsonRpcMessage,
22528 state: &Arc<ServerState>,
22529 session: &crate::session::ClientSession,
22530) -> Result<Value, String> {
22531 let id = extract_agent_id(req)?;
22532 require_own_agent_or_host(session, state, "agents.restart", &id).await?;
22533 let supervisor = state.supervisor()?;
22534 let agent = supervisor.restart(&id).await.map_err(|e| e.to_string())?;
22535 agent_to_wire(&agent)
22536}
22537
22538const MAX_AGENT_WAIT_SECS: f64 = 600.0;
22544
22545async fn handle_agents_wait(
22548 req: &JsonRpcMessage,
22549 state: &Arc<ServerState>,
22550 bound_agent: Option<&str>,
22551) -> Result<Value, String> {
22552 let id = extract_agent_id(req)?;
22553 require_own_agent(bound_agent, "agents.wait", &id)?;
22554 let targets: Vec<car_registry::supervisor::AgentStatus> = match req.params.get("targets") {
22555 Some(v) => serde_json::from_value(v.clone())
22556 .map_err(|e| format!("invalid 'targets' (expected status string array): {e}"))?,
22557 None => vec![car_registry::supervisor::AgentStatus::Running],
22558 };
22559 let targets = if targets.is_empty() {
22560 vec![car_registry::supervisor::AgentStatus::Running]
22561 } else {
22562 targets
22563 };
22564 let timeout = std::time::Duration::from_secs_f64(
22565 req.params
22566 .get("timeout_secs")
22567 .and_then(Value::as_f64)
22568 .unwrap_or(30.0)
22569 .clamp(0.0, MAX_AGENT_WAIT_SECS),
22574 );
22575 let poll = std::time::Duration::from_millis(
22576 req.params
22577 .get("poll_ms")
22578 .and_then(Value::as_u64)
22579 .unwrap_or(200)
22580 .max(10),
22581 );
22582 let supervisor = state.supervisor()?;
22583 let agent = supervisor
22584 .wait_for(&id, &targets, timeout, poll)
22585 .await
22586 .map_err(|e| e.to_string())?;
22587 agent_to_wire(&agent)
22588}
22589
22590async fn handle_agents_tail_log(
22591 req: &JsonRpcMessage,
22592 state: &Arc<ServerState>,
22593 bound_agent: Option<&str>,
22594) -> Result<Value, String> {
22595 let id = extract_agent_id(req)?;
22596 require_own_agent(bound_agent, "agents.tail_log", &id)?;
22597 let n = req
22601 .params
22602 .get("n")
22603 .or_else(|| req.params.get("lines"))
22604 .and_then(Value::as_u64)
22605 .unwrap_or(100) as usize;
22606 let offset = req
22607 .params
22608 .get("offset")
22609 .and_then(Value::as_u64)
22610 .unwrap_or(0) as usize;
22611 let stream = car_registry::supervisor::LogStream::from_wire(
22612 req.params.get("stream").and_then(Value::as_str),
22613 );
22614 let supervisor = state.supervisor()?;
22615 let tail = supervisor
22616 .read_log(&id, stream, n, offset)
22617 .await
22618 .map_err(|e| e.to_string())?;
22619 serde_json::to_value(tail).map_err(|e| e.to_string())
22620}
22621
22622async fn handle_agents_list_external(req: &JsonRpcMessage) -> Result<Value, String> {
22633 let include_health = req
22634 .params
22635 .get("include_health")
22636 .and_then(Value::as_bool)
22637 .unwrap_or(false);
22638 let json = car_ffi_common::external_agents::list(include_health).await?;
22639 serde_json::from_str(&json).map_err(|e| e.to_string())
22640}
22641
22642async fn handle_agents_detect_external(req: &JsonRpcMessage) -> Result<Value, String> {
22643 let include_health = req
22644 .params
22645 .get("include_health")
22646 .and_then(Value::as_bool)
22647 .unwrap_or(false);
22648 let json = car_ffi_common::external_agents::detect(include_health).await?;
22649 serde_json::from_str(&json).map_err(|e| e.to_string())
22650}
22651
22652#[derive(Debug, Deserialize)]
22653struct AssistantInvokeParams {
22654 capability: String,
22655 #[serde(default)]
22656 agent_hint: Option<String>,
22657 payload_json: String,
22658}
22659
22660const ASSISTANT_INVOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22661
22662async fn handle_assistants_invoke(
22666 req: &JsonRpcMessage,
22667 state: &Arc<ServerState>,
22668 host_session: &Arc<crate::session::ClientSession>,
22669) -> Result<Value, String> {
22670 let params: AssistantInvokeParams =
22671 serde_json::from_value(req.params.clone()).map_err(|e| {
22672 format!("assistants.invoke requires {{ capability, agent_hint?, payload_json }}: {e}")
22673 })?;
22674 let capability = params.capability.trim();
22675 if capability.is_empty() {
22676 return Err("assistants.invoke requires a non-empty `capability`".to_string());
22677 }
22678
22679 let registry = car_engine::AgentCapabilityRegistry::new();
22680 car_engine::register_builtins(®istry);
22681 let agent = registry
22682 .select(capability, params.agent_hint.as_deref())
22683 .ok_or_else(|| format!("no ready-to-use assistant supports capability `{capability}`"))?;
22684 let task = car_engine::format_capability_payload(capability, ¶ms.payload_json)
22685 .map_err(|e| e.to_string())?;
22686 let system_prompt = car_engine::agent_metadata(&agent)
22687 .map(|m| m.system_prompt.to_string())
22688 .unwrap_or_else(|| "You are a helpful assistant. Carry out the user's task.".into());
22689
22690 let run_id = uuid::Uuid::new_v4().to_string();
22691 let started_at = chrono::Utc::now();
22692 state
22693 .start_run(crate::session::RunMeta {
22694 run_id: run_id.clone(),
22695 agent_id: agent.clone(),
22696 client_id: host_session.client_id.clone(),
22697 active_client_id: host_session.client_id.clone(),
22698 resume_predecessor_client_id: None,
22699 resume_lease: None,
22700 intent: task.clone(),
22701 outcome_description: Some(format!("Run {capability} with a ready-to-use assistant")),
22702 started_at,
22703 termination: None,
22704 ended_at: None,
22705 turns: Vec::new(),
22706 start_committed: false,
22707 pending_terminal: None,
22708 cancellation_pending: None,
22709 cancellation_receipt: None,
22710 trace_corruption: None,
22711 durability_generation: 0,
22712 })
22713 .await?;
22714
22715 let req = car_inference::GenerateRequest {
22716 prompt: task.clone(),
22717 context: Some(system_prompt),
22718 intent: Some(car_inference::IntentHint {
22719 prefer_fast: true,
22720 ..Default::default()
22721 }),
22722 ..Default::default()
22723 };
22724 let engine = get_inference_engine(state);
22725 let generated: Result<String, String> = match assistant_model_setup_message(engine, &req).await
22726 {
22727 Some(message) => Err(message),
22728 None => match tokio::time::timeout(ASSISTANT_INVOKE_TIMEOUT, engine.generate(req)).await {
22729 Ok(Ok(result)) => Ok(result),
22730 Ok(Err(e)) => Err(format!("Assistant failed: {e}")),
22731 Err(_) => Err(format!(
22732 "Assistant timed out after {} seconds. Choose a faster model in Models, or try again after model setup finishes.",
22733 ASSISTANT_INVOKE_TIMEOUT.as_secs()
22734 )),
22735 },
22736 };
22737
22738 match generated {
22739 Ok(result) => {
22740 registry.note_used(capability, &agent);
22741 let _ = state
22742 .record_run_turns(
22743 &run_id,
22744 vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22745 index: 0,
22746 proposal_id: None,
22747 action_id: None,
22748 action_status: None,
22749 action_duration_ms: None,
22750 action_completed_at: None,
22751 depends_on: None,
22752 state_dependencies: None,
22753 prompt: Some(task),
22754 tool: Some(capability.to_string()),
22755 parameters: serde_json::from_str(¶ms.payload_json).unwrap_or_else(
22756 |_| serde_json::json!({ "payload_json": params.payload_json }),
22757 ),
22758 output: Some(serde_json::json!({ "text": result })),
22759 cli_outcome: None,
22760 verifier_verdict: car_proto::VerifierVerdict::NotRun,
22761 policy_rejected: None,
22762 })],
22763 )
22764 .await;
22765 state
22766 .complete_run(
22767 &run_id,
22768 car_proto::RunTermination::Outcome {
22769 status: car_ir::OutcomeStatus::Success,
22770 outcome: car_ir::AgentOutcome {
22771 status: car_ir::OutcomeStatus::Success,
22772 summary: "Assistant completed successfully.".to_string(),
22773 evidence: Vec::new(),
22774 metrics: Default::default(),
22775 timestamp: chrono::Utc::now(),
22776 },
22777 },
22778 )
22779 .await?;
22780 Ok(serde_json::json!({
22781 "agent": agent,
22782 "result": result,
22783 "run_id": run_id,
22784 }))
22785 }
22786 Err(message) => {
22787 let _ = state
22788 .record_run_turns(
22789 &run_id,
22790 vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22791 index: 0,
22792 proposal_id: None,
22793 action_id: None,
22794 action_status: None,
22795 action_duration_ms: None,
22796 action_completed_at: None,
22797 depends_on: None,
22798 state_dependencies: None,
22799 prompt: Some(task),
22800 tool: Some(capability.to_string()),
22801 parameters: serde_json::from_str(¶ms.payload_json).unwrap_or_else(
22802 |_| serde_json::json!({ "payload_json": params.payload_json }),
22803 ),
22804 output: Some(serde_json::json!({ "error": message.clone() })),
22805 cli_outcome: None,
22806 verifier_verdict: car_proto::VerifierVerdict::NotRun,
22807 policy_rejected: None,
22808 })],
22809 )
22810 .await;
22811 state
22812 .complete_run(
22813 &run_id,
22814 car_proto::RunTermination::Outcome {
22815 status: car_ir::OutcomeStatus::Failure,
22816 outcome: car_ir::AgentOutcome {
22817 status: car_ir::OutcomeStatus::Failure,
22818 summary: message.clone(),
22819 evidence: Vec::new(),
22820 metrics: Default::default(),
22821 timestamp: chrono::Utc::now(),
22822 },
22823 },
22824 )
22825 .await?;
22826 Err(message)
22827 }
22828 }
22829}
22830
22831async fn assistant_model_setup_message(
22832 engine: &car_inference::InferenceEngine,
22833 req: &car_inference::GenerateRequest,
22834) -> Option<String> {
22835 let decision = engine
22836 .route_adaptive_with_intent(&req.prompt, req.intent.clone())
22837 .await;
22838 match engine
22839 .unified_registry
22840 .ready_without_download(&decision.model_id)
22841 {
22842 Some(true) => None,
22843 Some(false) => Some(format!(
22844 "Assistant needs model setup before it can run. Open Models and install {}, then try again.",
22845 decision.model_name
22846 )),
22847 None => Some(format!(
22848 "Assistant selected {}, but that model is not registered. Open Models and choose an available model.",
22849 decision.model_name
22850 )),
22851 }
22852}
22853
22854async fn handle_agents_invoke_external(
22872 req: &JsonRpcMessage,
22873 state: &Arc<ServerState>,
22874 host_session: &Arc<crate::session::ClientSession>,
22875) -> Result<Value, String> {
22876 let id = req
22877 .params
22878 .get("id")
22879 .and_then(Value::as_str)
22880 .ok_or_else(|| "missing required `id` parameter".to_string())?
22881 .to_string();
22882 let task = req
22883 .params
22884 .get("task")
22885 .and_then(Value::as_str)
22886 .ok_or_else(|| "missing required `task` parameter".to_string())?
22887 .to_string();
22888 let stream = req
22889 .params
22890 .get("stream")
22891 .and_then(Value::as_bool)
22892 .unwrap_or(false);
22893 let session_id = req
22894 .params
22895 .get("session_id")
22896 .and_then(Value::as_str)
22897 .map(str::to_string)
22898 .unwrap_or_else(|| format!("ext-{}", uuid::Uuid::new_v4().simple()));
22899
22900 let mut options_value = req.params.clone();
22906 if let Some(obj) = options_value.as_object_mut() {
22907 obj.remove("id");
22908 obj.remove("task");
22909 obj.remove("stream");
22910 obj.remove("session_id");
22911 let has_explicit_mcp = obj.contains_key("mcp_endpoint");
22920 if !has_explicit_mcp {
22921 if let Some(url) = state.mcp_url.get() {
22922 obj.insert("mcp_endpoint".to_string(), Value::String(url.clone()));
22923 }
22924 }
22925 }
22926
22927 if !stream {
22928 let options_json = options_value.to_string();
22931 let json = car_ffi_common::external_agents::invoke(&id, &task, &options_json).await?;
22932 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
22933 append_external_agent_audit(&id, &task, &options_value, &result);
22934 return Ok(result);
22935 }
22936
22937 let opts: car_external_agents::InvokeOptions = serde_json::from_value(options_value.clone())
22943 .map_err(|e| format!("invalid options: {e}"))?;
22944
22945 let local_cancel = Arc::new(AtomicBool::new(false));
22952 let owns_chat_session = {
22953 let mut chats = state.chat_sessions.lock().await;
22963 if let Some(chat) = chats.get_mut(&session_id) {
22964 if chat.local_cancel.is_none() {
22965 chat.local_cancel = Some(local_cancel.clone());
22966 }
22967 false
22968 } else {
22969 let created_at = std::time::SystemTime::now()
22970 .duration_since(std::time::UNIX_EPOCH)
22971 .map(|d| d.as_secs())
22972 .unwrap_or(0);
22973 chats.insert(
22974 session_id.clone(),
22975 crate::session::ChatSession {
22976 agent_id: id.clone(),
22977 host_client_id: host_session.client_id.clone(),
22978 created_at,
22979 local_cancel: Some(local_cancel.clone()),
22980 },
22981 );
22982 true
22983 }
22984 };
22985
22986 use tokio::sync::mpsc;
22993 let (tx, mut rx) = mpsc::unbounded_channel::<car_external_agents::StreamEvent>();
22994
22995 let drain_state = state.clone();
22996 let drain_session_id = session_id.clone();
22997 let drain_agent_id = id.clone();
22998 tokio::spawn(async move {
22999 while let Some(event) = rx.recv().await {
23000 emit_external_chat_event(&drain_state, &drain_session_id, &drain_agent_id, event).await;
23001 }
23002 });
23003
23004 let emitter_tx = tx.clone();
23005 let emitter: car_external_agents::StreamEventEmitter = Arc::new(move |event| {
23006 let _ = emitter_tx.send(event);
23011 });
23012
23013 let spawn_state = state.clone();
23019 let spawn_session_id = session_id.clone();
23020 let spawn_id = id.clone();
23021 let spawn_task = task.clone();
23022 let spawn_options = options_value.clone();
23023 tokio::spawn(async move {
23024 let outcome = car_external_agents::invoke_with_emitter_and_cancel(
23025 &spawn_id,
23026 &spawn_task,
23027 opts,
23028 Some(emitter),
23029 Some(local_cancel.clone()),
23030 )
23031 .await;
23032 drop(tx); let terminal_params: Value;
23039 let result_value: Value;
23040 match outcome {
23041 Ok(res) => {
23042 let mut parts: Vec<String> = Vec::new();
23049 if res.turns > 0 {
23050 parts.push(format!(
23051 "{} turn{}",
23052 res.turns,
23053 if res.turns == 1 { "" } else { "s" }
23054 ));
23055 }
23056 if res.tool_calls > 0 {
23057 parts.push(format!(
23058 "{} tool{}",
23059 res.tool_calls,
23060 if res.tool_calls == 1 { "" } else { "s" }
23061 ));
23062 }
23063 if res.duration_ms > 0 {
23064 parts.push(format!("{:.1}s", res.duration_ms as f64 / 1000.0));
23065 }
23066 if res.dropped_attachments > 0 {
23067 parts.push(format!(
23068 "{} image{} skipped",
23069 res.dropped_attachments,
23070 if res.dropped_attachments == 1 {
23071 ""
23072 } else {
23073 "s"
23074 }
23075 ));
23076 }
23077 let summary = if parts.is_empty() {
23078 "stop".to_string()
23079 } else {
23080 parts.join(" · ")
23081 };
23082 if res.is_error {
23083 terminal_params = serde_json::json!({
23084 "session_id": spawn_session_id,
23085 "agent_id": spawn_id,
23086 "kind": "error",
23087 "error": res.error.clone().unwrap_or_else(|| "external agent reported error".to_string()),
23088 });
23089 } else {
23090 terminal_params = serde_json::json!({
23091 "session_id": spawn_session_id,
23092 "agent_id": spawn_id,
23093 "kind": "done",
23094 "finish_reason": summary,
23095 "dropped_attachments": res.dropped_attachments,
23098 });
23099 }
23100 result_value = serde_json::to_value(&res).unwrap_or(Value::Null);
23101 }
23102 Err(e) => {
23103 let message = format!("{e}");
23104 terminal_params = serde_json::json!({
23105 "session_id": spawn_session_id,
23106 "agent_id": spawn_id,
23107 "kind": "error",
23108 "error": message.clone(),
23109 });
23110 result_value = serde_json::json!({ "is_error": true, "error": message });
23111 }
23112 }
23113 send_external_chat_frame(&spawn_state, &spawn_session_id, terminal_params).await;
23114 remove_owned_external_chat_session(&spawn_state, &spawn_session_id, owns_chat_session)
23115 .await;
23116 append_external_agent_audit(&spawn_id, &spawn_task, &spawn_options, &result_value);
23117 });
23118
23119 Ok(serde_json::json!({
23120 "accepted": true,
23121 "session_id": session_id,
23122 }))
23123}
23124
23125async fn remove_owned_external_chat_session(
23126 state: &Arc<ServerState>,
23127 session_id: &str,
23128 owns_chat_session: bool,
23129) {
23130 if owns_chat_session {
23131 state.chat_sessions.lock().await.remove(session_id);
23132 }
23133}
23134
23135async fn emit_external_chat_event(
23152 state: &Arc<ServerState>,
23153 session_id: &str,
23154 agent_id: &str,
23155 event: car_external_agents::StreamEvent,
23156) {
23157 use car_external_agents::StreamEvent;
23158 match event {
23159 StreamEvent::Assistant(a) => {
23160 if let Some(content) = a.message.get("content").and_then(|v| v.as_array()) {
23161 for block in content {
23162 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
23163 match block_type {
23164 "text" => {
23165 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
23166 if !text.is_empty() {
23167 let params = serde_json::json!({
23168 "session_id": session_id,
23169 "agent_id": agent_id,
23170 "kind": "token",
23171 "delta": text,
23172 });
23173 send_external_chat_frame(state, session_id, params).await;
23174 }
23175 }
23176 }
23177 "tool_use" => {
23178 let name = block
23179 .get("name")
23180 .and_then(|v| v.as_str())
23181 .unwrap_or("(unknown tool)");
23182 let mut frame = serde_json::json!({
23188 "session_id": session_id,
23189 "agent_id": agent_id,
23190 "kind": "tool_call",
23191 "tool": name,
23192 "detail": name,
23193 });
23194 if let Some(input) = block.get("input") {
23195 if !input.is_null() {
23196 frame["params"] = input.clone();
23197 }
23198 }
23199 send_external_chat_frame(state, session_id, frame).await;
23200 }
23201 _ => {}
23202 }
23203 }
23204 }
23205 }
23206 _ => {
23207 }
23212 }
23213}
23214
23215async fn send_external_chat_frame(state: &Arc<ServerState>, session_id: &str, params: Value) {
23220 use futures::SinkExt;
23221 use tokio_tungstenite::tungstenite::Message;
23222
23223 update_chat_goal_from_event(state, session_id, ¶ms).await;
23224
23225 let host_client_id = state
23226 .chat_sessions
23227 .lock()
23228 .await
23229 .get(session_id)
23230 .map(|s| s.host_client_id.clone());
23231 let Some(host_client_id) = host_client_id else {
23232 return;
23233 };
23234 let host_channel = {
23235 let sessions = state.sessions.lock().await;
23236 sessions.get(&host_client_id).map(|s| s.channel.clone())
23237 };
23238 let Some(channel) = host_channel else {
23239 return;
23240 };
23241 let frame = serde_json::json!({
23242 "jsonrpc": "2.0",
23243 "method": "agents.chat.event",
23244 "params": params,
23245 });
23246 if let Ok(text) = serde_json::to_string(&frame) {
23247 let _ = channel
23248 .write
23249 .lock()
23250 .await
23251 .send(Message::Text(text.into()))
23252 .await;
23253 }
23254}
23255
23256fn append_external_agent_audit(id: &str, task: &str, options: &Value, result: &Value) {
23264 use std::io::Write;
23265 let car_dir = match car_home::root() {
23266 Some(root) => root,
23267 None => return,
23268 };
23269 if std::fs::create_dir_all(&car_dir).is_err() {
23270 return;
23271 }
23272 let path = car_dir.join("external-agents.jsonl");
23273 let record = serde_json::json!({
23274 "ts": chrono::Utc::now().to_rfc3339(),
23275 "adapter_id": id,
23276 "task": task,
23277 "options": options,
23278 "result": result,
23279 });
23280 let line = match serde_json::to_string(&record) {
23281 Ok(s) => s,
23282 Err(_) => return,
23283 };
23284 if let Ok(mut f) = std::fs::OpenOptions::new()
23285 .create(true)
23286 .append(true)
23287 .open(&path)
23288 {
23289 let _ = writeln!(f, "{}", line);
23290 } else {
23291 tracing::warn!(
23292 path = %path.display(),
23293 "failed to append external-agent audit record"
23294 );
23295 }
23296}
23297
23298async fn handle_agents_health_external(req: &JsonRpcMessage) -> Result<Value, String> {
23304 let force = req
23305 .params
23306 .get("force")
23307 .and_then(Value::as_bool)
23308 .unwrap_or(false);
23309 if let Some(id) = req.params.get("id").and_then(Value::as_str) {
23310 let json = car_ffi_common::external_agents::health_one(id, force).await?;
23311 serde_json::from_str(&json).map_err(|e| e.to_string())
23312 } else {
23313 let json = car_ffi_common::external_agents::health(force).await?;
23314 serde_json::from_str(&json).map_err(|e| e.to_string())
23315 }
23316}
23317
23318const AGENT_CHAT_ACK_TIMEOUT_SECS: u64 = 5;
23336
23337fn extract_chat_attachments(params: &Value) -> Result<Option<Value>, String> {
23347 const ALLOWED_MEDIA: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
23348 let items = match params.get("attachments") {
23349 None | Some(Value::Null) => return Ok(None),
23350 Some(Value::Array(items)) => items,
23351 Some(_) => return Err("`agents.chat` `attachments` must be an array".to_string()),
23352 };
23353 for item in items {
23358 let ty = item.get("type").and_then(Value::as_str).ok_or_else(|| {
23359 "`agents.chat` `attachments` entries must have a string `type`".to_string()
23360 })?;
23361 match ty {
23362 "image_base64" => {
23363 if item.get("data").and_then(Value::as_str).is_none() {
23364 return Err("`image_base64` attachment requires a string `data`".to_string());
23365 }
23366 let media = item.get("media_type").and_then(Value::as_str).unwrap_or("");
23367 if !ALLOWED_MEDIA.contains(&media) {
23368 return Err(format!(
23369 "`image_base64` attachment `media_type` must be one of {ALLOWED_MEDIA:?}"
23370 ));
23371 }
23372 }
23373 "image_url" => {
23374 let url = item.get("url").and_then(Value::as_str).unwrap_or("");
23375 if !(url.starts_with("https://") || url.starts_with("http://")) {
23376 return Err("`image_url` attachment `url` must be http(s)".to_string());
23377 }
23378 }
23379 other => {
23380 return Err(format!(
23381 "`agents.chat` `attachments` entries must be image ContentBlocks \
23382 (`image_base64` or `image_url`), got `{other}`"
23383 ));
23384 }
23385 }
23386 }
23387 Ok(Some(Value::Array(items.clone())))
23388}
23389
23390fn extract_chat_goal(params: &Value) -> Result<Option<Value>, String> {
23394 let Some(goal) = params.get("goal") else {
23395 return Ok(None);
23396 };
23397 if goal.is_null() {
23398 return Ok(None);
23399 }
23400 let obj = goal
23401 .as_object()
23402 .ok_or_else(|| "`agents.chat` `goal` must be an object".to_string())?;
23403 let check = obj
23404 .get("check")
23405 .and_then(Value::as_str)
23406 .map(str::trim)
23407 .filter(|s| !s.is_empty())
23408 .ok_or_else(|| "`agents.chat` `goal.check` must be a non-empty string".to_string())?;
23409 let max_iterations = obj
23410 .get("max_iterations")
23411 .and_then(Value::as_u64)
23412 .unwrap_or(8)
23413 .clamp(1, 50);
23414 Ok(Some(serde_json::json!({
23415 "check": check,
23416 "max_iterations": max_iterations,
23417 })))
23418}
23419
23420fn extract_chat_model(params: &Value) -> Result<Option<String>, String> {
23425 match params.get("model") {
23426 None | Some(Value::Null) => Ok(None),
23427 Some(Value::String(model)) => {
23428 Ok((!model.trim().is_empty()).then(|| model.trim().to_string()))
23429 }
23430 Some(_) => Err("`agents.chat` `model` must be a string".to_string()),
23431 }
23432}
23433
23434fn unix_secs_now() -> u64 {
23435 std::time::SystemTime::now()
23436 .duration_since(std::time::UNIX_EPOCH)
23437 .map(|d| d.as_secs())
23438 .unwrap_or(0)
23439}
23440
23441fn chat_goal_value_to_state(session_id: &str, goal: &Value) -> Result<ChatGoalState, String> {
23442 let obj = goal
23443 .as_object()
23444 .ok_or_else(|| "`goal` must be an object".to_string())?;
23445 let check = obj
23446 .get("check")
23447 .and_then(Value::as_str)
23448 .map(str::trim)
23449 .filter(|s| !s.is_empty())
23450 .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
23451 let max_iterations = obj
23452 .get("max_iterations")
23453 .and_then(Value::as_u64)
23454 .unwrap_or(8)
23455 .clamp(1, 50) as u32;
23456 Ok(ChatGoalState {
23457 session_id: session_id.to_string(),
23458 check: check.to_string(),
23459 max_iterations,
23460 status: "active".to_string(),
23461 last_iteration: None,
23462 last_met: None,
23463 last_grounded: None,
23464 last_reason: None,
23465 terminal_kind: None,
23466 terminal_message: None,
23467 updated_at: unix_secs_now(),
23468 })
23469}
23470
23471fn chat_goal_state_to_value(goal: &ChatGoalState) -> Result<Value, String> {
23472 serde_json::to_value(goal).map_err(|e| e.to_string())
23473}
23474
23475fn chat_goal_state_to_wire_goal(goal: &ChatGoalState) -> Value {
23476 serde_json::json!({
23477 "check": goal.check,
23478 "max_iterations": goal.max_iterations,
23479 })
23480}
23481
23482async fn ensure_chat_goal_running(
23483 state: &Arc<ServerState>,
23484 session_id: &str,
23485 goal_value: &Value,
23486) -> Result<(), String> {
23487 let mut inserted = false;
23488 {
23489 let mut goals = state.chat_goals.lock().await;
23490 if let Some(stored) = goals.get_mut(session_id) {
23491 stored.status = "running".to_string();
23492 stored.terminal_kind = None;
23493 stored.terminal_message = None;
23494 stored.updated_at = unix_secs_now();
23495 } else {
23496 let mut goal = chat_goal_value_to_state(session_id, goal_value)?;
23497 goal.status = "running".to_string();
23498 goals.insert(session_id.to_string(), goal);
23499 inserted = true;
23500 }
23501 }
23502 state.persist_chat_goals().await?;
23503 if inserted {
23504 tracing::debug!(
23505 session_id,
23506 "created durable chat goal status for inline goal"
23507 );
23508 }
23509 Ok(())
23510}
23511
23512async fn handle_goal_suggest(
23513 req: &JsonRpcMessage,
23514 state: &Arc<ServerState>,
23515) -> Result<Value, String> {
23516 let prompt = req
23517 .params
23518 .get("prompt")
23519 .or_else(|| req.params.get("objective"))
23520 .and_then(Value::as_str)
23521 .map(str::trim)
23522 .filter(|s| !s.is_empty())
23523 .ok_or_else(|| "`goal.suggest` requires `prompt` or `objective`".to_string())?;
23524 let cwd = req
23525 .params
23526 .get("working_dir")
23527 .or_else(|| req.params.get("cwd"))
23528 .and_then(Value::as_str)
23529 .map(std::path::PathBuf::from);
23530 let mut result = crate::goal_suggest::synthesize_goal_check(prompt, cwd.as_deref());
23531
23532 let should_set = req
23533 .params
23534 .get("set")
23535 .and_then(Value::as_bool)
23536 .unwrap_or(false);
23537 if should_set {
23538 let session_id = req
23539 .params
23540 .get("session_id")
23541 .and_then(Value::as_str)
23542 .map(str::trim)
23543 .filter(|s| !s.is_empty())
23544 .ok_or_else(|| "`goal.suggest` with `set: true` requires `session_id`".to_string())?;
23545 let goal_value = result.get("goal").cloned().unwrap_or(Value::Null);
23546 if goal_value.is_null() {
23547 return Err("`goal.suggest` could not infer a goal to set".to_string());
23548 }
23549 let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23550 state
23551 .chat_goals
23552 .lock()
23553 .await
23554 .insert(session_id.to_string(), goal.clone());
23555 state.persist_chat_goals().await?;
23556 if let Some(obj) = result.as_object_mut() {
23557 obj.insert("set".to_string(), Value::Bool(true));
23558 obj.insert(
23559 "session_id".to_string(),
23560 Value::String(session_id.to_string()),
23561 );
23562 obj.insert("stored_goal".to_string(), chat_goal_state_to_value(&goal)?);
23563 }
23564 }
23565
23566 Ok(result)
23567}
23568
23569async fn handle_goal_set(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
23570 let session_id = req
23571 .params
23572 .get("session_id")
23573 .and_then(Value::as_str)
23574 .map(str::trim)
23575 .filter(|s| !s.is_empty())
23576 .ok_or_else(|| "`goal.set` requires `session_id`".to_string())?;
23577 let goal_value = if let Some(goal) = req.params.get("goal") {
23578 goal.clone()
23579 } else {
23580 serde_json::json!({
23581 "check": req.params.get("check").cloned().unwrap_or(Value::Null),
23582 "max_iterations": req.params.get("max_iterations").cloned().unwrap_or(Value::Null),
23583 })
23584 };
23585 let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23586 state
23587 .chat_goals
23588 .lock()
23589 .await
23590 .insert(session_id.to_string(), goal.clone());
23591 state.persist_chat_goals().await?;
23592 Ok(serde_json::json!({
23593 "set": true,
23594 "goal": chat_goal_state_to_value(&goal)?,
23595 }))
23596}
23597
23598async fn handle_goal_status(
23599 req: &JsonRpcMessage,
23600 state: &Arc<ServerState>,
23601) -> Result<Value, String> {
23602 let goals = state.chat_goals.lock().await;
23603 if let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) {
23604 let goal = goals.get(session_id).cloned();
23605 return Ok(serde_json::json!({
23606 "session_id": session_id,
23607 "goal": goal
23608 .as_ref()
23609 .map(chat_goal_state_to_value)
23610 .transpose()?
23611 .unwrap_or(Value::Null),
23612 }));
23613 }
23614 let mut values = Vec::with_capacity(goals.len());
23615 for goal in goals.values() {
23616 values.push(chat_goal_state_to_value(goal)?);
23617 }
23618 Ok(serde_json::json!({ "goals": values }))
23619}
23620
23621async fn handle_goal_clear(
23622 req: &JsonRpcMessage,
23623 state: &Arc<ServerState>,
23624) -> Result<Value, String> {
23625 let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) else {
23626 let mut goals = state.chat_goals.lock().await;
23627 let removed = goals.len();
23628 goals.clear();
23629 drop(goals);
23630 state.persist_chat_goals().await?;
23631 return Ok(serde_json::json!({ "cleared": removed }));
23632 };
23633 let removed = state.chat_goals.lock().await.remove(session_id).is_some();
23634 state.persist_chat_goals().await?;
23635 Ok(serde_json::json!({
23636 "cleared": removed,
23637 "session_id": session_id,
23638 }))
23639}
23640
23641fn build_agent_chat_params(
23646 session_id: &str,
23647 prompt: &str,
23648 stream: bool,
23649 host_client_id: &str,
23650 voice_input: bool,
23651 model: Option<String>,
23652 attachments: Option<Value>,
23653 goal: Option<Value>,
23654) -> Value {
23655 let mut params = serde_json::json!({
23656 "session_id": session_id,
23657 "prompt": prompt,
23658 "stream": stream,
23659 "context": {
23660 "host_client_id": host_client_id,
23661 "voice_input": voice_input,
23662 },
23663 });
23664 if let Some(model) = model {
23665 if let Some(obj) = params.as_object_mut() {
23666 obj.insert("model".to_string(), Value::String(model));
23667 }
23668 }
23669 if let Some(att) = attachments {
23670 if let Some(obj) = params.as_object_mut() {
23671 obj.insert("attachments".to_string(), att);
23672 }
23673 }
23674 if let Some(goal) = goal {
23675 if let Some(obj) = params.as_object_mut() {
23676 obj.insert("goal".to_string(), goal);
23677 }
23678 }
23679 params
23680}
23681
23682async fn resolve_assistant_agent_alias(agent_id: String, state: &Arc<ServerState>) -> String {
23690 if !crate::assistant::register::is_assistant_alias(&agent_id) {
23691 return agent_id;
23692 }
23693 let attached = state.attached_agents.lock().await;
23694 if attached.contains_key(&agent_id) {
23695 return agent_id;
23696 }
23697 for alias in [
23698 crate::assistant::register::ASSISTANT_AGENT_ID,
23699 crate::assistant::register::LEGACY_ASSISTANT_AGENT_ID,
23700 ] {
23701 if alias != agent_id && attached.contains_key(alias) {
23702 return alias.to_string();
23703 }
23704 }
23705 agent_id
23706}
23707
23708async fn handle_agents_chat(
23709 req: &JsonRpcMessage,
23710 state: &Arc<ServerState>,
23711 host_session: &Arc<crate::session::ClientSession>,
23712) -> Result<Value, String> {
23713 use futures::SinkExt;
23714 use tokio::sync::oneshot;
23715 use tokio_tungstenite::tungstenite::Message;
23716
23717 let agent_id = req
23718 .params
23719 .get("agent_id")
23720 .and_then(Value::as_str)
23721 .ok_or_else(|| "`agents.chat` requires `agent_id`".to_string())?
23722 .to_string();
23723 let agent_id = resolve_assistant_agent_alias(agent_id, state).await;
23729 let prompt = req
23730 .params
23731 .get("prompt")
23732 .and_then(Value::as_str)
23733 .ok_or_else(|| "`agents.chat` requires `prompt`".to_string())?
23734 .to_string();
23735 let session_id = req
23736 .params
23737 .get("session_id")
23738 .and_then(Value::as_str)
23739 .map(str::to_string)
23740 .unwrap_or_else(|| format!("chat-{}", uuid::Uuid::new_v4().simple()));
23741 let stream = req
23742 .params
23743 .get("stream")
23744 .and_then(Value::as_bool)
23745 .unwrap_or(true);
23746 let voice_input = req
23747 .params
23748 .get("voice_input")
23749 .and_then(Value::as_bool)
23750 .unwrap_or(false);
23751 let model = extract_chat_model(&req.params)?;
23752
23753 let attachments = extract_chat_attachments(&req.params)?;
23758 let inline_goal = extract_chat_goal(&req.params)?;
23759 let stored_goal = if inline_goal.is_none() {
23760 state
23761 .chat_goals
23762 .lock()
23763 .await
23764 .get(&session_id)
23765 .map(chat_goal_state_to_wire_goal)
23766 } else {
23767 None
23768 };
23769 let goal = inline_goal.or(stored_goal);
23770 if goal.is_some() && attachments.is_some() {
23771 return Err(
23772 "`agents.chat` `goal` currently cannot be combined with `attachments`".to_string(),
23773 );
23774 }
23775 let has_attached_agent = state.attached_agents.lock().await.contains_key(&agent_id);
23776 let declarative_spec = if has_attached_agent {
23777 None
23778 } else {
23779 state.declagents().ok().and_then(|reg| reg.get(&agent_id))
23780 };
23781
23782 if !has_attached_agent && declarative_spec.is_none() {
23798 return Err(format!(
23799 "`{agent_id}` is not attached to this daemon and is not a declarative agent"
23800 ));
23801 }
23802 let chat_principal = session_principal_for_peers(host_session).await;
23803 {
23804 let sender_agent = host_session.agent_id.lock().await.clone();
23805 let is_host = host_session
23806 .is_host
23807 .load(std::sync::atomic::Ordering::Acquire);
23808 crate::peers::admit_turn(
23809 &state.clone(),
23810 &chat_principal,
23811 sender_agent,
23812 is_host,
23813 &agent_id,
23814 &prompt,
23815 )
23816 .await?;
23817 }
23818
23819 if !has_attached_agent {
23820 if let Some(spec) = declarative_spec {
23821 return handle_declarative_agents_chat(
23822 state,
23823 host_session,
23824 spec,
23825 prompt,
23826 session_id,
23827 stream,
23828 model,
23829 attachments,
23830 goal,
23831 )
23832 .await;
23833 }
23834 }
23835 let resolved = async {
23846 let agent_client_id = state
23847 .attached_agents
23848 .lock()
23849 .await
23850 .get(&agent_id)
23851 .cloned()
23852 .ok_or_else(|| {
23853 format!(
23854 "agent `{}` is not attached to this daemon — supervisor may have it stopped, or it hasn't called session.auth yet",
23855 agent_id
23856 )
23857 })?;
23858 let sessions = state.sessions.lock().await;
23859 sessions
23860 .get(&agent_client_id)
23861 .map(|s| s.channel.clone())
23862 .ok_or_else(|| {
23863 format!(
23864 "agent `{}` client_id `{}` not found in session registry (raced with disconnect)",
23865 agent_id, agent_client_id
23866 )
23867 })
23868 }
23869 .await;
23870 let agent_channel = match resolved {
23871 Ok(ch) => ch,
23872 Err(e) => {
23873 crate::peers::forget_synchronous_turn(state, &chat_principal, &agent_id, &prompt).await;
23874 return Err(e);
23875 }
23876 };
23877
23878 crate::peers::append_agent_chat_audit(
23881 state,
23882 &session_principal(host_session).await,
23883 &agent_id,
23884 &session_id,
23885 );
23886
23887 if let Some(goal_value) = &goal {
23888 ensure_chat_goal_running(state, &session_id, goal_value).await?;
23889 }
23890
23891 {
23897 let created_at = std::time::SystemTime::now()
23898 .duration_since(std::time::UNIX_EPOCH)
23899 .map(|d| d.as_secs())
23900 .unwrap_or(0);
23901 state.chat_sessions.lock().await.insert(
23902 session_id.clone(),
23903 crate::session::ChatSession {
23904 agent_id: agent_id.clone(),
23905 host_client_id: host_session.client_id.clone(),
23906 created_at,
23907 local_cancel: None,
23908 },
23909 );
23910 }
23911
23912 let request_id = agent_channel.next_request_id();
23919 let (tx, rx) = oneshot::channel();
23920 agent_channel
23921 .pending
23922 .lock()
23923 .await
23924 .insert(request_id.clone(), tx);
23925
23926 let chat_params = build_agent_chat_params(
23927 &session_id,
23928 &prompt,
23929 stream,
23930 &host_session.client_id,
23931 voice_input,
23932 model,
23933 attachments,
23934 goal,
23935 );
23936 let rpc_request = serde_json::json!({
23937 "jsonrpc": "2.0",
23938 "method": "agent.chat",
23939 "params": chat_params,
23940 "id": request_id,
23941 });
23942 let msg = Message::Text(
23943 serde_json::to_string(&rpc_request)
23944 .map_err(|e| e.to_string())?
23945 .into(),
23946 );
23947 if let Err(e) = agent_channel.write.lock().await.send(msg).await {
23948 agent_channel.pending.lock().await.remove(&request_id);
23952 state.chat_sessions.lock().await.remove(&session_id);
23953 update_chat_goal_from_event(
23954 state,
23955 &session_id,
23956 &serde_json::json!({
23957 "kind": "error",
23958 "error": format!("failed to deliver agent.chat to `{}`: {}", agent_id, e),
23959 }),
23960 )
23961 .await;
23962 return Err(format!(
23963 "failed to deliver agent.chat to `{}`: {}",
23964 agent_id, e
23965 ));
23966 }
23967
23968 let ack = match tokio::time::timeout(
23973 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23974 rx,
23975 )
23976 .await
23977 {
23978 Ok(Ok(resp)) => resp,
23979 Ok(Err(_)) => {
23980 state.chat_sessions.lock().await.remove(&session_id);
23982 update_chat_goal_from_event(
23983 state,
23984 &session_id,
23985 &serde_json::json!({
23986 "kind": "error",
23987 "error": format!("agent `{}` disconnected before acking agents.chat", agent_id),
23988 }),
23989 )
23990 .await;
23991 return Err(format!(
23992 "agent `{}` disconnected before acking agents.chat",
23993 agent_id
23994 ));
23995 }
23996 Err(_) => {
23997 agent_channel.pending.lock().await.remove(&request_id);
24001 state.chat_sessions.lock().await.remove(&session_id);
24002 update_chat_goal_from_event(
24003 state,
24004 &session_id,
24005 &serde_json::json!({
24006 "kind": "error",
24007 "error": format!(
24008 "agent `{}` did not ack agents.chat within {}s",
24009 agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24010 ),
24011 }),
24012 )
24013 .await;
24014 return Err(format!(
24015 "agent `{}` did not ack agents.chat within {}s",
24016 agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24017 ));
24018 }
24019 };
24020
24021 if let Some(err) = ack.error {
24022 state.chat_sessions.lock().await.remove(&session_id);
24024 update_chat_goal_from_event(
24025 state,
24026 &session_id,
24027 &serde_json::json!({
24028 "kind": "error",
24029 "error": format!("agent `{}` rejected chat: {}", agent_id, err),
24030 }),
24031 )
24032 .await;
24033 return Err(format!("agent `{}` rejected chat: {}", agent_id, err));
24034 }
24035
24036 Ok(serde_json::json!({
24037 "accepted": true,
24038 "session_id": session_id,
24039 }))
24040}
24041
24042async fn handle_declarative_agents_chat(
24043 state: &Arc<ServerState>,
24044 host_session: &Arc<crate::session::ClientSession>,
24045 spec: car_registry::declarative::DeclarativeAgentSpec,
24046 prompt: String,
24047 session_id: String,
24048 _stream: bool,
24049 model: Option<String>,
24050 attachments: Option<Value>,
24051 goal: Option<Value>,
24052) -> Result<Value, String> {
24053 if attachments.is_some() {
24054 return Err("declarative agents do not support `agents.chat` attachments".to_string());
24055 }
24056 if goal.is_some() {
24057 return Err(
24058 "`agents.chat` inline/standing goals are only supported by attached chat agents; \
24059 declarative agents use their manifest `goal`"
24060 .to_string(),
24061 );
24062 }
24063 if !spec.enabled {
24064 return Err(format!("agent '{}' is disabled", spec.id));
24065 }
24066
24067 let local_cancel = Arc::new(AtomicBool::new(false));
24068 {
24069 let created_at = std::time::SystemTime::now()
24070 .duration_since(std::time::UNIX_EPOCH)
24071 .map(|d| d.as_secs())
24072 .unwrap_or(0);
24073 state.chat_sessions.lock().await.insert(
24074 session_id.clone(),
24075 crate::session::ChatSession {
24076 agent_id: spec.id.clone(),
24077 host_client_id: host_session.client_id.clone(),
24078 created_at,
24079 local_cancel: Some(local_cancel.clone()),
24080 },
24081 );
24082 }
24083
24084 let state_for_task = state.clone();
24085 let sid = session_id.clone();
24086 tokio::spawn(async move {
24087 let result = crate::coder::rpc::run_declarative_with_cancel_and_model(
24088 &spec,
24089 &prompt,
24090 &state_for_task,
24091 Some(local_cancel.clone()),
24092 model,
24093 )
24094 .await;
24095 match result {
24096 Ok(run) => {
24097 if local_cancel.load(Ordering::SeqCst) {
24098 send_external_chat_frame(
24099 &state_for_task,
24100 &sid,
24101 serde_json::json!({
24102 "session_id": sid.clone(),
24103 "agent_id": spec.id.clone(),
24104 "kind": "error",
24105 "error": "cancelled",
24106 "result": crate::coder::rpc::run_result_json(&run),
24107 }),
24108 )
24109 .await;
24110 state_for_task.chat_sessions.lock().await.remove(&sid);
24111 return;
24112 }
24113 crate::coder::rpc::record_routing_outcome(&state_for_task, &spec.id, &run);
24114 if let Some(goal) = &run.goal {
24115 send_external_chat_frame(
24116 &state_for_task,
24117 &sid,
24118 serde_json::json!({
24119 "session_id": sid.clone(),
24120 "agent_id": spec.id.clone(),
24121 "kind": "goal_evaluated",
24122 "iteration": goal.iterations,
24123 "met": goal.met,
24124 "grounded": goal.grounded,
24125 "reason": goal.last_reason,
24126 }),
24127 )
24128 .await;
24129 }
24130 let result_json = crate::coder::rpc::run_result_json(&run);
24131 if let Some(err) = run.error.clone() {
24132 send_external_chat_frame(
24133 &state_for_task,
24134 &sid,
24135 serde_json::json!({
24136 "session_id": sid.clone(),
24137 "agent_id": spec.id.clone(),
24138 "kind": "error",
24139 "error": err,
24140 "result": result_json,
24141 }),
24142 )
24143 .await;
24144 } else {
24145 let text = run.output.clone();
24146 if !text.is_empty() {
24147 send_external_chat_frame(
24148 &state_for_task,
24149 &sid,
24150 serde_json::json!({
24151 "session_id": sid.clone(),
24152 "agent_id": spec.id.clone(),
24153 "kind": "token",
24154 "delta": text,
24155 }),
24156 )
24157 .await;
24158 }
24159 send_external_chat_frame(
24160 &state_for_task,
24161 &sid,
24162 serde_json::json!({
24163 "session_id": sid.clone(),
24164 "agent_id": spec.id.clone(),
24165 "kind": "done",
24166 "text": text,
24167 "finish_reason": "declarative agent completed",
24168 "result": result_json,
24169 }),
24170 )
24171 .await;
24172 }
24173 }
24174 Err(e) => {
24175 send_external_chat_frame(
24176 &state_for_task,
24177 &sid,
24178 serde_json::json!({
24179 "session_id": sid.clone(),
24180 "agent_id": spec.id.clone(),
24181 "kind": "error",
24182 "error": e,
24183 }),
24184 )
24185 .await;
24186 }
24187 }
24188 state_for_task.chat_sessions.lock().await.remove(&sid);
24189 });
24190
24191 Ok(serde_json::json!({
24192 "accepted": true,
24193 "session_id": session_id,
24194 "in_daemon": true,
24195 }))
24196}
24197
24198const A2A_CHAT_TIMEOUT_SECS: u64 = 180;
24203
24204pub(crate) const A2A_CONVERSATIONAL_PRINCIPAL: &str = "a2a:conversational";
24216
24217pub(crate) async fn chat_collect(
24218 state: &Arc<ServerState>,
24219 host_channel: &Arc<crate::session::WsChannel>,
24220 host_client_id: &str,
24221 prompt: &str,
24222) -> Result<String, String> {
24223 use futures::SinkExt;
24224 use tokio::sync::{mpsc, oneshot};
24225 use tokio_tungstenite::tungstenite::Message;
24226
24227 crate::peers::admit_turn(
24237 state,
24238 A2A_CONVERSATIONAL_PRINCIPAL,
24239 None,
24240 false,
24241 host_client_id,
24242 prompt,
24243 )
24244 .await?;
24245
24246 let session_id = format!("a2a-chat-{}", uuid::Uuid::new_v4().simple());
24247 let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<crate::session::ChatStreamChunk>();
24248 state.chat_collectors.lock().await.insert(
24249 session_id.clone(),
24250 crate::session::ChatCollector {
24251 tx: chunk_tx,
24252 host_client_id: host_client_id.to_string(),
24253 },
24254 );
24255
24256 let request_id = host_channel.next_request_id();
24258 let (ack_tx, ack_rx) = oneshot::channel();
24259 host_channel
24260 .pending
24261 .lock()
24262 .await
24263 .insert(request_id.clone(), ack_tx);
24264
24265 let req = serde_json::json!({
24266 "jsonrpc": "2.0",
24267 "method": "agent.chat",
24268 "params": {
24269 "session_id": session_id,
24270 "prompt": prompt,
24271 "stream": true,
24272 "context": { "source": "a2a" },
24273 },
24274 "id": request_id,
24275 });
24276 if let Err(e) = host_channel
24277 .write
24278 .lock()
24279 .await
24280 .send(Message::Text(
24281 serde_json::to_string(&req).unwrap_or_default().into(),
24282 ))
24283 .await
24284 {
24285 host_channel.pending.lock().await.remove(&request_id);
24286 state.chat_collectors.lock().await.remove(&session_id);
24287 return Err(format!("failed to deliver agent.chat to host: {e}"));
24288 }
24289
24290 let ack = tokio::time::timeout(
24292 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24293 ack_rx,
24294 )
24295 .await;
24296 match ack {
24297 Ok(Ok(resp)) => {
24298 if let Some(err) = resp.error {
24299 state.chat_collectors.lock().await.remove(&session_id);
24300 return Err(format!("host agent rejected chat: {err}"));
24301 }
24302 }
24303 Ok(Err(_)) => {
24304 state.chat_collectors.lock().await.remove(&session_id);
24305 return Err("host agent disconnected before acking agent.chat".to_string());
24306 }
24307 Err(_) => {
24308 host_channel.pending.lock().await.remove(&request_id);
24309 state.chat_collectors.lock().await.remove(&session_id);
24310 return Err(format!(
24311 "host agent did not ack agent.chat within {AGENT_CHAT_ACK_TIMEOUT_SECS}s"
24312 ));
24313 }
24314 }
24315
24316 let collect = collect_chat_stream(&mut chunk_rx);
24318 let result = tokio::time::timeout(
24319 std::time::Duration::from_secs(A2A_CHAT_TIMEOUT_SECS),
24320 collect,
24321 )
24322 .await;
24323 state.chat_collectors.lock().await.remove(&session_id);
24324 match result {
24325 Ok(r) => r,
24326 Err(_) => Err(format!(
24327 "host agent did not complete the reply within {A2A_CHAT_TIMEOUT_SECS}s"
24328 )),
24329 }
24330}
24331
24332pub(crate) struct WsChatResponder {
24338 pub state: std::sync::Weak<ServerState>,
24339 pub host_client_id: String,
24340}
24341
24342#[async_trait::async_trait]
24343impl car_a2a::ChatResponder for WsChatResponder {
24344 async fn respond(&self, prompt: &str) -> Result<String, String> {
24345 let state = self
24346 .state
24347 .upgrade()
24348 .ok_or_else(|| "daemon is shutting down".to_string())?;
24349 let host_channel = {
24350 let sessions = state.sessions.lock().await;
24351 sessions
24352 .get(&self.host_client_id)
24353 .map(|s| s.channel.clone())
24354 }
24355 .ok_or_else(|| "host session for the A2A listener has disconnected".to_string())?;
24356 chat_collect(&state, &host_channel, &self.host_client_id, prompt).await
24357 }
24358}
24359
24360async fn handle_agents_chat_cancel(
24368 req: &JsonRpcMessage,
24369 state: &Arc<ServerState>,
24370 caller_client_id: &str,
24371 caller_is_host: bool,
24372) -> Result<Value, String> {
24373 use futures::SinkExt;
24374 use tokio_tungstenite::tungstenite::Message;
24375
24376 let session_id = req
24377 .params
24378 .get("session_id")
24379 .and_then(Value::as_str)
24380 .ok_or_else(|| "`agents.chat.cancel` requires `session_id`".to_string())?
24381 .to_string();
24382
24383 {
24389 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24390 if let Some(chat) = chat {
24391 if !caller_is_host && caller_client_id != chat.host_client_id {
24392 return Err(
24393 "`agents.chat.cancel` requires the originating host session or \
24394 host-management role"
24395 .to_string(),
24396 );
24397 }
24398 }
24399 }
24400
24401 let chat = state.chat_sessions.lock().await.remove(&session_id);
24402 let chat = match chat {
24403 Some(c) => c,
24404 None => {
24405 return Ok(serde_json::json!({ "cancelled": false, "reason": "unknown session_id" }));
24407 }
24408 };
24409 if let Some(flag) = &chat.local_cancel {
24410 flag.store(true, Ordering::SeqCst);
24411 }
24412 update_chat_goal_from_event(
24413 state,
24414 &session_id,
24415 &serde_json::json!({
24416 "kind": "error",
24417 "error": "cancelled",
24418 }),
24419 )
24420 .await;
24421
24422 let agent_client_id = state
24425 .attached_agents
24426 .lock()
24427 .await
24428 .get(&chat.agent_id)
24429 .cloned();
24430 if let Some(client_id) = agent_client_id {
24431 let channel_opt = {
24432 let sessions = state.sessions.lock().await;
24433 sessions.get(&client_id).map(|s| s.channel.clone())
24434 };
24435 if let Some(channel) = channel_opt {
24436 let notification = serde_json::json!({
24437 "jsonrpc": "2.0",
24438 "method": "agent.chat.cancel",
24439 "params": { "session_id": session_id },
24440 });
24441 if let Ok(text) = serde_json::to_string(¬ification) {
24442 let _ = channel
24443 .write
24444 .lock()
24445 .await
24446 .send(Message::Text(text.into()))
24447 .await;
24448 }
24449 }
24450 }
24451
24452 Ok(serde_json::json!({ "cancelled": true, "session_id": session_id }))
24453}
24454
24455async fn handle_agents_chat_approve(
24474 req: &JsonRpcMessage,
24475 state: &Arc<ServerState>,
24476 caller_client_id: &str,
24477 caller_is_host: bool,
24478) -> Result<Value, String> {
24479 use futures::SinkExt;
24480 use tokio::sync::oneshot;
24481 use tokio_tungstenite::tungstenite::Message;
24482
24483 let session_id = req
24484 .params
24485 .get("session_id")
24486 .and_then(Value::as_str)
24487 .ok_or_else(|| "`agents.chat.approve` requires `session_id`".to_string())?
24488 .to_string();
24489 let approval_id = req
24490 .params
24491 .get("approval_id")
24492 .and_then(Value::as_str)
24493 .ok_or_else(|| "`agents.chat.approve` requires `approval_id`".to_string())?
24494 .to_string();
24495 let decision = req
24499 .params
24500 .get("decision")
24501 .cloned()
24502 .unwrap_or(Value::Bool(false));
24503
24504 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24507 let chat = chat.ok_or_else(|| {
24508 format!("`agents.chat.approve`: unknown or already-finished session_id `{session_id}`")
24509 })?;
24510 if !caller_is_host && caller_client_id != chat.host_client_id {
24511 return Err(
24512 "`agents.chat.approve` requires the originating host session or host-management role"
24513 .to_string(),
24514 );
24515 }
24516
24517 let agent_client_id = state
24519 .attached_agents
24520 .lock()
24521 .await
24522 .get(&chat.agent_id)
24523 .cloned()
24524 .ok_or_else(|| {
24525 format!(
24526 "agent `{}` is not attached to this daemon (raced with disconnect)",
24527 chat.agent_id
24528 )
24529 })?;
24530 let agent_channel = {
24531 let sessions = state.sessions.lock().await;
24532 sessions
24533 .get(&agent_client_id)
24534 .map(|s| s.channel.clone())
24535 .ok_or_else(|| {
24536 format!(
24537 "agent `{}` client_id `{}` not found in session registry",
24538 chat.agent_id, agent_client_id
24539 )
24540 })?
24541 };
24542
24543 let request_id = agent_channel.next_request_id();
24546 let (tx, rx) = oneshot::channel();
24547 agent_channel
24548 .pending
24549 .lock()
24550 .await
24551 .insert(request_id.clone(), tx);
24552
24553 let rpc_request = serde_json::json!({
24554 "jsonrpc": "2.0",
24555 "method": "agent.chat.approve",
24556 "params": { "approval_id": approval_id, "decision": decision },
24557 "id": request_id,
24558 });
24559 let msg = Message::Text(
24560 serde_json::to_string(&rpc_request)
24561 .map_err(|e| e.to_string())?
24562 .into(),
24563 );
24564 if let Err(e) = agent_channel.write.lock().await.send(msg).await {
24565 agent_channel.pending.lock().await.remove(&request_id);
24566 return Err(format!(
24567 "failed to deliver agent.chat.approve to `{}`: {}",
24568 chat.agent_id, e
24569 ));
24570 }
24571
24572 let ack = match tokio::time::timeout(
24573 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24574 rx,
24575 )
24576 .await
24577 {
24578 Ok(Ok(resp)) => resp,
24579 Ok(Err(_)) => {
24580 return Err(format!(
24581 "agent `{}` disconnected before acking agents.chat.approve",
24582 chat.agent_id
24583 ));
24584 }
24585 Err(_) => {
24586 agent_channel.pending.lock().await.remove(&request_id);
24587 return Err(format!(
24588 "agent `{}` did not ack agents.chat.approve within {}s",
24589 chat.agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24590 ));
24591 }
24592 };
24593 if let Some(err) = ack.error {
24594 return Err(format!(
24595 "agent `{}` rejected agents.chat.approve: {}",
24596 chat.agent_id, err
24597 ));
24598 }
24599
24600 let resolved = ack
24603 .output
24604 .as_ref()
24605 .and_then(|r| r.get("resolved"))
24606 .and_then(Value::as_bool)
24607 .unwrap_or(false);
24608 Ok(serde_json::json!({ "resolved": resolved, "session_id": session_id }))
24609}
24610
24611async fn update_chat_goal_from_event(state: &Arc<ServerState>, session_id: &str, params: &Value) {
24612 let Some(kind) = params.get("kind").and_then(Value::as_str) else {
24613 return;
24614 };
24615 let changed = {
24616 let mut goals = state.chat_goals.lock().await;
24617 let Some(goal) = goals.get_mut(session_id) else {
24618 return;
24619 };
24620 match kind {
24621 "goal_evaluated" => {
24622 goal.last_iteration = params
24623 .get("iteration")
24624 .and_then(Value::as_u64)
24625 .and_then(|n| u32::try_from(n).ok());
24626 goal.last_met = params.get("met").and_then(Value::as_bool);
24627 goal.last_grounded = params.get("grounded").and_then(Value::as_bool);
24628 goal.last_reason = params
24629 .get("reason")
24630 .and_then(Value::as_str)
24631 .map(str::to_string);
24632 goal.status = if goal.last_met == Some(true) && goal.last_grounded == Some(true) {
24633 "met".to_string()
24634 } else {
24635 "running".to_string()
24636 };
24637 goal.updated_at = unix_secs_now();
24638 true
24639 }
24640 "done" | "error" | "auth_required" => {
24646 goal.terminal_kind = Some(kind.to_string());
24647 goal.terminal_message = ["finish_reason", "error", "message"]
24654 .iter()
24655 .find_map(|key| params.get(*key).and_then(Value::as_str))
24656 .map(str::to_string);
24657 let unevaluated = params
24667 .get("goal_unevaluated")
24668 .and_then(Value::as_bool)
24669 .unwrap_or(false);
24670 goal.status = if kind == "error" || kind == "auth_required" {
24676 "error".to_string()
24677 } else if unevaluated {
24678 "unevaluated".to_string()
24679 } else {
24680 "met".to_string()
24681 };
24682 goal.updated_at = unix_secs_now();
24683 true
24684 }
24685 _ => false,
24686 }
24687 };
24688 if changed {
24689 if let Err(e) = state.persist_chat_goals().await {
24690 tracing::warn!(
24691 session_id,
24692 error = %e,
24693 "failed to persist chat goal status update"
24694 );
24695 }
24696 }
24697}
24698
24699async fn collect_chat_stream(
24708 chunk_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::session::ChatStreamChunk>,
24709) -> Result<String, String> {
24710 let mut text = String::new();
24711 while let Some(chunk) = chunk_rx.recv().await {
24712 match chunk.kind.as_str() {
24713 "token" => {
24714 if let Some(d) = chunk.delta {
24715 text.push_str(&d);
24716 }
24717 }
24718 "done" => return Ok(text),
24719 "error" => {
24720 return Err(chunk
24721 .error
24722 .unwrap_or_else(|| "host agent error".to_string()));
24723 }
24724 "auth_required" => {
24728 return Err(chunk
24729 .message
24730 .unwrap_or_else(|| "agent requires a Parslee sign-in".to_string()));
24731 }
24732 _ => {}
24733 }
24734 }
24735 Err("agent.chat stream ended before completion".to_string())
24736}
24737
24738pub(crate) async fn try_forward_agent_chat_event(
24749 parsed: &JsonRpcMessage,
24750 state: &Arc<ServerState>,
24751) -> bool {
24752 use futures::SinkExt;
24753 use tokio_tungstenite::tungstenite::Message;
24754
24755 let Some(method) = parsed.method.as_deref() else {
24759 return false;
24760 };
24761 if method != "agent.chat.event" {
24762 return false;
24763 }
24764 if !parsed.id.is_null() {
24765 return false;
24768 }
24769 let Some(session_id) = parsed.params.get("session_id").and_then(Value::as_str) else {
24770 return false;
24771 };
24772 let session_id = session_id.to_string();
24773
24774 let collector = state
24779 .chat_collectors
24780 .lock()
24781 .await
24782 .get(&session_id)
24783 .map(|c| c.tx.clone());
24784 if let Some(tx) = collector {
24785 let kind = parsed
24786 .params
24787 .get("kind")
24788 .and_then(Value::as_str)
24789 .map(str::to_string)
24790 .unwrap_or_else(|| {
24791 if parsed.params.get("error").is_some() {
24792 "error".to_string()
24793 } else if parsed.params.get("finish_reason").is_some() {
24794 "done".to_string()
24795 } else {
24796 "token".to_string()
24797 }
24798 });
24799 let delta = parsed
24800 .params
24801 .get("delta")
24802 .and_then(Value::as_str)
24803 .map(str::to_string);
24804 let error = parsed
24805 .params
24806 .get("error")
24807 .and_then(Value::as_str)
24808 .map(str::to_string);
24809 let message = parsed
24810 .params
24811 .get("message")
24812 .and_then(Value::as_str)
24813 .map(str::to_string);
24814 let _ = tx.send(crate::session::ChatStreamChunk {
24815 kind,
24816 delta,
24817 error,
24818 message,
24819 });
24820 return true;
24821 }
24822
24823 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24828 let Some(chat) = chat else {
24829 return true; };
24831
24832 let kind = parsed
24841 .params
24842 .get("kind")
24843 .and_then(Value::as_str)
24844 .map(str::to_string)
24845 .unwrap_or_else(|| {
24846 if parsed.params.get("error").is_some() {
24847 "error".to_string()
24848 } else if parsed.params.get("finish_reason").is_some() {
24849 "done".to_string()
24850 } else {
24851 "token".to_string()
24852 }
24853 });
24854 let mut goal_params = parsed.params.clone();
24866 if let Some(obj) = goal_params.as_object_mut() {
24867 obj.insert("kind".to_string(), Value::String(kind.clone()));
24868 }
24869 update_chat_goal_from_event(state, &session_id, &goal_params).await;
24870
24871 let host_channel = {
24875 let sessions = state.sessions.lock().await;
24876 sessions
24877 .get(&chat.host_client_id)
24878 .map(|s| s.channel.clone())
24879 };
24880 if let Some(channel) = host_channel {
24881 let mut params = parsed.params.clone();
24882 if let Some(obj) = params.as_object_mut() {
24883 obj.insert("agent_id".to_string(), Value::String(chat.agent_id.clone()));
24884 obj.entry("kind")
24889 .or_insert_with(|| Value::String(kind.clone()));
24890 }
24891 let forward = serde_json::json!({
24892 "jsonrpc": "2.0",
24893 "method": "agents.chat.event",
24894 "params": params,
24895 });
24896 if let Ok(text) = serde_json::to_string(&forward) {
24897 let send_result = channel
24898 .write
24899 .lock()
24900 .await
24901 .send(Message::Text(text.into()))
24902 .await;
24903 if let Err(e) = send_result {
24904 tracing::warn!(
24905 session_id = %session_id,
24906 agent_id = %chat.agent_id,
24907 host_client_id = %chat.host_client_id,
24908 kind = %kind,
24909 error = %e,
24910 "agent.chat.event forward to host failed at the WS send step"
24911 );
24912 }
24913 }
24914 } else {
24915 tracing::warn!(
24922 session_id = %session_id,
24923 agent_id = %chat.agent_id,
24924 host_client_id = %chat.host_client_id,
24925 kind = %kind,
24926 "agent.chat.event from supervised agent had no host channel \
24927 (host disconnected since `agents.chat`); dropping routing entry"
24928 );
24929 state.chat_sessions.lock().await.remove(&session_id);
24930 return true;
24931 }
24932
24933 if matches!(kind.as_str(), "done" | "error" | "auth_required") {
24937 state.chat_sessions.lock().await.remove(&session_id);
24938 }
24939
24940 true
24941}
24942
24943#[cfg(test)]
24944mod credential_event_lag_regression {
24945 use super::{credential_fanout_test_gate, credential_handoff_test_gate, run_dispatch};
24946 use futures::{Sink, SinkExt, StreamExt};
24947 use std::pin::Pin;
24948 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
24949 use std::sync::Arc;
24950 use std::task::{Context, Poll};
24951 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24952
24953 const HOST_TOKEN: &str = "lag-test-host-token-cccccccccccccccccccc";
24954
24955 struct StallAfterTwoWrites {
24956 outbound: futures::channel::mpsc::UnboundedSender<Message>,
24957 sends: Arc<AtomicUsize>,
24958 stalled: Arc<AtomicBool>,
24959 }
24960
24961 impl Sink<Message> for StallAfterTwoWrites {
24962 type Error = WsError;
24963
24964 fn poll_ready(
24965 self: Pin<&mut Self>,
24966 _context: &mut Context<'_>,
24967 ) -> Poll<Result<(), Self::Error>> {
24968 if self.sends.load(Ordering::SeqCst) >= 2 {
24969 self.stalled.store(true, Ordering::SeqCst);
24970 Poll::Pending
24971 } else {
24972 Poll::Ready(Ok(()))
24973 }
24974 }
24975
24976 fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
24977 self.outbound
24978 .unbounded_send(message)
24979 .map_err(|_| WsError::ConnectionClosed)?;
24980 self.sends.fetch_add(1, Ordering::SeqCst);
24981 Ok(())
24982 }
24983
24984 fn poll_flush(
24985 self: Pin<&mut Self>,
24986 _context: &mut Context<'_>,
24987 ) -> Poll<Result<(), Self::Error>> {
24988 Poll::Ready(Ok(()))
24989 }
24990
24991 fn poll_close(
24992 self: Pin<&mut Self>,
24993 _context: &mut Context<'_>,
24994 ) -> Poll<Result<(), Self::Error>> {
24995 Poll::Ready(Ok(()))
24996 }
24997 }
24998
24999 fn request(id: &str, method: &str, params: serde_json::Value) -> Message {
25000 Message::Text(
25001 serde_json::json!({
25002 "jsonrpc": "2.0",
25003 "id": id,
25004 "method": method,
25005 "params": params,
25006 })
25007 .to_string()
25008 .into(),
25009 )
25010 }
25011
25012 #[tokio::test]
25013 async fn credential_event_queue_overflow_terminates_host_dispatch() {
25014 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_LAG_CHILD";
25015 if std::env::var_os(SENTINEL).is_none() {
25016 let status = std::process::Command::new(std::env::current_exe().unwrap())
25017 .arg("--exact")
25018 .arg(
25019 "handler::credential_event_lag_regression::credential_event_queue_overflow_terminates_host_dispatch",
25020 )
25021 .arg("--nocapture")
25022 .env(SENTINEL, "1")
25023 .env("CAR_NO_INFERENCE_WORKER", "1")
25024 .status()
25025 .expect("spawn isolated credential-event lag contract");
25026 assert!(
25027 status.success(),
25028 "isolated credential-event lag contract failed"
25029 );
25030 return;
25031 }
25032
25033 let temp = tempfile::TempDir::new().unwrap();
25034 let secrets = tempfile::TempDir::new().unwrap();
25035 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25036 std::env::set_var(car_home::ENV_VAR, temp.path());
25037 car_auth::commit_login(
25038 "https://api.example.test",
25039 &car_auth::TokenSet {
25040 access_token: "lag-test-access".into(),
25041 refresh_token: "lag-test-refresh".into(),
25042 expires_in: 3_600,
25043 token_type: "Bearer".into(),
25044 },
25045 r#"{"Account":{"Id":"lag-account","Email":"lag@example.test"}}"#,
25046 None,
25047 )
25048 .await
25049 .unwrap();
25050
25051 let state = Arc::new(crate::session::ServerState::standalone(
25052 temp.path().join("journal"),
25053 ));
25054 state
25055 .install_host_token(HOST_TOKEN.to_string())
25056 .expect("install host token");
25057
25058 let (inbound_tx, inbound_rx) =
25059 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25060 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25061 let sends = Arc::new(AtomicUsize::new(0));
25062 let credential_write_stalled = Arc::new(AtomicBool::new(false));
25063 let sink = StallAfterTwoWrites {
25064 outbound: outbound_tx,
25065 sends: Arc::clone(&sends),
25066 stalled: Arc::clone(&credential_write_stalled),
25067 };
25068 let write: crate::session::WsSink = Box::pin(sink);
25069 let dispatch_state = Arc::clone(&state);
25070 let mut dispatch = tokio::spawn(async move {
25071 run_dispatch(
25072 inbound_rx,
25073 write,
25074 "credential-lag-test".to_string(),
25075 dispatch_state,
25076 )
25077 .await
25078 .map_err(|error| error.to_string())
25079 });
25080
25081 inbound_tx
25082 .unbounded_send(Ok(request(
25083 "auth",
25084 "session.auth",
25085 serde_json::json!({ "host_token": HOST_TOKEN }),
25086 )))
25087 .unwrap();
25088 let auth_response = outbound_rx.next().await.expect("host auth response");
25089 assert!(auth_response
25090 .into_text()
25091 .unwrap()
25092 .contains("\"role\":\"host\""));
25093
25094 inbound_tx
25095 .unbounded_send(Ok(request(
25096 "handshake",
25097 "server.handshake",
25098 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25099 )))
25100 .unwrap();
25101 let handshake_response = outbound_rx.next().await.expect("handshake response");
25102 assert!(handshake_response.into_text().unwrap().contains(&format!(
25103 "\"protocol_version\":{}",
25104 car_proto::PROTOCOL_VERSION
25105 )));
25106
25107 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25108 .await
25109 .expect("credential generation should resolve");
25110 for _ in 0..100 {
25111 if credential_write_stalled.load(Ordering::SeqCst) {
25112 break;
25113 }
25114 tokio::task::yield_now().await;
25115 }
25116 assert!(
25117 credential_write_stalled.load(Ordering::SeqCst),
25118 "the real host fanout must be stalled on its credential-event write"
25119 );
25120 for _ in 0..3 {
25121 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25122 .await
25123 .expect("credential generation should resolve while host fanout is stalled");
25124 }
25125
25126 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25127 .await
25128 .expect("credential-event overflow must cancel the owning host connection")
25129 .expect("dispatcher task should join");
25130 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25131 assert!(
25132 state.sessions.lock().await.is_empty(),
25133 "a lagged host cannot remain connected but silently unsubscribed"
25134 );
25135 }
25136
25137 #[tokio::test]
25138 async fn preauth_activity_cannot_disconnect_a_later_eligible_host() {
25139 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_PREAUTH_CHILD";
25140 if std::env::var_os(SENTINEL).is_none() {
25141 let status = std::process::Command::new(std::env::current_exe().unwrap())
25142 .arg("--exact")
25143 .arg(
25144 "handler::credential_event_lag_regression::preauth_activity_cannot_disconnect_a_later_eligible_host",
25145 )
25146 .arg("--nocapture")
25147 .env(SENTINEL, "1")
25148 .env("CAR_NO_INFERENCE_WORKER", "1")
25149 .env("CAR_TEST_PAUSE_CREDENTIAL_FANOUT", "1")
25150 .status()
25151 .expect("spawn isolated pre-auth credential-event contract");
25152 assert!(status.success(), "isolated pre-auth contract failed");
25153 return;
25154 }
25155
25156 let temp = tempfile::TempDir::new().unwrap();
25157 let secrets = tempfile::TempDir::new().unwrap();
25158 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25159 std::env::set_var(car_home::ENV_VAR, temp.path());
25160 car_auth::commit_login(
25161 "https://api.example.test",
25162 &car_auth::TokenSet {
25163 access_token: "preauth-test-access".into(),
25164 refresh_token: "preauth-test-refresh".into(),
25165 expires_in: 3_600,
25166 token_type: "Bearer".into(),
25167 },
25168 r#"{"Account":{"Id":"preauth-account","Email":"preauth@example.test"}}"#,
25169 None,
25170 )
25171 .await
25172 .unwrap();
25173
25174 let state = Arc::new(crate::session::ServerState::standalone(
25175 temp.path().join("journal"),
25176 ));
25177 state
25178 .install_host_token(HOST_TOKEN.to_string())
25179 .expect("install host token");
25180
25181 let (inbound_tx, inbound_rx) =
25182 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25183 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25184 let write: crate::session::WsSink = Box::pin(
25185 outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
25186 );
25187 let dispatch_state = Arc::clone(&state);
25188 let mut dispatch = tokio::spawn(async move {
25189 run_dispatch(
25190 inbound_rx,
25191 write,
25192 "credential-preauth-test".to_string(),
25193 dispatch_state,
25194 )
25195 .await
25196 .map_err(|error| error.to_string())
25197 });
25198
25199 credential_fanout_test_gate().ready.notified().await;
25200 for _ in 0..3 {
25201 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25202 .await
25203 .expect("pre-auth credential generation should resolve");
25204 }
25205
25206 inbound_tx
25207 .unbounded_send(Ok(request(
25208 "auth",
25209 "session.auth",
25210 serde_json::json!({ "host_token": HOST_TOKEN }),
25211 )))
25212 .unwrap();
25213 let auth_response = outbound_rx.next().await.expect("host auth response");
25214 assert!(auth_response
25215 .into_text()
25216 .unwrap()
25217 .contains("\"role\":\"host\""));
25218
25219 inbound_tx
25220 .unbounded_send(Ok(request(
25221 "handshake",
25222 "server.handshake",
25223 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25224 )))
25225 .unwrap();
25226 let mut negotiated = false;
25227 for _ in 0..100 {
25228 negotiated = state.sessions.lock().await.values().any(|session| {
25229 session.negotiated_protocol_version.load(Ordering::Acquire)
25230 == car_proto::PROTOCOL_VERSION
25231 });
25232 if negotiated {
25233 break;
25234 }
25235 tokio::task::yield_now().await;
25236 }
25237 assert!(
25238 negotiated,
25239 "host protocol negotiation must complete while fanout is paused"
25240 );
25241
25242 credential_fanout_test_gate().release.notify_one();
25243 let mut saw_handshake = false;
25244 let mut saw_reconciled = false;
25245 for _ in 0..2 {
25246 let frame =
25247 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25248 .await
25249 .expect("eligible host should receive handshake and reconciled snapshot")
25250 .expect("eligible host connection must remain open")
25251 .into_text()
25252 .unwrap();
25253 saw_handshake |= frame.contains(&format!(
25254 "\"protocol_version\":{}",
25255 car_proto::PROTOCOL_VERSION
25256 ));
25257 saw_reconciled |= frame.contains("\"method\":\"auth.credential.event\"")
25258 && frame.contains("\"state\":\"configured\"");
25259 }
25260 assert!(saw_handshake, "host must receive handshake success");
25261 assert!(
25262 saw_reconciled,
25263 "host must receive the retained credential snapshot"
25264 );
25265
25266 inbound_tx
25267 .unbounded_send(Ok(request(
25268 "still-connected",
25269 "auth.authority_hint",
25270 serde_json::json!({}),
25271 )))
25272 .unwrap();
25273 let response =
25274 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25275 .await
25276 .expect("host should remain responsive after pre-auth activity")
25277 .expect("host connection should remain open")
25278 .into_text()
25279 .unwrap();
25280 assert!(response.contains("\"id\":\"still-connected\""));
25281 assert!(!dispatch.is_finished());
25282
25283 inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
25284 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25285 .await
25286 .expect("dispatcher should stop after close")
25287 .expect("dispatcher task should join");
25288 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25289 }
25290
25291 #[tokio::test]
25292 async fn handoff_cannot_send_terminal_before_queued_pending() {
25293 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_HANDOFF_CHILD";
25294 if std::env::var_os(SENTINEL).is_none() {
25295 let status = std::process::Command::new(std::env::current_exe().unwrap())
25296 .arg("--exact")
25297 .arg(
25298 "handler::credential_event_lag_regression::handoff_cannot_send_terminal_before_queued_pending",
25299 )
25300 .arg("--nocapture")
25301 .env(SENTINEL, "1")
25302 .env("CAR_NO_INFERENCE_WORKER", "1")
25303 .env("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF", "1")
25304 .status()
25305 .expect("spawn isolated credential handoff contract");
25306 assert!(status.success(), "isolated credential handoff failed");
25307 return;
25308 }
25309
25310 let temp = tempfile::TempDir::new().unwrap();
25311 let secrets = tempfile::TempDir::new().unwrap();
25312 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25313 std::env::set_var(car_home::ENV_VAR, temp.path());
25314 car_auth::commit_login(
25315 "https://api.example.test",
25316 &car_auth::TokenSet {
25317 access_token: "handoff-test-access".into(),
25318 refresh_token: "handoff-test-refresh".into(),
25319 expires_in: 3_600,
25320 token_type: "Bearer".into(),
25321 },
25322 r#"{"Account":{"Id":"handoff-account","Email":"handoff@example.test"}}"#,
25323 None,
25324 )
25325 .await
25326 .unwrap();
25327
25328 let state = Arc::new(crate::session::ServerState::standalone(
25329 temp.path().join("journal"),
25330 ));
25331 state
25332 .install_host_token(HOST_TOKEN.to_string())
25333 .expect("install host token");
25334
25335 let (inbound_tx, inbound_rx) =
25336 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25337 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25338 let write: crate::session::WsSink = Box::pin(
25339 outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
25340 );
25341 let dispatch_state = Arc::clone(&state);
25342 let mut dispatch = tokio::spawn(async move {
25343 run_dispatch(
25344 inbound_rx,
25345 write,
25346 "credential-handoff-test".to_string(),
25347 dispatch_state,
25348 )
25349 .await
25350 .map_err(|error| error.to_string())
25351 });
25352
25353 inbound_tx
25354 .unbounded_send(Ok(request(
25355 "auth",
25356 "session.auth",
25357 serde_json::json!({ "host_token": HOST_TOKEN }),
25358 )))
25359 .unwrap();
25360 let auth_response = outbound_rx.next().await.expect("host auth response");
25361 assert!(auth_response
25362 .into_text()
25363 .unwrap()
25364 .contains("\"role\":\"host\""));
25365
25366 inbound_tx
25367 .unbounded_send(Ok(request(
25368 "handshake",
25369 "server.handshake",
25370 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25371 )))
25372 .unwrap();
25373 credential_handoff_test_gate().ready.notified().await;
25374
25375 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25376 .await
25377 .expect("interleaved credential generation should resolve");
25378 credential_handoff_test_gate().release.notify_one();
25379
25380 let mut saw_handshake = false;
25381 let mut states = Vec::new();
25382 while !saw_handshake || states.len() < 2 {
25383 let frame =
25384 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25385 .await
25386 .expect("handoff must deliver handshake and exact credential lifecycle")
25387 .expect("eligible host connection must remain open")
25388 .into_text()
25389 .unwrap();
25390 let value: serde_json::Value =
25391 serde_json::from_str(&frame).expect("credential handoff frame JSON");
25392 saw_handshake |= value["id"] == "handshake";
25393 if value["method"] == "auth.credential.event" {
25394 states.push(value["params"]["state"].as_str().unwrap().to_string());
25395 }
25396 }
25397 assert_eq!(states, ["pending", "configured"]);
25398
25399 inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
25400 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25401 .await
25402 .expect("dispatcher should stop after close")
25403 .expect("dispatcher task should join");
25404 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25405 }
25406}
25407
25408#[cfg(test)]
25409mod fd_leak_regression {
25410 use super::run_dispatch;
25417 use futures::SinkExt;
25418 use std::sync::Arc;
25419 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25420
25421 #[tokio::test]
25422 async fn abrupt_read_error_still_runs_session_cleanup() {
25423 let tmp = tempfile::TempDir::new().unwrap();
25424 let state = Arc::new(crate::session::ServerState::standalone(
25425 tmp.path().to_path_buf(),
25426 ));
25427
25428 let read = futures::stream::iter(vec![Err::<Message, WsError>(WsError::ConnectionClosed)]);
25432 let write: crate::session::WsSink =
25433 Box::pin(futures::sink::drain().sink_map_err(|_| WsError::ConnectionClosed));
25434
25435 let result = run_dispatch(read, write, "test-peer".to_string(), state.clone()).await;
25436 assert!(
25437 result.is_ok(),
25438 "run_dispatch must return Ok after cleanup, got {result:?}"
25439 );
25440
25441 assert!(
25444 state.sessions.lock().await.is_empty(),
25445 "state.sessions must be empty after an abrupt disconnect (car#209)"
25446 );
25447 }
25448}
25449
25450#[cfg(test)]
25451mod a2ui_action_delivery {
25452 use super::{handle_a2ui_action, JsonRpcMessage};
25457 use crate::session::{ServerState, WsChannel, WsSink};
25458 use futures::{SinkExt, StreamExt};
25459 use std::collections::HashMap;
25460 use std::sync::atomic::AtomicU64;
25461 use std::sync::Arc;
25462 use tokio::sync::Mutex;
25463 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25464
25465 #[tokio::test]
25466 async fn client_action_broadcasts_to_a2ui_subscribers() {
25467 let tmp = tempfile::TempDir::new().unwrap();
25468 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25469
25470 let (tx, mut rx) = futures::channel::mpsc::unbounded::<Message>();
25473 let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25474 let channel = Arc::new(WsChannel {
25475 write: Mutex::new(sink),
25476 pending: Mutex::new(HashMap::new()),
25477 active_actions: Mutex::new(HashMap::new()),
25478 next_id: AtomicU64::new(0),
25479 });
25480 state
25481 .a2ui_subscribers
25482 .lock()
25483 .await
25484 .insert("test-sub".to_string(), channel);
25485
25486 let req: JsonRpcMessage = serde_json::from_value(serde_json::json!({
25490 "jsonrpc": "2.0",
25491 "method": "a2ui.action",
25492 "id": 1,
25493 "params": {
25494 "action": "trader:pause",
25495 "surfaceId": "surf-1",
25496 "sourceComponentId": "b1",
25497 "timestamp": "2026-06-03T00:00:00Z"
25498 }
25499 }))
25500 .unwrap();
25501
25502 let out = handle_a2ui_action(&req, &state).await;
25503 assert!(out.is_ok(), "handle_a2ui_action failed: {out:?}");
25504
25505 let msg = rx.next().await.expect("subscriber received no frame");
25506 let text = match msg {
25507 Message::Text(t) => t.to_string(),
25508 other => panic!("expected text frame, got {other:?}"),
25509 };
25510 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25511 assert_eq!(v["method"], "a2ui.event");
25512 assert_eq!(v["params"]["kind"], "a2ui.action");
25513 assert_eq!(
25515 v["params"]["result"]["action"]["name"], "trader:pause",
25516 "ClientAction.name should accept the `action` alias"
25517 );
25518 assert_eq!(v["params"]["result"]["surfaceId"], "surf-1");
25519 }
25520}
25521
25522#[cfg(test)]
25523mod a2a_chat_collector {
25524 use super::{try_forward_agent_chat_event, JsonRpcMessage};
25528 use crate::session::{ChatCollector, ServerState};
25529 use std::sync::Arc;
25530 use tokio::sync::mpsc;
25531
25532 fn event(session_id: &str, extra: serde_json::Value) -> JsonRpcMessage {
25533 let mut params = serde_json::Map::new();
25534 params.insert("session_id".into(), session_id.into());
25535 if let Some(obj) = extra.as_object() {
25536 for (k, v) in obj {
25537 params.insert(k.clone(), v.clone());
25538 }
25539 }
25540 serde_json::from_value(serde_json::json!({
25541 "jsonrpc": "2.0",
25542 "method": "agent.chat.event",
25543 "params": params,
25544 }))
25545 .unwrap()
25546 }
25547
25548 #[tokio::test]
25549 async fn collector_receives_normalized_chunks() {
25550 let tmp = tempfile::TempDir::new().unwrap();
25551 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25552 let (tx, mut rx) = mpsc::unbounded_channel();
25553 state.chat_collectors.lock().await.insert(
25554 "a2a-chat-xyz".into(),
25555 ChatCollector {
25556 tx,
25557 host_client_id: "host-1".into(),
25558 },
25559 );
25560
25561 assert!(
25563 try_forward_agent_chat_event(
25564 &event(
25565 "a2a-chat-xyz",
25566 serde_json::json!({ "kind": "token", "delta": "Hello " })
25567 ),
25568 &state,
25569 )
25570 .await
25571 );
25572 try_forward_agent_chat_event(
25574 &event("a2a-chat-xyz", serde_json::json!({ "delta": "world" })),
25575 &state,
25576 )
25577 .await;
25578 try_forward_agent_chat_event(
25580 &event(
25581 "a2a-chat-xyz",
25582 serde_json::json!({ "finish_reason": "1 turn" }),
25583 ),
25584 &state,
25585 )
25586 .await;
25587
25588 let c1 = rx.recv().await.unwrap();
25589 assert_eq!(c1.kind, "token");
25590 assert_eq!(c1.delta.as_deref(), Some("Hello "));
25591 let c2 = rx.recv().await.unwrap();
25592 assert_eq!(c2.delta.as_deref(), Some("world"));
25593 let c3 = rx.recv().await.unwrap();
25594 assert_eq!(c3.kind, "done");
25595
25596 assert!(state
25598 .chat_collectors
25599 .lock()
25600 .await
25601 .contains_key("a2a-chat-xyz"));
25602 }
25603
25604 #[tokio::test]
25613 async fn an_auth_required_chunk_ends_the_collection_with_its_message() {
25614 let (tx, mut rx) = mpsc::unbounded_channel();
25615 tx.send(crate::session::ChatStreamChunk {
25616 kind: "token".into(),
25617 delta: Some("thinking…".into()),
25618 error: None,
25619 message: None,
25620 })
25621 .unwrap();
25622 tx.send(crate::session::ChatStreamChunk {
25623 kind: "auth_required".into(),
25624 delta: None,
25625 error: None,
25626 message: Some("Parslee Core runs on your Parslee account.".into()),
25627 })
25628 .unwrap();
25629 let result = tokio::time::timeout(
25632 std::time::Duration::from_secs(2),
25633 super::collect_chat_stream(&mut rx),
25634 )
25635 .await
25636 .expect("the collector must not wait for its caller's timeout");
25637 assert_eq!(
25638 result,
25639 Err("Parslee Core runs on your Parslee account.".to_string())
25640 );
25641 }
25642
25643 #[tokio::test]
25648 async fn an_auth_required_frame_drops_the_routing_entry() {
25649 let tmp = tempfile::TempDir::new().unwrap();
25650 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25651 state
25655 .create_session("host-1", Arc::new(crate::session::WsChannel::test_stub()))
25656 .await
25657 .unwrap();
25658 state.chat_sessions.lock().await.insert(
25659 "auth-route".into(),
25660 crate::session::ChatSession {
25661 agent_id: "parslee-core".into(),
25662 host_client_id: "host-1".into(),
25663 created_at: 0,
25664 local_cancel: None,
25665 },
25666 );
25667
25668 try_forward_agent_chat_event(
25669 &event(
25670 "auth-route",
25671 serde_json::json!({ "kind": "token", "delta": "hi" }),
25672 ),
25673 &state,
25674 )
25675 .await;
25676 assert!(
25677 state.chat_sessions.lock().await.contains_key("auth-route"),
25678 "a non-terminal frame must leave the route in place"
25679 );
25680
25681 try_forward_agent_chat_event(
25682 &event(
25683 "auth-route",
25684 serde_json::json!({
25685 "kind": "auth_required",
25686 "reason": "signed_out",
25687 "message": "Parslee Core runs on your Parslee account."
25688 }),
25689 ),
25690 &state,
25691 )
25692 .await;
25693 assert!(
25694 !state.chat_sessions.lock().await.contains_key("auth-route"),
25695 "a terminal auth_required must drop the routing entry"
25696 );
25697 }
25698
25699 #[tokio::test]
25700 async fn error_event_carries_error_text() {
25701 let tmp = tempfile::TempDir::new().unwrap();
25702 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25703 let (tx, mut rx) = mpsc::unbounded_channel();
25704 state.chat_collectors.lock().await.insert(
25705 "a2a-chat-err".into(),
25706 ChatCollector {
25707 tx,
25708 host_client_id: "h".into(),
25709 },
25710 );
25711 try_forward_agent_chat_event(
25712 &event(
25713 "a2a-chat-err",
25714 serde_json::json!({ "error": "model exploded" }),
25715 ),
25716 &state,
25717 )
25718 .await;
25719 let c = rx.recv().await.unwrap();
25720 assert_eq!(c.kind, "error"); assert_eq!(c.error.as_deref(), Some("model exploded"));
25722 }
25723}
25724
25725#[cfg(test)]
25726mod schedule_suggest_rpc {
25727 use super::{handle_schedule_suggest, JsonRpcMessage};
25728
25729 fn request(params: serde_json::Value) -> JsonRpcMessage {
25730 JsonRpcMessage {
25731 jsonrpc: "2.0".into(),
25732 method: Some("schedule.suggest".into()),
25733 params,
25734 id: serde_json::json!(1),
25735 result: None,
25736 error: None,
25737 }
25738 }
25739
25740 #[test]
25741 fn rpc_returns_structured_cadence_and_preserves_timezone() {
25742 let result = handle_schedule_suggest(&request(serde_json::json!({
25743 "phrase": "weekly on Monday at 8:30",
25744 "timezone": "America/New_York"
25745 })))
25746 .unwrap();
25747 assert_eq!(result["cadence"]["trigger"], "cron");
25748 assert_eq!(result["cadence"]["schedule"], "30 8 * * 1");
25749 assert_eq!(result["cadence"]["timezone"], "America/New_York");
25750 assert!(result.get("reason").is_none());
25751 }
25752
25753 #[test]
25754 fn rpc_returns_reason_instead_of_guessing_and_rejects_missing_phrase() {
25755 let result = handle_schedule_suggest(&request(serde_json::json!({
25756 "phrase": "sometime after lunch"
25757 })))
25758 .unwrap();
25759 assert!(result["cadence"].is_null());
25760 assert!(result["reason"].as_str().is_some_and(|s| !s.is_empty()));
25761
25762 assert_eq!(
25763 handle_schedule_suggest(&request(serde_json::json!({}))).unwrap_err(),
25764 "schedule.suggest requires 'phrase'"
25765 );
25766 }
25767}
25768
25769#[cfg(test)]
25770mod scheduler_stateful_dedup {
25771 use super::run_scheduler_task_once;
25779 use car_multi::{AgentOutput, AgentRunner, AgentSpec, Mailbox, MultiError};
25780 use car_scheduler::{Task, TaskStore, TaskTrigger};
25781 use std::sync::atomic::{AtomicU32, Ordering};
25782 use std::sync::Arc;
25783
25784 struct CountingRunner {
25786 runs: Arc<AtomicU32>,
25787 }
25788
25789 #[async_trait::async_trait]
25790 impl AgentRunner for CountingRunner {
25791 async fn run(
25792 &self,
25793 spec: &AgentSpec,
25794 _task: &str,
25795 _runtime: &car_engine::Runtime,
25796 _mailbox: &Mailbox,
25797 ) -> Result<AgentOutput, MultiError> {
25798 self.runs.fetch_add(1, Ordering::SeqCst);
25799 Ok(AgentOutput {
25800 name: spec.name.clone(),
25801 answer: "done".to_string(),
25802 turns: 1,
25803 tool_calls: 0,
25804 duration_ms: 1.0,
25805 error: None,
25806 outcome: None,
25807 tokens: None,
25808 tools_used: Vec::new(),
25809 })
25810 }
25811 }
25812
25813 #[tokio::test]
25814 async fn second_run_dedups_across_the_jsonrpc_boundary() {
25815 let dir = tempfile::TempDir::new().unwrap();
25816 let store = TaskStore::new(dir.path());
25817 let runs = Arc::new(AtomicU32::new(0));
25818
25819 let task =
25823 Task::new("dedup_task", "do the thing").with_trigger(TaskTrigger::Interval, "1h");
25824 let task_json = serde_json::to_value(&task).unwrap();
25825
25826 let mut t1: Task = serde_json::from_value(task_json.clone()).unwrap();
25828 assert!(t1.executions.is_empty());
25829 let runner1: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25830 run_scheduler_task_once(&mut t1, runner1, &store).await;
25831
25832 let mut t2: Task = serde_json::from_value(task_json).unwrap();
25835 assert!(t2.executions.is_empty());
25836 let runner2: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25837 run_scheduler_task_once(&mut t2, runner2, &store).await;
25838
25839 assert_eq!(
25840 runs.load(Ordering::SeqCst),
25841 1,
25842 "the runner must be invoked exactly once across two stateless calls"
25843 );
25844
25845 let persisted = store.load(&task.id).expect("task should be persisted");
25847 assert_eq!(persisted.run_count, 1);
25848 assert_eq!(persisted.executions.len(), 1);
25849 }
25850}
25851
25852#[cfg(test)]
25853mod a2ui_apply_delivery {
25854 use super::apply_a2ui_envelope;
25860 use crate::session::{ServerState, WsChannel, WsSink};
25861 use futures::{SinkExt, StreamExt};
25862 use std::collections::HashMap;
25863 use std::sync::atomic::AtomicU64;
25864 use std::sync::Arc;
25865 use tokio::sync::Mutex;
25866 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25867
25868 fn fake_subscriber() -> (
25869 Arc<WsChannel>,
25870 futures::channel::mpsc::UnboundedReceiver<Message>,
25871 ) {
25872 let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
25873 let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25874 let channel = Arc::new(WsChannel {
25875 write: Mutex::new(sink),
25876 pending: Mutex::new(HashMap::new()),
25877 active_actions: Mutex::new(HashMap::new()),
25878 next_id: AtomicU64::new(0),
25879 });
25880 (channel, rx)
25881 }
25882
25883 fn envelope(value: serde_json::Value) -> car_a2ui::A2uiEnvelope {
25884 serde_json::from_value(value).expect("valid envelope")
25885 }
25886
25887 #[tokio::test]
25888 async fn apply_create_broadcasts_surface_updated() {
25889 let tmp = tempfile::TempDir::new().unwrap();
25890 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25891 let (channel, mut rx) = fake_subscriber();
25892 state
25893 .a2ui_subscribers
25894 .lock()
25895 .await
25896 .insert("test-sub".to_string(), channel);
25897
25898 apply_a2ui_envelope(
25899 &state,
25900 envelope(serde_json::json!({
25901 "version": "v0.9",
25902 "createSurface": { "surfaceId": "surf-a" }
25903 })),
25904 None,
25905 None,
25906 )
25907 .await
25908 .expect("apply must succeed");
25909
25910 let msg = rx.next().await.expect("subscriber received no frame");
25911 let text = match msg {
25912 Message::Text(t) => t.to_string(),
25913 other => panic!("expected text frame, got {other:?}"),
25914 };
25915 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25916 assert_eq!(v["method"], "a2ui.event");
25917 assert_eq!(v["params"]["kind"], "a2ui.surface_updated");
25918 assert_eq!(v["params"]["result"]["surfaceId"], "surf-a");
25919 }
25920
25921 #[tokio::test]
25922 async fn apply_delete_broadcasts_surface_deleted() {
25923 let tmp = tempfile::TempDir::new().unwrap();
25924 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25925 let (channel, mut rx) = fake_subscriber();
25926 state
25927 .a2ui_subscribers
25928 .lock()
25929 .await
25930 .insert("test-sub".to_string(), channel);
25931
25932 apply_a2ui_envelope(
25934 &state,
25935 envelope(serde_json::json!({
25936 "version": "v0.9",
25937 "createSurface": { "surfaceId": "surf-d" }
25938 })),
25939 None,
25940 None,
25941 )
25942 .await
25943 .expect("create must succeed");
25944 let _ = rx.next().await.expect("surface_updated frame");
25945
25946 apply_a2ui_envelope(
25947 &state,
25948 envelope(serde_json::json!({
25949 "version": "v0.9",
25950 "deleteSurface": { "surfaceId": "surf-d" }
25951 })),
25952 None,
25953 None,
25954 )
25955 .await
25956 .expect("delete must succeed");
25957
25958 let msg = rx
25959 .next()
25960 .await
25961 .expect("subscriber received no delete frame");
25962 let text = match msg {
25963 Message::Text(t) => t.to_string(),
25964 other => panic!("expected text frame, got {other:?}"),
25965 };
25966 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25967 assert_eq!(v["method"], "a2ui.event");
25968 assert_eq!(v["params"]["kind"], "a2ui.surface_deleted");
25969 assert_eq!(v["params"]["result"]["surfaceId"], "surf-d");
25970 }
25971}
25972
25973#[cfg(test)]
25974mod agents_chat_attachments {
25975 use super::{
25980 build_agent_chat_params, ensure_chat_goal_running, extract_chat_attachments,
25981 extract_chat_goal, extract_chat_model, handle_agents_chat_cancel, handle_goal_clear,
25982 handle_goal_set, handle_goal_status, handle_goal_suggest, send_external_chat_frame,
25983 try_forward_agent_chat_event, update_chat_goal_from_event, JsonRpcMessage,
25984 };
25985 use crate::session::{ChatSession, ServerState, WsChannel};
25986 use serde_json::{json, Value};
25987 use std::sync::atomic::AtomicBool;
25988 use std::sync::Arc;
25989
25990 fn req(params: Value) -> JsonRpcMessage {
25991 JsonRpcMessage {
25992 jsonrpc: "2.0".into(),
25993 method: None,
25994 params,
25995 id: json!(1),
25996 result: None,
25997 error: None,
25998 }
25999 }
26000
26001 #[test]
26002 fn extract_returns_none_when_absent_or_null() {
26003 assert_eq!(extract_chat_attachments(&json!({"prompt": "hi"})), Ok(None));
26004 assert_eq!(
26005 extract_chat_attachments(&json!({"attachments": null})),
26006 Ok(None)
26007 );
26008 }
26009
26010 #[test]
26011 fn extract_accepts_image_content_blocks() {
26012 let params = json!({
26013 "attachments": [
26014 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
26015 {"type": "image_url", "url": "https://example.com/a.jpg", "detail": "auto"}
26016 ]
26017 });
26018 let got = extract_chat_attachments(¶ms).unwrap().unwrap();
26019 assert_eq!(got.as_array().map(|a| a.len()), Some(2));
26020 }
26021
26022 #[test]
26023 fn extract_rejects_non_array() {
26024 assert!(extract_chat_attachments(&json!({"attachments": "nope"})).is_err());
26025 }
26026
26027 #[test]
26028 fn extract_rejects_non_image_entry() {
26029 let params = json!({
26030 "attachments": [
26031 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
26032 {"type": "text", "text": "not an image"}
26033 ]
26034 });
26035 assert!(extract_chat_attachments(¶ms).is_err());
26036 }
26037
26038 #[test]
26039 fn extract_rejects_base64_missing_data() {
26040 let params = json!({"attachments": [{"type": "image_base64", "media_type": "image/png"}]});
26041 assert!(extract_chat_attachments(¶ms).is_err());
26042 }
26043
26044 #[test]
26045 fn extract_rejects_disallowed_media_type() {
26046 let params = json!({
26047 "attachments": [{"type": "image_base64", "data": "AAAA", "media_type": "image/svg+xml"}]
26048 });
26049 assert!(extract_chat_attachments(¶ms).is_err());
26050 }
26051
26052 #[test]
26053 fn extract_rejects_non_http_image_url() {
26054 let params = json!({"attachments": [{"type": "image_url", "url": "file:///etc/passwd"}]});
26055 assert!(extract_chat_attachments(¶ms).is_err());
26056 }
26057
26058 #[test]
26059 fn build_params_omits_attachments_when_none() {
26060 let params =
26061 build_agent_chat_params("sess-1", "hello", true, "host-1", false, None, None, None);
26062 assert_eq!(params["session_id"], "sess-1");
26063 assert_eq!(params["prompt"], "hello");
26064 assert_eq!(params["context"]["host_client_id"], "host-1");
26065 assert!(
26066 params.get("attachments").is_none(),
26067 "no `attachments` key when there are none — agents that ignore it see the legacy shape"
26068 );
26069 }
26070
26071 #[test]
26072 fn build_params_carries_attachments_when_present() {
26073 let blocks = json!([
26074 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"}
26075 ]);
26076 let params = build_agent_chat_params(
26077 "sess-2",
26078 "describe",
26079 true,
26080 "host-2",
26081 false,
26082 None,
26083 Some(blocks),
26084 None,
26085 );
26086 let att = params["attachments"]
26087 .as_array()
26088 .expect("attachments present");
26089 assert_eq!(att.len(), 1);
26090 assert_eq!(att[0]["type"], "image_base64");
26091 assert_eq!(att[0]["media_type"], "image/png");
26092 }
26093
26094 #[test]
26095 fn extract_goal_returns_none_when_absent_or_null() {
26096 assert_eq!(extract_chat_goal(&json!({"prompt": "hi"})), Ok(None));
26097 assert_eq!(extract_chat_goal(&json!({"goal": null})), Ok(None));
26098 }
26099
26100 #[test]
26101 fn extract_goal_normalizes_check_and_default_iterations() {
26102 let got = extract_chat_goal(&json!({"goal": {"check": " cargo test -q "}}))
26103 .unwrap()
26104 .unwrap();
26105 assert_eq!(got["check"], "cargo test -q");
26106 assert_eq!(got["max_iterations"], 8);
26107 }
26108
26109 #[test]
26110 fn extract_goal_clamps_iteration_budget() {
26111 let low = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 0}}))
26112 .unwrap()
26113 .unwrap();
26114 let high = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 500}}))
26115 .unwrap()
26116 .unwrap();
26117 assert_eq!(low["max_iterations"], 1);
26118 assert_eq!(high["max_iterations"], 50);
26119 }
26120
26121 #[test]
26122 fn extract_goal_rejects_bad_shape() {
26123 assert!(extract_chat_goal(&json!({"goal": "nope"})).is_err());
26124 assert!(extract_chat_goal(&json!({"goal": {"check": ""}})).is_err());
26125 }
26126
26127 #[test]
26128 fn selected_chat_model_is_forwarded_and_unset_preserves_agent_default() {
26129 assert_eq!(
26130 extract_chat_model(&json!({
26131 "model": " openrouter/deepseek/deepseek-v3.2 "
26132 }))
26133 .unwrap()
26134 .as_deref(),
26135 Some("openrouter/deepseek/deepseek-v3.2")
26136 );
26137 assert_eq!(extract_chat_model(&json!({})), Ok(None));
26138 assert_eq!(extract_chat_model(&json!({"model": " "})), Ok(None));
26139 assert!(extract_chat_model(&json!({"model": 42})).is_err());
26140
26141 let selected = build_agent_chat_params(
26142 "sess-model",
26143 "hello",
26144 true,
26145 "host-model",
26146 false,
26147 Some("openrouter/deepseek/deepseek-v3.2".into()),
26148 None,
26149 None,
26150 );
26151 assert_eq!(
26152 selected["model"], "openrouter/deepseek/deepseek-v3.2",
26153 "the actual daemon-to-agent chat request must carry the selected CAR model"
26154 );
26155
26156 let adaptive = build_agent_chat_params(
26157 "sess-adaptive",
26158 "hello",
26159 true,
26160 "host-adaptive",
26161 false,
26162 None,
26163 None,
26164 None,
26165 );
26166 assert!(
26167 adaptive.get("model").is_none(),
26168 "omission preserves the supervised agent or adaptive-router default"
26169 );
26170 }
26171
26172 #[test]
26173 fn build_params_carries_goal_when_present() {
26174 let goal = json!({"check": "cargo test -q", "max_iterations": 4});
26175 let params = build_agent_chat_params(
26176 "sess-3",
26177 "fix tests",
26178 true,
26179 "host-3",
26180 false,
26181 None,
26182 None,
26183 Some(goal),
26184 );
26185 assert_eq!(params["goal"]["check"], "cargo test -q");
26186 assert_eq!(params["goal"]["max_iterations"], 4);
26187 assert!(params.get("attachments").is_none());
26188 }
26189
26190 #[tokio::test]
26191 async fn goal_set_status_and_clear_roundtrip() {
26192 let tmp = tempfile::TempDir::new().unwrap();
26193 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26194
26195 let set = handle_goal_set(
26196 &req(json!({
26197 "session_id": "chat-1",
26198 "goal": {"check": " test -f done ", "max_iterations": 500}
26199 })),
26200 &state,
26201 )
26202 .await
26203 .unwrap();
26204 assert_eq!(set["set"], true);
26205 assert_eq!(set["goal"]["check"], "test -f done");
26206 assert_eq!(set["goal"]["max_iterations"], 50);
26207 assert_eq!(set["goal"]["status"], "active");
26208
26209 let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
26210 .await
26211 .unwrap();
26212 assert_eq!(status["goal"]["session_id"], "chat-1");
26213 assert_eq!(status["goal"]["check"], "test -f done");
26214
26215 let clear = handle_goal_clear(&req(json!({"session_id": "chat-1"})), &state)
26216 .await
26217 .unwrap();
26218 assert_eq!(clear["cleared"], true);
26219 let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
26220 .await
26221 .unwrap();
26222 assert!(status["goal"].is_null());
26223 }
26224
26225 #[tokio::test]
26226 async fn goal_status_tracks_evaluator_and_terminal_events() {
26227 let tmp = tempfile::TempDir::new().unwrap();
26228 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26229 handle_goal_set(
26230 &req(json!({"session_id": "chat-2", "check": "test -f done", "max_iterations": 4})),
26231 &state,
26232 )
26233 .await
26234 .unwrap();
26235
26236 update_chat_goal_from_event(
26237 &state,
26238 "chat-2",
26239 &json!({
26240 "kind": "goal_evaluated",
26241 "iteration": 1,
26242 "met": false,
26243 "grounded": false,
26244 "reason": "file missing"
26245 }),
26246 )
26247 .await;
26248 let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
26249 .await
26250 .unwrap();
26251 assert_eq!(status["goal"]["status"], "running");
26252 assert_eq!(status["goal"]["last_iteration"], 1);
26253 assert_eq!(status["goal"]["last_reason"], "file missing");
26254
26255 update_chat_goal_from_event(
26256 &state,
26257 "chat-2",
26258 &json!({
26259 "kind": "goal_evaluated",
26260 "iteration": 2,
26261 "met": true,
26262 "grounded": true,
26263 "reason": "file exists"
26264 }),
26265 )
26266 .await;
26267 update_chat_goal_from_event(
26268 &state,
26269 "chat-2",
26270 &json!({"kind": "done", "finish_reason": "goal reached"}),
26271 )
26272 .await;
26273 let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
26274 .await
26275 .unwrap();
26276 assert_eq!(status["goal"]["status"], "met");
26277 assert_eq!(status["goal"]["last_met"], true);
26278 assert_eq!(status["goal"]["terminal_kind"], "done");
26279 assert_eq!(status["goal"]["terminal_message"], "goal reached");
26280 }
26281
26282 fn chat_event(session_id: &str, extra: Value) -> JsonRpcMessage {
26285 let mut params = serde_json::Map::new();
26286 params.insert("session_id".into(), session_id.into());
26287 if let Some(obj) = extra.as_object() {
26288 for (k, v) in obj {
26289 params.insert(k.clone(), v.clone());
26290 }
26291 }
26292 JsonRpcMessage {
26293 jsonrpc: "2.0".into(),
26294 method: Some("agent.chat.event".into()),
26295 params: Value::Object(params),
26296 id: Value::Null,
26297 result: None,
26298 error: None,
26299 }
26300 }
26301
26302 async fn routed_goal_session(session_id: &str) -> (tempfile::TempDir, Arc<ServerState>) {
26306 let tmp = tempfile::TempDir::new().unwrap();
26307 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26308 state
26309 .create_session("host-1", Arc::new(WsChannel::test_stub()))
26310 .await
26311 .unwrap();
26312 state.chat_sessions.lock().await.insert(
26313 session_id.to_string(),
26314 ChatSession {
26315 agent_id: "parslee-core".into(),
26316 host_client_id: "host-1".into(),
26317 created_at: 0,
26318 local_cancel: None,
26319 },
26320 );
26321 handle_goal_set(
26322 &req(json!({"session_id": session_id, "check": "test -f done", "max_iterations": 4})),
26323 &state,
26324 )
26325 .await
26326 .unwrap();
26327 (tmp, state)
26328 }
26329
26330 #[tokio::test]
26340 async fn an_auth_required_frame_persists_its_message_through_the_wire_path() {
26341 const MESSAGE: &str = "Parslee Core runs on your Parslee account. Sign in to continue. \
26342 New to Parslee? Create your account at parslee.ai first, then \
26343 come back and sign in.";
26344 let (_tmp, state) = routed_goal_session("wire-auth").await;
26345
26346 try_forward_agent_chat_event(
26347 &chat_event(
26348 "wire-auth",
26349 json!({
26350 "kind": "auth_required",
26351 "reason": "signed_out",
26352 "message": MESSAGE
26353 }),
26354 ),
26355 &state,
26356 )
26357 .await;
26358
26359 let status = handle_goal_status(&req(json!({"session_id": "wire-auth"})), &state)
26360 .await
26361 .unwrap();
26362 assert_eq!(status["goal"]["terminal_kind"], "auth_required");
26363 assert_eq!(
26364 status["goal"]["terminal_message"], MESSAGE,
26365 "the remedy must survive the trip from the agent to the goal record"
26366 );
26367 assert_eq!(status["goal"]["status"], "error");
26368 }
26369
26370 #[tokio::test]
26373 async fn an_error_frame_persists_its_text_through_the_wire_path() {
26374 let (_tmp, state) = routed_goal_session("wire-error").await;
26375
26376 try_forward_agent_chat_event(
26377 &chat_event(
26378 "wire-error",
26379 json!({ "kind": "error", "error": "goal not reached: turn budget exhausted" }),
26380 ),
26381 &state,
26382 )
26383 .await;
26384
26385 let status = handle_goal_status(&req(json!({"session_id": "wire-error"})), &state)
26386 .await
26387 .unwrap();
26388 assert_eq!(status["goal"]["terminal_kind"], "error");
26389 assert_eq!(
26390 status["goal"]["terminal_message"],
26391 "goal not reached: turn budget exhausted"
26392 );
26393 assert_eq!(status["goal"]["status"], "error");
26394 }
26395
26396 #[tokio::test]
26400 async fn a_fail_open_done_is_not_recorded_as_met_through_the_wire_path() {
26401 let (_tmp, state) = routed_goal_session("wire-unevaluated").await;
26402
26403 try_forward_agent_chat_event(
26404 &chat_event(
26405 "wire-unevaluated",
26406 json!({
26407 "kind": "done",
26408 "finish_reason": "goal check unevaluated (timed out)",
26409 "goal_unevaluated": true
26410 }),
26411 ),
26412 &state,
26413 )
26414 .await;
26415
26416 let status = handle_goal_status(&req(json!({"session_id": "wire-unevaluated"})), &state)
26417 .await
26418 .unwrap();
26419 assert_eq!(
26420 status["goal"]["status"], "unevaluated",
26421 "a check that never ran is not a pass"
26422 );
26423 assert_eq!(
26424 status["goal"]["terminal_message"],
26425 "goal check unevaluated (timed out)"
26426 );
26427 }
26428
26429 #[tokio::test]
26439 async fn a_goal_that_ends_on_auth_required_is_recorded_as_terminal() {
26440 let tmp = tempfile::TempDir::new().unwrap();
26441 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26442 handle_goal_set(
26443 &req(json!({"session_id": "chat-auth", "check": "test -f done", "max_iterations": 4})),
26444 &state,
26445 )
26446 .await
26447 .unwrap();
26448
26449 update_chat_goal_from_event(
26453 &state,
26454 "chat-auth",
26455 &json!({
26456 "kind": "auth_required",
26457 "finish_reason": Value::Null,
26458 "error": Value::Null,
26459 "message": "Parslee Core runs on your Parslee account."
26460 }),
26461 )
26462 .await;
26463
26464 let status = handle_goal_status(&req(json!({"session_id": "chat-auth"})), &state)
26465 .await
26466 .unwrap();
26467 assert_eq!(
26468 status["goal"]["terminal_kind"], "auth_required",
26469 "the record must name WHY the run ended"
26470 );
26471 assert_eq!(
26472 status["goal"]["terminal_message"],
26473 "Parslee Core runs on your Parslee account."
26474 );
26475 assert_eq!(
26476 status["goal"]["status"], "error",
26477 "the same status an `error` terminal yields — not `running`, and \
26478 never `met`"
26479 );
26480 }
26481
26482 #[tokio::test]
26490 async fn a_fail_open_done_is_recorded_as_unevaluated_not_met() {
26491 let tmp = tempfile::TempDir::new().unwrap();
26492 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26493 handle_goal_set(
26494 &req(json!({"session_id": "chat-timeout", "check": "test -f done"})),
26495 &state,
26496 )
26497 .await
26498 .unwrap();
26499
26500 update_chat_goal_from_event(
26505 &state,
26506 "chat-timeout",
26507 &json!({
26508 "kind": "done",
26509 "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
26510 "goal_unevaluated": true,
26511 }),
26512 )
26513 .await;
26514
26515 let status = handle_goal_status(&req(json!({"session_id": "chat-timeout"})), &state)
26516 .await
26517 .unwrap();
26518 assert_eq!(
26519 status["goal"]["status"], "unevaluated",
26520 "a goal check that never ran must not be recorded as met: {status}"
26521 );
26522 assert_eq!(status["goal"]["terminal_kind"], "done");
26523 assert_eq!(
26524 status["goal"]["terminal_message"],
26525 "goal check unevaluated (timed out) — reply delivered unverified"
26526 );
26527
26528 handle_goal_set(
26531 &req(json!({"session_id": "chat-achieved", "check": "test -f done"})),
26532 &state,
26533 )
26534 .await
26535 .unwrap();
26536 update_chat_goal_from_event(
26537 &state,
26538 "chat-achieved",
26539 &json!({"kind": "done", "finish_reason": "goal reached"}),
26540 )
26541 .await;
26542 let status = handle_goal_status(&req(json!({"session_id": "chat-achieved"})), &state)
26543 .await
26544 .unwrap();
26545 assert_eq!(status["goal"]["status"], "met");
26546 }
26547
26548 #[tokio::test]
26549 async fn inline_goal_creates_durable_running_status() {
26550 let tmp = tempfile::TempDir::new().unwrap();
26551 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26552
26553 ensure_chat_goal_running(
26554 &state,
26555 "chat-inline",
26556 &json!({"check": "test -f done", "max_iterations": 99}),
26557 )
26558 .await
26559 .unwrap();
26560
26561 let status = handle_goal_status(&req(json!({"session_id": "chat-inline"})), &state)
26562 .await
26563 .unwrap();
26564 assert_eq!(status["goal"]["session_id"], "chat-inline");
26565 assert_eq!(status["goal"]["check"], "test -f done");
26566 assert_eq!(status["goal"]["max_iterations"], 50);
26567 assert_eq!(status["goal"]["status"], "running");
26568 }
26569
26570 #[tokio::test]
26571 async fn rerun_goal_clears_prior_terminal_status() {
26572 let tmp = tempfile::TempDir::new().unwrap();
26573 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26574 handle_goal_set(
26575 &req(json!({"session_id": "chat-rerun", "check": "test -f done"})),
26576 &state,
26577 )
26578 .await
26579 .unwrap();
26580 update_chat_goal_from_event(
26581 &state,
26582 "chat-rerun",
26583 &json!({"kind": "error", "error": "cancelled"}),
26584 )
26585 .await;
26586
26587 ensure_chat_goal_running(
26588 &state,
26589 "chat-rerun",
26590 &json!({"check": "test -f done", "max_iterations": 4}),
26591 )
26592 .await
26593 .unwrap();
26594
26595 let status = handle_goal_status(&req(json!({"session_id": "chat-rerun"})), &state)
26596 .await
26597 .unwrap();
26598 assert_eq!(status["goal"]["status"], "running");
26599 assert!(status["goal"]["terminal_kind"].is_null());
26600 assert!(status["goal"]["terminal_message"].is_null());
26601 }
26602
26603 #[tokio::test]
26604 async fn daemon_owned_chat_frames_update_goal_status_before_host_delivery() {
26605 let tmp = tempfile::TempDir::new().unwrap();
26606 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26607 handle_goal_set(
26608 &req(json!({"session_id": "chat-local", "check": "test -f done"})),
26609 &state,
26610 )
26611 .await
26612 .unwrap();
26613
26614 send_external_chat_frame(
26618 &state,
26619 "chat-local",
26620 json!({
26621 "session_id": "chat-local",
26622 "agent_id": "local",
26623 "kind": "goal_evaluated",
26624 "iteration": 1,
26625 "met": true,
26626 "grounded": true,
26627 "reason": "file exists"
26628 }),
26629 )
26630 .await;
26631
26632 let status = handle_goal_status(&req(json!({"session_id": "chat-local"})), &state)
26633 .await
26634 .unwrap();
26635 assert_eq!(status["goal"]["status"], "met");
26636 assert_eq!(status["goal"]["last_iteration"], 1);
26637 assert_eq!(status["goal"]["last_reason"], "file exists");
26638 }
26639
26640 #[tokio::test]
26641 async fn cancelling_goal_chat_marks_standing_goal_terminal() {
26642 let tmp = tempfile::TempDir::new().unwrap();
26643 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26644 handle_goal_set(
26645 &req(json!({"session_id": "chat-cancel", "check": "test -f done"})),
26646 &state,
26647 )
26648 .await
26649 .unwrap();
26650 update_chat_goal_from_event(
26651 &state,
26652 "chat-cancel",
26653 &json!({"kind": "goal_evaluated", "iteration": 1, "met": false, "grounded": false, "reason": "not yet"}),
26654 )
26655 .await;
26656 let cancel_flag = Arc::new(AtomicBool::new(false));
26657 state.chat_sessions.lock().await.insert(
26658 "chat-cancel".to_string(),
26659 ChatSession {
26660 agent_id: "local".to_string(),
26661 host_client_id: "host".to_string(),
26662 created_at: 0,
26663 local_cancel: Some(cancel_flag.clone()),
26664 },
26665 );
26666
26667 let stolen = handle_agents_chat_cancel(
26672 &req(json!({"session_id": "chat-cancel"})),
26673 &state,
26674 "someone-else",
26675 false,
26676 )
26677 .await;
26678 assert!(
26679 stolen.is_err(),
26680 "a foreign session must not cancel this turn: {stolen:?}"
26681 );
26682 assert!(
26683 state.chat_sessions.lock().await.contains_key("chat-cancel"),
26684 "a refused cancel must not destroy the routing entry on its way out"
26685 );
26686 assert!(
26687 !cancel_flag.load(std::sync::atomic::Ordering::SeqCst),
26688 "and must not have signalled cancellation"
26689 );
26690
26691 let cancel = handle_agents_chat_cancel(
26692 &req(json!({"session_id": "chat-cancel"})),
26693 &state,
26694 "host",
26695 true,
26696 )
26697 .await
26698 .unwrap();
26699 assert_eq!(cancel["cancelled"], true);
26700 let status = handle_goal_status(&req(json!({"session_id": "chat-cancel"})), &state)
26701 .await
26702 .unwrap();
26703 assert_eq!(status["goal"]["status"], "error");
26704 assert_eq!(status["goal"]["terminal_kind"], "error");
26705 assert_eq!(status["goal"]["terminal_message"], "cancelled");
26706 assert!(cancel_flag.load(std::sync::atomic::Ordering::SeqCst));
26707 }
26708
26709 #[tokio::test]
26710 async fn goal_status_persists_across_server_state_restart() {
26711 let tmp = tempfile::TempDir::new().unwrap();
26712 let journal_dir = tmp.path().join(".car").join("journals");
26713 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26714
26715 handle_goal_set(
26716 &req(json!({"session_id": "chat-durable", "check": "test -f done"})),
26717 &state,
26718 )
26719 .await
26720 .unwrap();
26721 update_chat_goal_from_event(
26722 &state,
26723 "chat-durable",
26724 &json!({
26725 "kind": "goal_evaluated",
26726 "iteration": 1,
26727 "met": false,
26728 "grounded": false,
26729 "reason": "not yet"
26730 }),
26731 )
26732 .await;
26733
26734 let restarted = Arc::new(ServerState::standalone(journal_dir.clone()));
26735 let status = handle_goal_status(&req(json!({"session_id": "chat-durable"})), &restarted)
26736 .await
26737 .unwrap();
26738 assert_eq!(status["goal"]["check"], "test -f done");
26739 assert_eq!(status["goal"]["status"], "active");
26740 assert_eq!(status["goal"]["last_iteration"], 1);
26741 assert_eq!(status["goal"]["last_reason"], "not yet");
26742
26743 update_chat_goal_from_event(
26744 &restarted,
26745 "chat-durable",
26746 &json!({
26747 "kind": "goal_evaluated",
26748 "iteration": 2,
26749 "met": true,
26750 "grounded": true,
26751 "reason": "done exists"
26752 }),
26753 )
26754 .await;
26755 update_chat_goal_from_event(
26756 &restarted,
26757 "chat-durable",
26758 &json!({"kind": "done", "finish_reason": "goal reached"}),
26759 )
26760 .await;
26761
26762 let restarted_again = Arc::new(ServerState::standalone(journal_dir));
26763 let status = handle_goal_status(
26764 &req(json!({"session_id": "chat-durable"})),
26765 &restarted_again,
26766 )
26767 .await
26768 .unwrap();
26769 assert_eq!(status["goal"]["status"], "met");
26770 assert_eq!(status["goal"]["last_iteration"], 2);
26771 assert_eq!(status["goal"]["terminal_kind"], "done");
26772 }
26773
26774 #[tokio::test]
26775 async fn restart_downgrades_stale_running_goal_to_active() {
26776 let tmp = tempfile::TempDir::new().unwrap();
26777 let journal_dir = tmp.path().join(".car").join("journals");
26778 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26779
26780 handle_goal_set(
26781 &req(json!({"session_id": "chat-running", "check": "true"})),
26782 &state,
26783 )
26784 .await
26785 .unwrap();
26786 {
26787 let mut goals = state.chat_goals.lock().await;
26788 goals.get_mut("chat-running").unwrap().status = "running".to_string();
26789 }
26790 state.persist_chat_goals().await.unwrap();
26791
26792 let restarted = Arc::new(ServerState::standalone(journal_dir));
26793 let status = handle_goal_status(&req(json!({"session_id": "chat-running"})), &restarted)
26794 .await
26795 .unwrap();
26796 assert_eq!(status["goal"]["status"], "active");
26797 }
26798
26799 #[tokio::test]
26800 async fn goal_suggest_detects_cargo_project() {
26801 let tmp = tempfile::TempDir::new().unwrap();
26802 std::fs::write(
26803 tmp.path().join("Cargo.toml"),
26804 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26805 )
26806 .unwrap();
26807 let state = Arc::new(ServerState::standalone(
26808 tmp.path().join(".car").join("journals"),
26809 ));
26810
26811 let got = handle_goal_suggest(
26812 &req(json!({
26813 "prompt": "make the tests pass",
26814 "working_dir": tmp.path().to_string_lossy()
26815 })),
26816 &state,
26817 )
26818 .await
26819 .unwrap();
26820 assert_eq!(got["suggested"], true);
26821 assert_eq!(got["confidence"], "high");
26822 assert!(got["goal"]["check"]
26823 .as_str()
26824 .unwrap()
26825 .ends_with(" && cargo test -q"));
26826 }
26827
26828 #[tokio::test]
26829 async fn goal_suggest_prefers_project_check_over_existing_path_mentions() {
26830 let tmp = tempfile::TempDir::new().unwrap();
26831 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26832 std::fs::write(
26833 tmp.path().join("Cargo.toml"),
26834 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26835 )
26836 .unwrap();
26837 std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
26838 let state = Arc::new(ServerState::standalone(
26839 tmp.path().join(".car").join("journals"),
26840 ));
26841
26842 let got = handle_goal_suggest(
26843 &req(json!({
26844 "prompt": "fix src/lib.rs",
26845 "working_dir": tmp.path().to_string_lossy()
26846 })),
26847 &state,
26848 )
26849 .await
26850 .unwrap();
26851 assert!(got["goal"]["check"]
26852 .as_str()
26853 .unwrap()
26854 .ends_with(" && cargo test -q"));
26855 }
26856
26857 #[tokio::test]
26858 async fn goal_suggest_combines_created_file_with_project_check() {
26859 let tmp = tempfile::TempDir::new().unwrap();
26860 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26861 std::fs::write(
26862 tmp.path().join("Cargo.toml"),
26863 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26864 )
26865 .unwrap();
26866 let state = Arc::new(ServerState::standalone(
26867 tmp.path().join(".car").join("journals"),
26868 ));
26869
26870 let got = handle_goal_suggest(
26871 &req(json!({
26872 "prompt": "create src/lib.rs",
26873 "working_dir": tmp.path().to_string_lossy()
26874 })),
26875 &state,
26876 )
26877 .await
26878 .unwrap();
26879 assert_eq!(got["suggested"], true);
26880 assert_eq!(got["confidence"], "high");
26881 let check = got["goal"]["check"].as_str().unwrap();
26882 assert!(check.contains("test -f 'src/lib.rs'"), "{check}");
26883 assert!(check.ends_with(" && cargo test -q"), "{check}");
26884 let signals = got["signals"].as_array().unwrap();
26885 assert!(signals.contains(&json!("prompt_mentions_path:src/lib.rs")));
26886 assert!(signals.contains(&json!("Cargo.toml")));
26887 }
26888
26889 #[tokio::test]
26890 async fn goal_suggest_chooses_package_script_from_prompt() {
26891 let tmp = tempfile::TempDir::new().unwrap();
26892 std::fs::write(
26893 tmp.path().join("package.json"),
26894 r#"{"scripts":{"test":"vitest run","typecheck":"tsc --noEmit","check":"biome check","build":"vite build","lint":"eslint ."}}"#,
26895 )
26896 .unwrap();
26897 std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26898 let state = Arc::new(ServerState::standalone(
26899 tmp.path().join(".car").join("journals"),
26900 ));
26901
26902 let got = handle_goal_suggest(
26903 &req(json!({
26904 "prompt": "fix the TypeScript errors",
26905 "working_dir": tmp.path().to_string_lossy()
26906 })),
26907 &state,
26908 )
26909 .await
26910 .unwrap();
26911 assert_eq!(got["suggested"], true);
26912 assert!(got["goal"]["check"]
26913 .as_str()
26914 .unwrap()
26915 .ends_with(" && pnpm typecheck"));
26916 }
26917
26918 #[tokio::test]
26919 async fn goal_suggest_chooses_check_script_from_prompt() {
26920 let tmp = tempfile::TempDir::new().unwrap();
26921 std::fs::write(
26922 tmp.path().join("package.json"),
26923 r#"{"scripts":{"test":"vitest run","check":"biome check","build":"vite build"}}"#,
26924 )
26925 .unwrap();
26926 std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26927 let state = Arc::new(ServerState::standalone(
26928 tmp.path().join(".car").join("journals"),
26929 ));
26930
26931 let got = handle_goal_suggest(
26932 &req(json!({
26933 "prompt": "fix the CI check failures",
26934 "working_dir": tmp.path().to_string_lossy()
26935 })),
26936 &state,
26937 )
26938 .await
26939 .unwrap();
26940 assert_eq!(got["suggested"], true);
26941 assert!(got["goal"]["check"]
26942 .as_str()
26943 .unwrap()
26944 .ends_with(" && pnpm check"));
26945 let signals = got["signals"].as_array().unwrap();
26946 assert!(signals.contains(&json!("package_script:check")));
26947 }
26948
26949 #[tokio::test]
26950 async fn goal_suggest_chooses_build_script_from_nested_directory_prompt() {
26951 let tmp = tempfile::TempDir::new().unwrap();
26952 std::fs::create_dir_all(tmp.path().join("apps/web")).unwrap();
26953 std::fs::write(
26954 tmp.path().join("apps/web/package.json"),
26955 r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"eslint ."}}"#,
26956 )
26957 .unwrap();
26958 let state = Arc::new(ServerState::standalone(
26959 tmp.path().join(".car").join("journals"),
26960 ));
26961
26962 let got = handle_goal_suggest(
26963 &req(json!({
26964 "prompt": "fix the production build in apps/web",
26965 "working_dir": tmp.path().to_string_lossy()
26966 })),
26967 &state,
26968 )
26969 .await
26970 .unwrap();
26971 assert_eq!(got["suggested"], true);
26972 let check = got["goal"]["check"].as_str().unwrap();
26973 assert!(check.contains("apps/web"), "{check}");
26974 assert!(check.ends_with(" && npm run build"), "{check}");
26975 let signals = got["signals"].as_array().unwrap();
26976 assert!(signals.contains(&json!("package_script:build")));
26977 }
26978
26979 #[tokio::test]
26980 async fn goal_suggest_chooses_lint_script_from_prompt() {
26981 let tmp = tempfile::TempDir::new().unwrap();
26982 std::fs::write(
26983 tmp.path().join("package.json"),
26984 r#"{"scripts":{"test":"vitest run","lint":"eslint .","build":"vite build"}}"#,
26985 )
26986 .unwrap();
26987 let state = Arc::new(ServerState::standalone(
26988 tmp.path().join(".car").join("journals"),
26989 ));
26990
26991 let got = handle_goal_suggest(
26992 &req(json!({
26993 "prompt": "fix lint errors",
26994 "working_dir": tmp.path().to_string_lossy()
26995 })),
26996 &state,
26997 )
26998 .await
26999 .unwrap();
27000 assert_eq!(got["suggested"], true);
27001 assert!(got["goal"]["check"]
27002 .as_str()
27003 .unwrap()
27004 .ends_with(" && npm run lint"));
27005 let signals = got["signals"].as_array().unwrap();
27006 assert!(signals.contains(&json!("package_script:lint")));
27007 }
27008
27009 #[tokio::test]
27010 async fn goal_suggest_uses_nearest_nested_project_for_created_file() {
27011 let tmp = tempfile::TempDir::new().unwrap();
27012 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27013 std::fs::write(
27014 tmp.path().join("apps/web/package.json"),
27015 r#"{"scripts":{"test":"vitest run"}}"#,
27016 )
27017 .unwrap();
27018 let state = Arc::new(ServerState::standalone(
27019 tmp.path().join(".car").join("journals"),
27020 ));
27021
27022 let got = handle_goal_suggest(
27023 &req(json!({
27024 "prompt": "create apps/web/src/App.ts",
27025 "working_dir": tmp.path().to_string_lossy()
27026 })),
27027 &state,
27028 )
27029 .await
27030 .unwrap();
27031 assert_eq!(got["suggested"], true);
27032 assert_eq!(got["confidence"], "high");
27033 let check = got["goal"]["check"].as_str().unwrap();
27034 assert!(check.contains("apps/web"), "{check}");
27035 assert!(check.contains("test -f 'src/App.ts'"), "{check}");
27036 assert!(check.ends_with(" && npm test"), "{check}");
27037 let signals = got["signals"].as_array().unwrap();
27038 assert!(signals.contains(&json!("project_dir:apps/web")));
27039 assert!(signals.contains(&json!("package.json")));
27040 }
27041
27042 #[tokio::test]
27043 async fn goal_suggest_uses_nearest_nested_project_for_existing_file_prompt() {
27044 let tmp = tempfile::TempDir::new().unwrap();
27045 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27046 std::fs::write(
27047 tmp.path().join("apps/web/package.json"),
27048 r#"{"scripts":{"typecheck":"tsc --noEmit","test":"vitest run"}}"#,
27049 )
27050 .unwrap();
27051 std::fs::write(tmp.path().join("apps/web/src/App.ts"), "export {}\n").unwrap();
27052 let state = Arc::new(ServerState::standalone(
27053 tmp.path().join(".car").join("journals"),
27054 ));
27055
27056 let got = handle_goal_suggest(
27057 &req(json!({
27058 "prompt": "fix TypeScript errors in apps/web/src/App.ts",
27059 "working_dir": tmp.path().to_string_lossy()
27060 })),
27061 &state,
27062 )
27063 .await
27064 .unwrap();
27065 assert_eq!(got["suggested"], true);
27066 assert_eq!(got["confidence"], "high");
27067 let check = got["goal"]["check"].as_str().unwrap();
27068 assert!(check.contains("apps/web"), "{check}");
27069 assert!(!check.contains("test -f"), "{check}");
27070 assert!(check.ends_with(" && npm run typecheck"), "{check}");
27071 let signals = got["signals"].as_array().unwrap();
27072 assert!(signals.contains(&json!("prompt_mentions_path:apps/web/src/App.ts")));
27073 assert!(signals.contains(&json!("project_dir:apps/web")));
27074 assert!(signals.contains(&json!("package_script:typecheck")));
27075 }
27076
27077 #[tokio::test]
27078 async fn goal_suggest_uses_nearest_nested_project_for_directory_prompt() {
27079 let tmp = tempfile::TempDir::new().unwrap();
27080 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27081 std::fs::write(
27082 tmp.path().join("apps/web/package.json"),
27083 r#"{"scripts":{"test":"vitest run"}}"#,
27084 )
27085 .unwrap();
27086 let state = Arc::new(ServerState::standalone(
27087 tmp.path().join(".car").join("journals"),
27088 ));
27089
27090 let got = handle_goal_suggest(
27091 &req(json!({
27092 "prompt": "fix apps/web",
27093 "working_dir": tmp.path().to_string_lossy()
27094 })),
27095 &state,
27096 )
27097 .await
27098 .unwrap();
27099 assert_eq!(got["suggested"], true);
27100 assert_eq!(got["confidence"], "high");
27101 let check = got["goal"]["check"].as_str().unwrap();
27102 assert!(check.contains("apps/web"), "{check}");
27103 assert!(!check.contains("test -f"), "{check}");
27104 assert!(check.ends_with(" && npm test"), "{check}");
27105 let signals = got["signals"].as_array().unwrap();
27106 assert!(signals.contains(&json!("prompt_mentions_path:apps/web")));
27107 assert!(signals.contains(&json!("project_dir:apps/web")));
27108 }
27109
27110 #[tokio::test]
27111 async fn goal_suggest_can_store_the_suggested_goal() {
27112 let tmp = tempfile::TempDir::new().unwrap();
27113 std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
27114 let journal_dir = tmp.path().join(".car").join("journals");
27115 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
27116
27117 let got = handle_goal_suggest(
27118 &req(json!({
27119 "prompt": "make it work",
27120 "working_dir": tmp.path().to_string_lossy(),
27121 "session_id": "chat-suggest",
27122 "set": true
27123 })),
27124 &state,
27125 )
27126 .await
27127 .unwrap();
27128 assert_eq!(got["set"], true);
27129 assert_eq!(got["stored_goal"]["session_id"], "chat-suggest");
27130
27131 let restarted = Arc::new(ServerState::standalone(journal_dir));
27132 let status = handle_goal_status(&req(json!({"session_id": "chat-suggest"})), &restarted)
27133 .await
27134 .unwrap();
27135 assert!(status["goal"]["check"]
27136 .as_str()
27137 .unwrap()
27138 .ends_with(" && go test ./..."));
27139 }
27140
27141 #[tokio::test]
27142 async fn goal_suggest_detects_swift_package() {
27143 let tmp = tempfile::TempDir::new().unwrap();
27144 std::fs::write(
27145 tmp.path().join("Package.swift"),
27146 "// swift-tools-version: 6.0\nimport PackageDescription\n",
27147 )
27148 .unwrap();
27149 let state = Arc::new(ServerState::standalone(
27150 tmp.path().join(".car").join("journals"),
27151 ));
27152
27153 let got = handle_goal_suggest(
27154 &req(json!({
27155 "prompt": "make the package tests pass",
27156 "working_dir": tmp.path().to_string_lossy()
27157 })),
27158 &state,
27159 )
27160 .await
27161 .unwrap();
27162 assert_eq!(got["suggested"], true);
27163 assert_eq!(got["confidence"], "high");
27164 assert!(got["goal"]["check"]
27165 .as_str()
27166 .unwrap()
27167 .ends_with(" && swift test"));
27168 let signals = got["signals"].as_array().unwrap();
27169 assert!(signals.contains(&json!("Package.swift")));
27170 }
27171
27172 #[tokio::test]
27173 async fn goal_suggest_combines_created_swift_file_with_package_check() {
27174 let tmp = tempfile::TempDir::new().unwrap();
27175 std::fs::create_dir_all(tmp.path().join("Sources/App")).unwrap();
27176 std::fs::write(
27177 tmp.path().join("Package.swift"),
27178 "// swift-tools-version: 6.0\nimport PackageDescription\n",
27179 )
27180 .unwrap();
27181 let state = Arc::new(ServerState::standalone(
27182 tmp.path().join(".car").join("journals"),
27183 ));
27184
27185 let got = handle_goal_suggest(
27186 &req(json!({
27187 "prompt": "create Sources/App/Main.swift",
27188 "working_dir": tmp.path().to_string_lossy()
27189 })),
27190 &state,
27191 )
27192 .await
27193 .unwrap();
27194 assert_eq!(got["suggested"], true);
27195 let check = got["goal"]["check"].as_str().unwrap();
27196 assert!(
27197 check.contains("test -f 'Sources/App/Main.swift'"),
27198 "{check}"
27199 );
27200 assert!(check.ends_with(" && swift test"), "{check}");
27201 }
27202
27203 #[tokio::test]
27204 async fn goal_suggest_detects_cmake_project() {
27205 let tmp = tempfile::TempDir::new().unwrap();
27206 std::fs::write(
27207 tmp.path().join("CMakeLists.txt"),
27208 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27209 )
27210 .unwrap();
27211 let state = Arc::new(ServerState::standalone(
27212 tmp.path().join(".car").join("journals"),
27213 ));
27214
27215 let got = handle_goal_suggest(
27216 &req(json!({
27217 "prompt": "fix the C++ build",
27218 "working_dir": tmp.path().to_string_lossy()
27219 })),
27220 &state,
27221 )
27222 .await
27223 .unwrap();
27224 assert_eq!(got["suggested"], true);
27225 assert_eq!(got["confidence"], "high");
27226 assert!(got["goal"]["check"]
27227 .as_str()
27228 .unwrap()
27229 .ends_with(" && cmake -S . -B build && cmake --build build"));
27230 let signals = got["signals"].as_array().unwrap();
27231 assert!(signals.contains(&json!("CMakeLists.txt")));
27232 }
27233
27234 #[tokio::test]
27235 async fn goal_suggest_combines_created_cpp_file_with_cmake_check() {
27236 let tmp = tempfile::TempDir::new().unwrap();
27237 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
27238 std::fs::write(
27239 tmp.path().join("CMakeLists.txt"),
27240 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27241 )
27242 .unwrap();
27243 let state = Arc::new(ServerState::standalone(
27244 tmp.path().join(".car").join("journals"),
27245 ));
27246
27247 let got = handle_goal_suggest(
27248 &req(json!({
27249 "prompt": "create src/widget.cpp",
27250 "working_dir": tmp.path().to_string_lossy()
27251 })),
27252 &state,
27253 )
27254 .await
27255 .unwrap();
27256 assert_eq!(got["suggested"], true);
27257 assert_eq!(got["confidence"], "high");
27258 let check = got["goal"]["check"].as_str().unwrap();
27259 assert!(check.contains("test -f 'src/widget.cpp'"), "{check}");
27260 assert!(
27261 check.ends_with(" && cmake -S . -B build && cmake --build build"),
27262 "{check}"
27263 );
27264 let signals = got["signals"].as_array().unwrap();
27265 assert!(signals.contains(&json!("prompt_mentions_path:src/widget.cpp")));
27266 assert!(signals.contains(&json!("CMakeLists.txt")));
27267 }
27268
27269 #[tokio::test]
27270 async fn goal_suggest_uses_ctest_for_cmake_test_prompts() {
27271 let tmp = tempfile::TempDir::new().unwrap();
27272 std::fs::write(
27273 tmp.path().join("CMakeLists.txt"),
27274 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27275 )
27276 .unwrap();
27277 let state = Arc::new(ServerState::standalone(
27278 tmp.path().join(".car").join("journals"),
27279 ));
27280
27281 let got = handle_goal_suggest(
27282 &req(json!({
27283 "prompt": "make the cmake unit tests pass",
27284 "working_dir": tmp.path().to_string_lossy()
27285 })),
27286 &state,
27287 )
27288 .await
27289 .unwrap();
27290 assert_eq!(got["suggested"], true);
27291 let check = got["goal"]["check"].as_str().unwrap();
27292 assert!(check.ends_with(
27293 " && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure"
27294 ), "{check}");
27295 }
27296
27297 #[tokio::test]
27298 async fn goal_suggest_detects_gradle_project() {
27299 let tmp = tempfile::TempDir::new().unwrap();
27300 std::fs::write(
27301 tmp.path().join("build.gradle.kts"),
27302 "plugins { kotlin(\"jvm\") }\n",
27303 )
27304 .unwrap();
27305 std::fs::write(tmp.path().join("gradlew"), "#!/bin/sh\n").unwrap();
27306 let state = Arc::new(ServerState::standalone(
27307 tmp.path().join(".car").join("journals"),
27308 ));
27309
27310 let got = handle_goal_suggest(
27311 &req(json!({
27312 "prompt": "make the kotlin tests pass",
27313 "working_dir": tmp.path().to_string_lossy()
27314 })),
27315 &state,
27316 )
27317 .await
27318 .unwrap();
27319 assert_eq!(got["suggested"], true);
27320 assert_eq!(got["confidence"], "high");
27321 assert!(got["goal"]["check"]
27322 .as_str()
27323 .unwrap()
27324 .ends_with(" && ./gradlew test"));
27325 let signals = got["signals"].as_array().unwrap();
27326 assert!(signals.contains(&json!("build.gradle.kts")));
27327 assert!(signals.contains(&json!("gradlew")));
27328 }
27329
27330 #[tokio::test]
27331 async fn goal_suggest_combines_created_kotlin_file_with_gradle_check() {
27332 let tmp = tempfile::TempDir::new().unwrap();
27333 std::fs::create_dir_all(tmp.path().join("app/src/main/java/com/demo")).unwrap();
27334 std::fs::write(tmp.path().join("build.gradle"), "plugins { id 'java' }\n").unwrap();
27335 let state = Arc::new(ServerState::standalone(
27336 tmp.path().join(".car").join("journals"),
27337 ));
27338
27339 let got = handle_goal_suggest(
27340 &req(json!({
27341 "prompt": "create app/src/main/java/com/demo/Main.kt",
27342 "working_dir": tmp.path().to_string_lossy()
27343 })),
27344 &state,
27345 )
27346 .await
27347 .unwrap();
27348 assert_eq!(got["suggested"], true);
27349 assert_eq!(got["confidence"], "high");
27350 let check = got["goal"]["check"].as_str().unwrap();
27351 assert!(
27352 check.contains("test -f 'app/src/main/java/com/demo/Main.kt'"),
27353 "{check}"
27354 );
27355 assert!(check.ends_with(" && gradle test"), "{check}");
27356 }
27357
27358 #[tokio::test]
27359 async fn goal_suggest_detects_maven_project() {
27360 let tmp = tempfile::TempDir::new().unwrap();
27361 std::fs::write(
27362 tmp.path().join("pom.xml"),
27363 "<project><modelVersion>4.0.0</modelVersion></project>\n",
27364 )
27365 .unwrap();
27366 std::fs::write(tmp.path().join("mvnw"), "#!/bin/sh\n").unwrap();
27367 let state = Arc::new(ServerState::standalone(
27368 tmp.path().join(".car").join("journals"),
27369 ));
27370
27371 let got = handle_goal_suggest(
27372 &req(json!({
27373 "prompt": "make the java tests pass",
27374 "working_dir": tmp.path().to_string_lossy()
27375 })),
27376 &state,
27377 )
27378 .await
27379 .unwrap();
27380 assert_eq!(got["suggested"], true);
27381 assert_eq!(got["confidence"], "high");
27382 assert!(got["goal"]["check"]
27383 .as_str()
27384 .unwrap()
27385 .ends_with(" && ./mvnw test"));
27386 let signals = got["signals"].as_array().unwrap();
27387 assert!(signals.contains(&json!("pom.xml")));
27388 assert!(signals.contains(&json!("mvnw")));
27389 }
27390
27391 #[tokio::test]
27392 async fn goal_suggest_combines_created_java_file_with_maven_check() {
27393 let tmp = tempfile::TempDir::new().unwrap();
27394 std::fs::create_dir_all(tmp.path().join("src/main/java/com/demo")).unwrap();
27395 std::fs::write(
27396 tmp.path().join("pom.xml"),
27397 "<project><modelVersion>4.0.0</modelVersion></project>\n",
27398 )
27399 .unwrap();
27400 let state = Arc::new(ServerState::standalone(
27401 tmp.path().join(".car").join("journals"),
27402 ));
27403
27404 let got = handle_goal_suggest(
27405 &req(json!({
27406 "prompt": "create src/main/java/com/demo/Main.java",
27407 "working_dir": tmp.path().to_string_lossy()
27408 })),
27409 &state,
27410 )
27411 .await
27412 .unwrap();
27413 assert_eq!(got["suggested"], true);
27414 assert_eq!(got["confidence"], "high");
27415 let check = got["goal"]["check"].as_str().unwrap();
27416 assert!(
27417 check.contains("test -f 'src/main/java/com/demo/Main.java'"),
27418 "{check}"
27419 );
27420 assert!(check.ends_with(" && mvn test"), "{check}");
27421 }
27422
27423 #[tokio::test]
27424 async fn goal_suggest_detects_dotnet_solution() {
27425 let tmp = tempfile::TempDir::new().unwrap();
27426 std::fs::write(tmp.path().join("Demo.sln"), "").unwrap();
27427 let state = Arc::new(ServerState::standalone(
27428 tmp.path().join(".car").join("journals"),
27429 ));
27430
27431 let got = handle_goal_suggest(
27432 &req(json!({
27433 "prompt": "make the csharp tests pass",
27434 "working_dir": tmp.path().to_string_lossy()
27435 })),
27436 &state,
27437 )
27438 .await
27439 .unwrap();
27440 assert_eq!(got["suggested"], true);
27441 assert_eq!(got["confidence"], "high");
27442 assert!(got["goal"]["check"]
27443 .as_str()
27444 .unwrap()
27445 .ends_with(" && dotnet test"));
27446 let signals = got["signals"].as_array().unwrap();
27447 assert!(signals.contains(&json!("Demo.sln")));
27448 }
27449
27450 #[tokio::test]
27451 async fn goal_suggest_combines_created_csharp_file_with_dotnet_check() {
27452 let tmp = tempfile::TempDir::new().unwrap();
27453 std::fs::create_dir_all(tmp.path().join("src/Demo")).unwrap();
27454 std::fs::write(
27455 tmp.path().join("src").join("Demo").join("Demo.csproj"),
27456 "<Project Sdk=\"Microsoft.NET.Sdk\" />\n",
27457 )
27458 .unwrap();
27459 let state = Arc::new(ServerState::standalone(
27460 tmp.path().join(".car").join("journals"),
27461 ));
27462
27463 let got = handle_goal_suggest(
27464 &req(json!({
27465 "prompt": "create Program.cs",
27466 "working_dir": tmp.path().join("src").join("Demo").to_string_lossy()
27467 })),
27468 &state,
27469 )
27470 .await
27471 .unwrap();
27472 assert_eq!(got["suggested"], true);
27473 assert_eq!(got["confidence"], "high");
27474 let check = got["goal"]["check"].as_str().unwrap();
27475 assert!(check.contains("test -f 'Program.cs'"), "{check}");
27476 assert!(check.ends_with(" && dotnet test"), "{check}");
27477 }
27478
27479 #[tokio::test]
27480 async fn goal_suggest_detects_php_composer_project() {
27481 let tmp = tempfile::TempDir::new().unwrap();
27482 std::fs::create_dir_all(tmp.path().join("vendor/bin")).unwrap();
27483 std::fs::write(tmp.path().join("composer.json"), r#"{"require-dev":{}}"#).unwrap();
27484 std::fs::write(
27485 tmp.path().join("vendor/bin/phpunit"),
27486 "#!/usr/bin/env php\n",
27487 )
27488 .unwrap();
27489 let state = Arc::new(ServerState::standalone(
27490 tmp.path().join(".car").join("journals"),
27491 ));
27492
27493 let got = handle_goal_suggest(
27494 &req(json!({
27495 "prompt": "make the php tests pass",
27496 "working_dir": tmp.path().to_string_lossy()
27497 })),
27498 &state,
27499 )
27500 .await
27501 .unwrap();
27502 assert_eq!(got["suggested"], true);
27503 assert_eq!(got["confidence"], "high");
27504 assert!(got["goal"]["check"]
27505 .as_str()
27506 .unwrap()
27507 .ends_with(" && vendor/bin/phpunit"));
27508 let signals = got["signals"].as_array().unwrap();
27509 assert!(signals.contains(&json!("composer.json")));
27510 assert!(signals.contains(&json!("phpunit")));
27511 }
27512
27513 #[tokio::test]
27514 async fn goal_suggest_combines_created_php_file_with_composer_script() {
27515 let tmp = tempfile::TempDir::new().unwrap();
27516 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
27517 std::fs::write(
27518 tmp.path().join("composer.json"),
27519 r#"{"scripts":{"test":"phpunit"}}"#,
27520 )
27521 .unwrap();
27522 let state = Arc::new(ServerState::standalone(
27523 tmp.path().join(".car").join("journals"),
27524 ));
27525
27526 let got = handle_goal_suggest(
27527 &req(json!({
27528 "prompt": "create src/App.php",
27529 "working_dir": tmp.path().to_string_lossy()
27530 })),
27531 &state,
27532 )
27533 .await
27534 .unwrap();
27535 assert_eq!(got["suggested"], true);
27536 assert_eq!(got["confidence"], "high");
27537 let check = got["goal"]["check"].as_str().unwrap();
27538 assert!(check.contains("test -f 'src/App.php'"), "{check}");
27539 assert!(check.ends_with(" && composer test"), "{check}");
27540 let signals = got["signals"].as_array().unwrap();
27541 assert!(signals.contains(&json!("composer_script:test")));
27542 }
27543
27544 #[tokio::test]
27545 async fn goal_suggest_uses_php_lint_when_composer_has_no_test_signal() {
27546 let tmp = tempfile::TempDir::new().unwrap();
27547 std::fs::write(tmp.path().join("composer.json"), r#"{"scripts":{}}"#).unwrap();
27548 let state = Arc::new(ServerState::standalone(
27549 tmp.path().join(".car").join("journals"),
27550 ));
27551
27552 let got = handle_goal_suggest(
27553 &req(json!({
27554 "prompt": "make the php project valid",
27555 "working_dir": tmp.path().to_string_lossy()
27556 })),
27557 &state,
27558 )
27559 .await
27560 .unwrap();
27561 assert_eq!(got["suggested"], true);
27562 assert_eq!(got["confidence"], "medium");
27563 let check = got["goal"]["check"].as_str().unwrap();
27564 assert!(
27565 check.ends_with(
27566 " && find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l"
27567 ),
27568 "{check}"
27569 );
27570 let signals = got["signals"].as_array().unwrap();
27571 assert!(!signals.contains(&json!("composer_script:test")));
27572 }
27573
27574 #[tokio::test]
27575 async fn goal_suggest_detects_ruby_bundler_project() {
27576 let tmp = tempfile::TempDir::new().unwrap();
27577 std::fs::write(
27578 tmp.path().join("Gemfile"),
27579 "source 'https://rubygems.org'\n",
27580 )
27581 .unwrap();
27582 std::fs::write(tmp.path().join("Rakefile"), "task :test\n").unwrap();
27583 let state = Arc::new(ServerState::standalone(
27584 tmp.path().join(".car").join("journals"),
27585 ));
27586
27587 let got = handle_goal_suggest(
27588 &req(json!({
27589 "prompt": "make the ruby tests pass",
27590 "working_dir": tmp.path().to_string_lossy()
27591 })),
27592 &state,
27593 )
27594 .await
27595 .unwrap();
27596 assert_eq!(got["suggested"], true);
27597 assert_eq!(got["confidence"], "high");
27598 assert!(got["goal"]["check"]
27599 .as_str()
27600 .unwrap()
27601 .ends_with(" && bundle exec rake test"));
27602 let signals = got["signals"].as_array().unwrap();
27603 assert!(signals.contains(&json!("Gemfile")));
27604 assert!(signals.contains(&json!("Rakefile")));
27605 }
27606
27607 #[tokio::test]
27608 async fn goal_suggest_uses_ruby_syntax_check_without_test_runner_signal() {
27609 let tmp = tempfile::TempDir::new().unwrap();
27610 std::fs::write(
27611 tmp.path().join("Gemfile"),
27612 "source 'https://rubygems.org'\n",
27613 )
27614 .unwrap();
27615 let state = Arc::new(ServerState::standalone(
27616 tmp.path().join(".car").join("journals"),
27617 ));
27618
27619 let got = handle_goal_suggest(
27620 &req(json!({
27621 "prompt": "make the ruby project valid",
27622 "working_dir": tmp.path().to_string_lossy()
27623 })),
27624 &state,
27625 )
27626 .await
27627 .unwrap();
27628 assert_eq!(got["suggested"], true);
27629 assert_eq!(got["confidence"], "medium");
27630 let check = got["goal"]["check"].as_str().unwrap();
27631 assert!(
27632 check.ends_with(
27633 " && find . -name '*.rb' -not -path './vendor/*' -print0 | xargs -0 -n1 ruby -c"
27634 ),
27635 "{check}"
27636 );
27637 let signals = got["signals"].as_array().unwrap();
27638 assert!(signals.contains(&json!("Gemfile")));
27639 assert!(!signals.contains(&json!("Rakefile")));
27640 }
27641
27642 #[tokio::test]
27643 async fn goal_suggest_combines_created_elixir_file_with_mix_check() {
27644 let tmp = tempfile::TempDir::new().unwrap();
27645 std::fs::create_dir_all(tmp.path().join("lib/demo")).unwrap();
27646 std::fs::write(
27647 tmp.path().join("mix.exs"),
27648 "defmodule Demo.MixProject do\nend\n",
27649 )
27650 .unwrap();
27651 let state = Arc::new(ServerState::standalone(
27652 tmp.path().join(".car").join("journals"),
27653 ));
27654
27655 let got = handle_goal_suggest(
27656 &req(json!({
27657 "prompt": "create lib/demo/worker.ex",
27658 "working_dir": tmp.path().to_string_lossy()
27659 })),
27660 &state,
27661 )
27662 .await
27663 .unwrap();
27664 assert_eq!(got["suggested"], true);
27665 assert_eq!(got["confidence"], "high");
27666 let check = got["goal"]["check"].as_str().unwrap();
27667 assert!(check.contains("test -f 'lib/demo/worker.ex'"), "{check}");
27668 assert!(check.ends_with(" && mix test"), "{check}");
27669 }
27670
27671 #[tokio::test]
27672 async fn goal_suggest_falls_through_when_package_has_no_scripts() {
27673 let tmp = tempfile::TempDir::new().unwrap();
27674 std::fs::write(tmp.path().join("package.json"), r#"{"scripts":{}}"#).unwrap();
27675 std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
27676 let state = Arc::new(ServerState::standalone(
27677 tmp.path().join(".car").join("journals"),
27678 ));
27679
27680 let got = handle_goal_suggest(
27681 &req(json!({
27682 "prompt": "make it work",
27683 "working_dir": tmp.path().to_string_lossy()
27684 })),
27685 &state,
27686 )
27687 .await
27688 .unwrap();
27689 assert_eq!(got["suggested"], true);
27690 assert!(got["goal"]["check"]
27691 .as_str()
27692 .unwrap()
27693 .ends_with(" && go test ./..."));
27694 let warnings = got["warnings"].as_array().unwrap();
27695 assert!(warnings.contains(&json!(
27696 "package.json has no test/typecheck/check/build/lint script"
27697 )));
27698 }
27699
27700 #[tokio::test]
27701 async fn goal_suggest_refuses_to_invent_a_weak_check() {
27702 let tmp = tempfile::TempDir::new().unwrap();
27703 let state = Arc::new(ServerState::standalone(
27704 tmp.path().join(".car").join("journals"),
27705 ));
27706 let got = handle_goal_suggest(
27707 &req(json!({
27708 "prompt": "make it better",
27709 "working_dir": tmp.path().to_string_lossy()
27710 })),
27711 &state,
27712 )
27713 .await
27714 .unwrap();
27715 assert_eq!(got["suggested"], false);
27716 assert!(got["goal"].is_null());
27717 }
27718}
27719
27720#[cfg(test)]
27721mod metrics_alert_surface {
27722 use super::{handle_metrics_alerts, JsonRpcMessage};
27723 use crate::session::{ServerState, WsChannel};
27724 use car_verify::goal::GoalCondition;
27725 use serde_json::{json, Value};
27726 use std::sync::Arc;
27727
27728 fn req(params: Value) -> JsonRpcMessage {
27729 JsonRpcMessage {
27730 jsonrpc: "2.0".into(),
27731 method: None,
27732 params,
27733 id: json!(1),
27734 result: None,
27735 error: None,
27736 }
27737 }
27738
27739 #[tokio::test]
27740 async fn metrics_alerts_returns_goal_ungrounded() {
27741 let tmp = tempfile::TempDir::new().unwrap();
27742 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27743 let session = state
27744 .create_session("metrics-alerts", Arc::new(WsChannel::test_stub()))
27745 .await
27746 .unwrap();
27747 let condition = GoalCondition::Command {
27748 id: "tests".to_string(),
27749 expect_exit: 0,
27750 };
27751 session
27752 .runtime
27753 .record_goal_evaluated(
27754 "make tests pass",
27755 &condition,
27756 1,
27757 true,
27758 false,
27759 "ungrounded assistant summary claim(s): tests were run/passed",
27760 "mlx/qwen3-8b:4bit",
27761 )
27762 .await;
27763
27764 let got = handle_metrics_alerts(&req(json!({"max_goals_ungrounded": 0})), &session)
27765 .await
27766 .unwrap();
27767 assert_eq!(got["summary"]["goals_ungrounded"], json!(1));
27768 assert_eq!(got["alerts"][0]["kind"], json!("goal_ungrounded"));
27769 assert_eq!(got["alerts"][0]["observed"], json!(1.0));
27770 assert_eq!(got["alerts"][0]["threshold"], json!(0.0));
27771 }
27772}
27773
27774#[cfg(test)]
27775mod tool_registry_surface {
27776 use super::{
27796 handle_tools_list, handle_tools_register, handle_tools_unregister, JsonRpcMessage,
27797 };
27798 use crate::session::{ServerState, WsChannel};
27799 use serde_json::{json, Value};
27800 use std::sync::Arc;
27801
27802 fn req(params: Value) -> JsonRpcMessage {
27803 JsonRpcMessage {
27804 jsonrpc: "2.0".into(),
27805 method: None,
27806 params,
27807 id: json!(1),
27808 result: None,
27809 error: None,
27810 }
27811 }
27812
27813 async fn session(client_id: &str) -> Arc<crate::session::ClientSession> {
27814 let tmp = tempfile::TempDir::new().unwrap();
27815 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27816 state
27817 .create_session(client_id, Arc::new(WsChannel::test_stub()))
27818 .await
27819 .unwrap()
27820 }
27821
27822 fn names(listed: &Value) -> Vec<String> {
27824 listed["tools"]
27825 .as_array()
27826 .expect("tools must be an array")
27827 .iter()
27828 .map(|t| {
27829 t["name"]
27830 .as_str()
27831 .expect("name must be a string")
27832 .to_string()
27833 })
27834 .collect()
27835 }
27836
27837 #[tokio::test]
27842 async fn fresh_session_lists_only_the_built_in_messaging_send() {
27843 let session = session("c-tools-list-empty").await;
27844 let listed = handle_tools_list(&session).await.unwrap();
27845 assert_eq!(listed["count"], json!(1));
27846 assert_eq!(names(&listed), vec!["messaging.send"]);
27847 assert_eq!(listed["tools"][0]["source"], json!("builtin"));
27848 }
27849
27850 #[tokio::test]
27855 async fn list_returns_exactly_the_two_registered_tools_over_the_baseline_in_sorted_order() {
27856 let session = session("c-tools-list-two").await;
27857
27858 let registered = handle_tools_register(
27864 &req(json!([
27865 { "name": "write_file" },
27866 { "name": "read_file" },
27867 ])),
27868 &session,
27869 )
27870 .await
27871 .unwrap();
27872 assert_eq!(registered, json!(2));
27873
27874 let listed = handle_tools_list(&session).await.unwrap();
27875 assert_eq!(listed["count"], json!(3));
27876 assert_eq!(
27877 names(&listed),
27878 vec!["messaging.send", "read_file", "write_file"]
27879 );
27880 }
27881
27882 #[tokio::test]
27883 async fn register_rejects_a_server_owned_tool_name_without_changing_its_source() {
27884 let session = session("c-tools-register-shadow").await;
27885 let error = handle_tools_register(
27886 &req(json!([{ "name": "messaging.send", "description": "shadow" }])),
27887 &session,
27888 )
27889 .await
27890 .expect_err("callback registration must not shadow a built-in");
27891 assert_eq!(
27892 error,
27893 "tool 'messaging.send' is server-owned and cannot be shadowed by tools.register"
27894 );
27895
27896 let listed = handle_tools_list(&session).await.unwrap();
27897 assert_eq!(listed["count"], json!(1));
27898 assert_eq!(names(&listed), vec!["messaging.send"]);
27899 assert_eq!(listed["tools"][0]["source"], json!("builtin"));
27900 }
27901
27902 #[tokio::test]
27903 async fn unregister_drops_one_tool_and_list_reports_the_remainder() {
27904 let session = session("c-tools-unregister").await;
27905 handle_tools_register(
27906 &req(json!([{ "name": "read_file" }, { "name": "write_file" }])),
27907 &session,
27908 )
27909 .await
27910 .unwrap();
27911
27912 let dropped = handle_tools_unregister(&req(json!({ "name": "write_file" })), &session)
27913 .await
27914 .unwrap();
27915 assert_eq!(dropped["unregistered"], json!("write_file"));
27916 assert_eq!(dropped["removed"], json!(1));
27917
27918 let listed = handle_tools_list(&session).await.unwrap();
27919 assert_eq!(listed["count"], json!(2));
27920 assert_eq!(names(&listed), vec!["messaging.send", "read_file"]);
27921 }
27922
27923 #[tokio::test]
27927 async fn unregistering_an_unknown_tool_reports_zero_rather_than_erroring() {
27928 let session = session("c-tools-unregister-unknown").await;
27929
27930 let dropped =
27931 handle_tools_unregister(&req(json!({ "name": "never_registered" })), &session)
27932 .await
27933 .expect("unknown tool must not be an error");
27934 assert_eq!(dropped["unregistered"], json!("never_registered"));
27935 assert_eq!(dropped["removed"], json!(0));
27936
27937 assert_eq!(
27938 handle_tools_unregister(&req(json!({})), &session)
27939 .await
27940 .unwrap_err(),
27941 "missing 'name'"
27942 );
27943 }
27944
27945 #[tokio::test]
27948 async fn list_round_trips_the_full_tool_schema() {
27949 let session = session("c-tools-list-schema").await;
27950 handle_tools_register(
27951 &req(json!([{
27952 "name": "read_file",
27953 "description": "Read a UTF-8 file from disk",
27954 "parameters": {
27955 "type": "object",
27956 "properties": { "path": { "type": "string" } },
27957 "required": ["path"],
27958 },
27959 "idempotent": true,
27960 }])),
27961 &session,
27962 )
27963 .await
27964 .unwrap();
27965
27966 let listed = handle_tools_list(&session).await.unwrap();
27967 let tool = listed["tools"]
27968 .as_array()
27969 .unwrap()
27970 .iter()
27971 .find(|t| t["name"] == json!("read_file"))
27972 .expect("read_file must be listed");
27973 assert_eq!(tool["name"], json!("read_file"));
27974 assert_eq!(tool["source"], json!("user_defined"));
27975 assert_eq!(tool["description"], json!("Read a UTF-8 file from disk"));
27976 assert_eq!(tool["idempotent"], json!(true));
27977 assert_eq!(
27978 tool["parameters"],
27979 json!({
27980 "type": "object",
27981 "properties": { "path": { "type": "string" } },
27982 "required": ["path"],
27983 })
27984 );
27985 }
27986
27987 #[tokio::test]
27993 async fn registered_tool_cannot_claim_a_source_it_did_not_earn() {
27994 let session = session("c-tools-spoofed-source").await;
27995 handle_tools_register(
27996 &req(json!([{
27997 "name": "read_file",
27998 "description": "Claims to be a builtin",
27999 "source": "builtin",
28000 "parameters": {},
28001 }])),
28002 &session,
28003 )
28004 .await
28005 .expect("an unknown field must not fail registration");
28006
28007 let listed = handle_tools_list(&session).await.unwrap();
28008 let tool = listed["tools"]
28009 .as_array()
28010 .unwrap()
28011 .iter()
28012 .find(|t| t["name"] == json!("read_file"))
28013 .expect("read_file must be listed");
28014 assert_eq!(
28015 tool["source"],
28016 json!("user_defined"),
28017 "the runtime assigns user_defined; a client cannot claim builtin"
28018 );
28019 }
28020}
28021
28022#[cfg(test)]
28023mod tool_stream_surface {
28024 use super::{handle_tools_cancel, handle_tools_poll, JsonRpcMessage};
28028 use crate::session::{ServerState, WsChannel};
28029 use serde_json::{json, Value};
28030 use std::sync::Arc;
28031
28032 fn req(params: Value) -> JsonRpcMessage {
28033 JsonRpcMessage {
28034 jsonrpc: "2.0".into(),
28035 method: None,
28036 params,
28037 id: json!(1),
28038 result: None,
28039 error: None,
28040 }
28041 }
28042
28043 #[tokio::test]
28044 async fn poll_and_cancel_roundtrip() {
28045 let tmp = tempfile::TempDir::new().unwrap();
28046 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28047 let channel = Arc::new(WsChannel::test_stub());
28048 let session = state.create_session("c-tools-c2", channel).await.unwrap();
28049
28050 let poll = handle_tools_poll(&req(json!({"handle": "nope"})), &session)
28052 .await
28053 .unwrap();
28054 assert!(poll.is_null(), "unknown handle must poll to null");
28055
28056 assert!(handle_tools_poll(&req(json!({})), &session).await.is_err());
28058
28059 let (h, _tok) = session
28062 .runtime
28063 .tool_handles
28064 .register("tail_log", "a1")
28065 .await;
28066 session
28067 .runtime
28068 .tool_handles
28069 .push_chunk(
28070 &h.id,
28071 car_ir::ToolStreamChunk::Text {
28072 text: "line".into(),
28073 },
28074 )
28075 .await;
28076
28077 let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
28078 .await
28079 .unwrap();
28080 assert_eq!(poll["status"], "running");
28081 assert_eq!(poll["chunks"][0]["kind"], "text");
28082 assert_eq!(poll["chunks"][0]["text"], "line");
28083 assert_eq!(poll["tool"], "tail_log");
28084
28085 let cancel = handle_tools_cancel(&req(json!({"handle": h.id})), &session)
28087 .await
28088 .unwrap();
28089 assert_eq!(cancel["cancelled"], true);
28090 let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
28091 .await
28092 .unwrap();
28093 assert_eq!(poll["status"], "cancelled");
28094
28095 let cancel = handle_tools_cancel(&req(json!({"handle": "nope"})), &session)
28097 .await
28098 .unwrap();
28099 assert_eq!(cancel["cancelled"], false);
28100 }
28101}
28102
28103#[cfg(test)]
28104mod chat_cancel_surface {
28105 use super::{handle_agents_chat_cancel, remove_owned_external_chat_session, JsonRpcMessage};
28106 use crate::session::{ChatSession, ServerState};
28107 use serde_json::{json, Value};
28108 use std::sync::atomic::{AtomicBool, Ordering};
28109 use std::sync::Arc;
28110
28111 fn req(params: Value) -> JsonRpcMessage {
28112 JsonRpcMessage {
28113 jsonrpc: "2.0".into(),
28114 method: None,
28115 params,
28116 id: json!(1),
28117 result: None,
28118 error: None,
28119 }
28120 }
28121
28122 #[tokio::test]
28123 async fn cancel_sets_local_declarative_flag_and_drops_session() {
28124 let tmp = tempfile::TempDir::new().unwrap();
28125 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28126 let flag = Arc::new(AtomicBool::new(false));
28127 state.chat_sessions.lock().await.insert(
28128 "decl-chat".to_string(),
28129 ChatSession {
28130 agent_id: "writer".to_string(),
28131 host_client_id: "host-1".to_string(),
28132 created_at: 0,
28133 local_cancel: Some(flag.clone()),
28134 },
28135 );
28136
28137 let cancel = handle_agents_chat_cancel(
28138 &req(json!({"session_id": "decl-chat"})),
28139 &state,
28140 "host",
28141 true,
28142 )
28143 .await
28144 .unwrap();
28145 assert_eq!(cancel["cancelled"], true);
28146 assert!(flag.load(Ordering::SeqCst));
28147 assert!(!state.chat_sessions.lock().await.contains_key("decl-chat"));
28148
28149 let again = handle_agents_chat_cancel(
28150 &req(json!({"session_id": "decl-chat"})),
28151 &state,
28152 "host",
28153 true,
28154 )
28155 .await
28156 .unwrap();
28157 assert_eq!(again["cancelled"], false);
28158 }
28159
28160 #[tokio::test]
28161 async fn external_cleanup_preserves_proxied_chat_route() {
28162 let tmp = tempfile::TempDir::new().unwrap();
28163 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28164 let flag = Arc::new(AtomicBool::new(false));
28165 state.chat_sessions.lock().await.insert(
28166 "proxied-chat".to_string(),
28167 ChatSession {
28168 agent_id: "car-assistant".to_string(),
28169 host_client_id: "host-1".to_string(),
28170 created_at: 0,
28171 local_cancel: Some(flag),
28172 },
28173 );
28174
28175 remove_owned_external_chat_session(&state, "proxied-chat", false).await;
28176 assert!(state
28177 .chat_sessions
28178 .lock()
28179 .await
28180 .contains_key("proxied-chat"));
28181
28182 remove_owned_external_chat_session(&state, "proxied-chat", true).await;
28183 assert!(!state
28184 .chat_sessions
28185 .lock()
28186 .await
28187 .contains_key("proxied-chat"));
28188 }
28189}
28190
28191#[cfg(test)]
28192mod assistant_agent_alias_surface {
28193 use super::resolve_assistant_agent_alias;
28198 use crate::session::ServerState;
28199 use std::sync::Arc;
28200
28201 fn state() -> Arc<ServerState> {
28202 let tmp = tempfile::TempDir::new().unwrap();
28203 Arc::new(ServerState::standalone(tmp.path().to_path_buf()))
28204 }
28205
28206 #[tokio::test]
28207 async fn legacy_id_resolves_to_canonical_when_only_canonical_attached() {
28208 let state = state();
28209 state
28210 .attached_agents
28211 .lock()
28212 .await
28213 .insert("parslee-core".to_string(), "client-1".to_string());
28214
28215 assert_eq!(
28216 resolve_assistant_agent_alias("car-assistant".to_string(), &state).await,
28217 "parslee-core"
28218 );
28219 }
28220
28221 #[tokio::test]
28222 async fn canonical_id_resolves_to_legacy_when_only_legacy_attached() {
28223 let state = state();
28224 state
28225 .attached_agents
28226 .lock()
28227 .await
28228 .insert("car-assistant".to_string(), "client-1".to_string());
28229
28230 assert_eq!(
28231 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28232 "car-assistant"
28233 );
28234 }
28235
28236 #[tokio::test]
28237 async fn exact_match_is_never_rewritten() {
28238 let state = state();
28239 state
28240 .attached_agents
28241 .lock()
28242 .await
28243 .insert("parslee-core".to_string(), "client-1".to_string());
28244
28245 assert_eq!(
28246 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28247 "parslee-core"
28248 );
28249 }
28250
28251 #[tokio::test]
28252 async fn non_assistant_id_is_left_untouched_even_if_unattached() {
28253 let state = state();
28254 assert_eq!(
28255 resolve_assistant_agent_alias("some-other-agent".to_string(), &state).await,
28256 "some-other-agent"
28257 );
28258 }
28259
28260 #[tokio::test]
28261 async fn assistant_id_is_left_untouched_when_neither_spelling_attached() {
28262 let state = state();
28263 assert_eq!(
28264 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28265 "parslee-core"
28266 );
28267 }
28268}
28269
28270#[cfg(test)]
28271mod chat_approve_surface {
28272 use super::{handle_agents_chat_approve, JsonRpcMessage};
28279 use crate::session::{ChatSession, ServerState};
28280 use serde_json::{json, Value};
28281 use std::sync::Arc;
28282
28283 fn req(params: Value) -> JsonRpcMessage {
28284 JsonRpcMessage {
28285 jsonrpc: "2.0".into(),
28286 method: None,
28287 params,
28288 id: json!(1),
28289 result: None,
28290 error: None,
28291 }
28292 }
28293
28294 #[tokio::test]
28295 async fn requires_session_and_approval_ids() {
28296 let tmp = tempfile::TempDir::new().unwrap();
28297 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28298
28299 let err =
28301 handle_agents_chat_approve(&req(json!({"approval_id": "a1"})), &state, "host-1", false)
28302 .await
28303 .unwrap_err();
28304 assert!(err.contains("session_id"), "{err}");
28305
28306 let err =
28308 handle_agents_chat_approve(&req(json!({"session_id": "s1"})), &state, "host-1", false)
28309 .await
28310 .unwrap_err();
28311 assert!(err.contains("approval_id"), "{err}");
28312 }
28313
28314 #[tokio::test]
28315 async fn unknown_session_is_rejected() {
28316 let tmp = tempfile::TempDir::new().unwrap();
28317 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28318 let err = handle_agents_chat_approve(
28319 &req(json!({"session_id": "ghost", "approval_id": "a1", "decision": true})),
28320 &state,
28321 "host-1",
28322 false,
28323 )
28324 .await
28325 .unwrap_err();
28326 assert!(err.contains("unknown or already-finished"), "{err}");
28327 }
28328
28329 #[test]
28333 fn a_bound_session_may_act_only_as_itself() {
28334 assert!(super::require_own_agent(Some("a"), "m", "a").is_ok());
28335 assert!(super::require_own_agent(Some("a"), "m", "b").is_err());
28336 assert!(super::require_own_agent(Some("a"), "m", "A").is_err());
28339 assert!(super::require_own_agent(Some("a"), "m", "a ").is_err());
28340 }
28341
28342 #[test]
28345 fn an_unbound_session_is_not_restricted_by_this_rule() {
28346 assert!(super::require_own_agent(None, "m", "a").is_ok());
28347 assert!(super::require_own_agent(None, "m", "anything-at-all").is_ok());
28348 }
28349
28350 #[test]
28354 fn the_refusal_says_which_call_was_refused_without_revealing_anything() {
28355 let err = super::require_own_agent(Some("a"), "agents.wait", "b").unwrap_err();
28356 assert!(err.contains("agents.wait"), "{err}");
28357 assert!(err.contains("may only act on itself"), "{err}");
28358 assert!(!err.contains(" "), "collapsed continuation: {err:?}");
28359 assert!(super::require_own_agent(Some("a"), "agents.wait", "a").is_ok());
28360 assert!(super::require_own_agent(None, "agents.wait", "b").is_ok());
28361 }
28362
28363 #[tokio::test]
28364 async fn caller_must_be_originating_host_or_host_management() {
28365 let tmp = tempfile::TempDir::new().unwrap();
28366 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28367 state.chat_sessions.lock().await.insert(
28368 "s1".to_string(),
28369 ChatSession {
28370 agent_id: "car-assistant".to_string(),
28371 host_client_id: "host-1".to_string(),
28372 created_at: 0,
28373 local_cancel: None,
28374 },
28375 );
28376
28377 let err = handle_agents_chat_approve(
28378 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28379 &state,
28380 "agent-client",
28381 false,
28382 )
28383 .await
28384 .unwrap_err();
28385 assert!(
28386 err.contains("originating host session or host-management role"),
28387 "{err}"
28388 );
28389 assert!(state.chat_sessions.lock().await.contains_key("s1"));
28390 }
28391
28392 #[tokio::test]
28393 async fn known_session_but_detached_agent_is_rejected() {
28394 let tmp = tempfile::TempDir::new().unwrap();
28395 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28396 state.chat_sessions.lock().await.insert(
28399 "s1".to_string(),
28400 ChatSession {
28401 agent_id: "car-assistant".to_string(),
28402 host_client_id: "host-1".to_string(),
28403 created_at: 0,
28404 local_cancel: None,
28405 },
28406 );
28407 let err = handle_agents_chat_approve(
28408 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28409 &state,
28410 "host-1",
28411 false,
28412 )
28413 .await
28414 .unwrap_err();
28415 assert!(err.contains("not attached"), "{err}");
28416 assert!(state.chat_sessions.lock().await.contains_key("s1"));
28418 }
28419
28420 #[tokio::test]
28421 async fn host_management_may_resolve_any_chat_approval() {
28422 let tmp = tempfile::TempDir::new().unwrap();
28423 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28424 state.chat_sessions.lock().await.insert(
28425 "s1".to_string(),
28426 ChatSession {
28427 agent_id: "car-assistant".to_string(),
28428 host_client_id: "host-1".to_string(),
28429 created_at: 0,
28430 local_cancel: None,
28431 },
28432 );
28433
28434 let err = handle_agents_chat_approve(
28435 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28436 &state,
28437 "host-admin",
28438 true,
28439 )
28440 .await
28441 .unwrap_err();
28442 assert!(
28443 err.contains("not attached"),
28444 "host-management should pass authorization and fail only at routing: {err}"
28445 );
28446 }
28447}
28448
28449#[cfg(test)]
28450mod sync_lease_surface {
28451 use super::{
28458 acquire_lease, check_sync_fence, handle_lease_status, handle_sync_append,
28459 handle_sync_record_turn, handle_sync_resume, handle_sync_status, record_sync_intent,
28460 release_lease, JsonRpcMessage,
28461 };
28462 use crate::session::ServerState;
28463 use serde_json::{json, Value};
28464 use std::sync::Arc;
28465
28466 fn req(params: Value) -> JsonRpcMessage {
28467 JsonRpcMessage {
28468 jsonrpc: "2.0".into(),
28469 method: None,
28470 params,
28471 id: json!(1),
28472 result: None,
28473 error: None,
28474 }
28475 }
28476
28477 #[tokio::test]
28478 async fn sync_and_lease_dispatch_roundtrip() {
28479 let tmp = tempfile::TempDir::new().unwrap();
28480 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28481
28482 let status = handle_sync_status(&state).await.unwrap();
28484 assert!(status["device_id"].as_str().unwrap().starts_with("device-"));
28485
28486 handle_sync_append(
28488 &req(json!({"surface": "knowledge", "payload": {"id": "f1", "body": "sky"}})),
28489 &state,
28490 )
28491 .await
28492 .unwrap();
28493 handle_sync_record_turn(
28494 &req(json!({"conversation_id": "c1", "role": "user", "content": "hi", "timestamp": 1})),
28495 &state,
28496 )
28497 .await
28498 .unwrap();
28499
28500 let resume = handle_sync_resume(&req(json!({"conversation_id": "c1"})), &state)
28502 .await
28503 .unwrap();
28504 let msgs = resume.as_array().unwrap();
28505 assert_eq!(msgs.len(), 1);
28506 assert_eq!(msgs[0]["role"], json!("user"));
28507
28508 let lease = acquire_lease(
28513 &req(json!({"agent_id": "milo", "ttl_ms": 1_000_000})),
28514 &state,
28515 "milo",
28516 )
28517 .await
28518 .unwrap();
28519 assert_eq!(lease["epoch"], json!(1));
28520 let ls = handle_lease_status(&req(json!({"agent_id": "milo"})), &state, Some("milo"))
28521 .await
28522 .unwrap();
28523 assert_eq!(ls["lease"]["holder"], lease["holder"]);
28524
28525 let fence = check_sync_fence(
28527 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
28528 &state,
28529 "milo",
28530 )
28531 .await
28532 .unwrap();
28533 assert_eq!(fence["may_dispatch"], json!(true));
28534
28535 record_sync_intent(
28537 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1, "status": "committed"})),
28538 &state,
28539 "milo",
28540 )
28541 .await
28542 .unwrap();
28543 let fence = check_sync_fence(
28544 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
28545 &state,
28546 "milo",
28547 )
28548 .await
28549 .unwrap();
28550 assert_eq!(fence["decision"]["decision"], json!("already_committed"));
28551 assert_eq!(fence["may_dispatch"], json!(false));
28552
28553 release_lease(
28555 &req(json!({"agent_id": "milo", "epoch": 1})),
28556 &state,
28557 "milo",
28558 )
28559 .await
28560 .unwrap();
28561 assert!(release_lease(
28562 &req(json!({"agent_id": "milo", "epoch": 1})),
28563 &state,
28564 "milo"
28565 )
28566 .await
28567 .is_err());
28568 }
28569}
28570
28571#[cfg(test)]
28572mod merged_knowledge_tests {
28573 use super::merge_knowledge;
28574 use serde_json::json;
28575
28576 #[test]
28577 fn org_none_leaves_personal_knowledge_unchanged() {
28578 let personal = vec![json!({"subject": "a", "body": "1"})];
28580 assert_eq!(merge_knowledge(personal.clone(), None), personal);
28581 }
28582
28583 fn reduce_last_wins(
28588 entries: &[serde_json::Value],
28589 ) -> std::collections::HashMap<String, String> {
28590 let mut out = std::collections::HashMap::new();
28591 for e in entries {
28592 let subject = e["subject"].as_str().unwrap().to_string();
28593 let body = e["body"].as_str().unwrap().to_string();
28594 out.insert(subject, body); }
28596 out
28597 }
28598
28599 #[test]
28600 fn shared_subject_resolves_to_personal_not_org() {
28601 let personal = vec![json!({"subject": "shared", "body": "personal-newer"})];
28606 let org = vec![
28607 json!({"subject": "org-only", "body": "from-org"}),
28608 json!({"subject": "shared", "body": "org-staler"}),
28609 ];
28610 let reduced = reduce_last_wins(&merge_knowledge(personal, Some(org)));
28611 assert_eq!(
28612 reduced.get("shared").map(String::as_str),
28613 Some("personal-newer"),
28614 "personal must win a shared subject — a staler org fact must NOT shadow it"
28615 );
28616 assert_eq!(
28617 reduced.get("org-only").map(String::as_str),
28618 Some("from-org"),
28619 "an org-only subject is still surfaced"
28620 );
28621 }
28622
28623 #[test]
28624 fn within_scope_newest_is_preserved() {
28625 let personal = vec![
28627 json!({"subject": "a", "body": "old"}),
28628 json!({"subject": "a", "body": "new"}),
28629 ];
28630 let reduced = reduce_last_wins(&merge_knowledge(personal, None));
28631 assert_eq!(reduced.get("a").map(String::as_str), Some("new"));
28632 }
28633}
28634
28635#[cfg(test)]
28641mod skill_adopt_pack_tests {
28642 use super::resolve_adopt_provenance;
28643 use car_bundle::{sign_manifest, AgentIdentity, AgentManifest, PublisherInfo, TransportSpec};
28644 use ed25519_dalek::SigningKey;
28645 use serde_json::json;
28646
28647 fn signed_manifest(id: &str) -> (AgentManifest, String) {
28652 let mut m = AgentManifest {
28653 agent: AgentIdentity {
28654 id: id.into(),
28655 name: id.into(),
28656 namespace: Some("parslee".into()),
28657 version: Some("1.0.0".into()),
28658 description: None,
28659 license: None,
28660 homepage: None,
28661 },
28662 publisher: None,
28663 runtime: None,
28664 lifecycle: None,
28665 transport: TransportSpec::PureData,
28666 capabilities: None,
28667 };
28668 let key = SigningKey::from_bytes(&[7u8; 32]);
28669 sign_manifest(&mut m, &key).expect("sign");
28670 let key_id = m
28671 .publisher
28672 .as_ref()
28673 .and_then(|p: &PublisherInfo| p.key_id.clone())
28674 .expect("key_id present after signing");
28675 (m, key_id)
28676 }
28677
28678 #[test]
28679 fn no_manifest_no_provenance_defaults_to_unsigned_and_untrusted() {
28680 let params = json!({ "scanned": true, "vulnerabilities": 2, "source": "community" });
28681 let p = resolve_adopt_provenance(¶ms, &[]).expect("resolves");
28682 assert!(!p.signed, "nothing signed it, so it is not signed");
28683 assert!(!p.signer_trusted);
28684 assert!(p.scanned);
28687 assert_eq!(p.vulnerabilities, 2);
28688 assert_eq!(p.source, car_policy::skill_trust::SkillSource::Community);
28689 }
28690
28691 #[test]
28692 fn manifest_signed_by_a_keyring_signer_is_signed_and_trusted() {
28693 let (m, key_id) = signed_manifest("pack-agent");
28694 let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
28695 let p = resolve_adopt_provenance(¶ms, &[key_id]).expect("resolves");
28696 assert!(p.signed);
28697 assert!(p.signer_trusted, "key_id is in the operator keyring");
28698 }
28699
28700 #[test]
28701 fn manifest_signed_by_an_unknown_signer_is_signed_but_not_trusted() {
28702 let (m, _key_id) = signed_manifest("pack-agent");
28703 let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
28704 let p = resolve_adopt_provenance(¶ms, &[]).expect("resolves");
28705 assert!(p.signed, "the signature itself still verifies");
28706 assert!(
28707 !p.signer_trusted,
28708 "an empty keyring trusts no signer, which costs this pack Official \
28709 (and only Official) — signed + scanned still reaches Verified"
28710 );
28711 }
28712
28713 #[test]
28714 fn manifest_and_provenance_together_is_an_error_not_a_precedence_choice() {
28715 let (m, _) = signed_manifest("pack-agent");
28716 let params = json!({
28717 "manifest": serde_json::to_value(&m).unwrap(),
28718 "provenance": { "signed": true, "signer_trusted": true },
28719 });
28720 let err = resolve_adopt_provenance(¶ms, &[]).expect_err("must refuse");
28721 assert!(err.contains("not both"), "unexpected message: {err}");
28722 }
28723
28724 #[test]
28725 fn unrecognised_source_is_rejected_rather_than_silently_defaulted() {
28726 let params = json!({ "source": "bogus" });
28727 let err = resolve_adopt_provenance(¶ms, &[]).expect_err("must refuse");
28728 assert!(err.contains("invalid source"), "unexpected message: {err}");
28729 }
28730}
28731
28732#[cfg(test)]
28733mod approval_requester_tests {
28734 use super::stamped_requester;
28735
28736 #[test]
28740 fn a_bound_session_cannot_claim_to_be_another_agent() {
28741 assert_eq!(
28742 stamped_requester(Some("agent-7".into()), Some("agent-9".into())),
28743 Some("agent-7".into())
28744 );
28745 }
28746
28747 #[test]
28751 fn a_bound_session_cannot_launder_by_omitting_the_field() {
28752 assert_eq!(
28753 stamped_requester(Some("agent-7".into()), None),
28754 Some("agent-7".into())
28755 );
28756 }
28757
28758 #[test]
28761 fn an_unbound_host_client_keeps_its_claim() {
28762 assert_eq!(
28763 stamped_requester(None, Some("agent-7".into())),
28764 Some("agent-7".into())
28765 );
28766 assert_eq!(stamped_requester(None, None), None);
28767 }
28768}