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 "session.auth",
357 "session.init",
358];
359const DEADLINE_FAST_SECS: u64 = 180;
360const PROPOSAL_DEADLINE_GRACE_SECS: u64 = 15;
383const DEFAULT_PROPOSAL_MAX_RETRIES: u64 = 3;
389const HANDLER_DEADLINE_ERROR_CODE: i32 = -32004;
393
394fn requires_protocol_handshake(method: &str) -> bool {
399 method == "host.subscribe"
400 || method.starts_with("auth.")
401 || method == "diagnostics.secret_store_activity"
402 || method == "session.clear_halt"
403 || method == "infer.cancel"
404 || method == "infer.deadline"
405 || method == "runs.cancel"
406 || matches!(
407 method,
408 "agent_permissions.set_tool"
409 | "agent_permissions.reset_tool"
410 | "agent_permissions.evaluate_tool"
411 )
412 || method == "feedback.compose_preview"
415 || method == "feedback.submit"
416 || method == "feedback.status"
417 || method == "feedback.list"
418}
419
420fn session_has_capability(session: &crate::session::ClientSession, capability: &str) -> bool {
421 session
422 .negotiated_capabilities
423 .read()
424 .map(|capabilities| capabilities.contains(capability))
425 .unwrap_or(false)
426}
427
428fn require_feedback_capability(session: &crate::session::ClientSession) -> Result<(), String> {
434 if session_has_capability(session, car_proto::FEEDBACK_CAPABILITY) {
435 Ok(())
436 } else {
437 Err(format!(
438 "{} negotiate `{}` as a required or optional capability before calling `feedback.*`",
439 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
440 car_proto::FEEDBACK_CAPABILITY
441 ))
442 }
443}
444
445fn require_agent_tool_override_capability(
446 session: &crate::session::ClientSession,
447) -> Result<(), String> {
448 if session_has_capability(session, car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY) {
449 Ok(())
450 } else {
451 Err(format!(
452 "this operation requires negotiated capability {}",
453 car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY
454 ))
455 }
456}
457
458const KEEPALIVE_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
465const 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);
468const AUTH_STATE_RESPONSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(95);
475const AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX: &str =
476 "auth coordination deadline before redemption; safe to retry auth.complete";
477const AUTH_START_RETRY_PREFIX: &str =
478 "auth.start coordination deadline before reservation; safe to retry auth.start serially";
479const AUTH_STATUS_RETRY_PREFIX: &str =
480 "auth.completion_status coordination deadline; retry the proof read serially";
481#[cfg(test)]
486const AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON: std::time::Duration =
487 std::time::Duration::from_secs(480);
488
489fn is_daemon_owned_auth_method(method: &str) -> bool {
490 DAEMON_OWNED_AUTH_METHODS.contains(&method)
491}
492
493fn server_deadline_for(method: &str, params: &Value) -> Option<std::time::Duration> {
494 if DEADLINE_EXEMPT_SUBSTRINGS
495 .iter()
496 .any(|s| method.contains(s))
497 {
498 return None;
499 }
500 let fast = DEADLINE_FAST_SUFFIXES.iter().any(|s| method.ends_with(s))
501 || DEADLINE_FAST_PREFIXES.iter().any(|p| method.starts_with(p))
502 || DEADLINE_FAST_EXACT.contains(&method);
503 let secs = if fast {
504 DEADLINE_FAST_SECS
505 } else if method == "proposal.submit" {
506 proposal_submit_deadline_secs(params)
507 } else {
508 handler_default_deadline_secs()
509 };
510 Some(std::time::Duration::from_secs(secs))
511}
512
513fn inference_total_timeout() -> std::time::Duration {
514 std::env::var("CAR_INFER_TIMEOUT_SECS")
515 .ok()
516 .and_then(|value| value.parse::<u64>().ok())
517 .map(std::time::Duration::from_secs)
518 .unwrap_or(DEFAULT_INFER_TOTAL_TIMEOUT)
519}
520
521fn proposal_submit_deadline_secs(params: &Value) -> u64 {
541 let default_secs = handler_default_deadline_secs();
542 match derive_proposal_budget_secs(params) {
543 0 => default_secs,
544 derived => default_secs.max(derived.saturating_add(PROPOSAL_DEADLINE_GRACE_SECS)),
545 }
546}
547
548fn derive_proposal_budget_secs(params: &Value) -> u64 {
567 let Some(actions) = params
568 .get("proposal")
569 .and_then(|p| p.get("actions"))
570 .and_then(|a| a.as_array())
571 else {
572 return 0;
573 };
574 if actions.is_empty() {
575 return 0;
576 }
577
578 let mut total_ms: u64 = 0;
581 for action in actions {
582 let budget_ms = action
583 .get("timeout_ms")
584 .and_then(|v| v.as_u64())
585 .unwrap_or(crate::session::DEFAULT_TOOL_TIMEOUT_MS);
586 total_ms =
587 total_ms.saturating_add(budget_ms.saturating_mul(proposal_action_attempts(action)));
588 }
589
590 total_ms.div_ceil(1000)
593}
594
595fn proposal_action_attempts(action: &Value) -> u64 {
603 let is_retry = action
604 .get("failure_behavior")
605 .and_then(|v| v.as_str())
606 .map(|s| s.eq_ignore_ascii_case("retry"))
607 .unwrap_or(false);
608
609 if is_retry {
610 action
611 .get("max_retries")
612 .and_then(|v| v.as_u64())
613 .unwrap_or(DEFAULT_PROPOSAL_MAX_RETRIES)
614 .saturating_add(1)
615 } else {
616 1
617 }
618}
619
620#[derive(Debug)]
621enum HandlerFailure {
622 Dispatch(String),
623 MethodNotFound(String),
624 InvalidParams(String),
625 Deadline(String),
626 ContentRefused(String),
630 ProtocolCapability(String),
631 CatalogPrecondition(String),
632 RunOwnershipConflict(String),
633 RunTraceCorruption(String),
634}
635
636impl HandlerFailure {
637 fn from_dispatch(message: String) -> Self {
649 if message.starts_with("unknown method:") {
650 Self::MethodNotFound(message)
651 } else if message.starts_with("invalid params:") {
652 Self::InvalidParams(message)
653 } else if message.starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX) {
654 Self::ContentRefused(message)
655 } else if message.starts_with(car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX) {
656 Self::ProtocolCapability(message)
657 } else if message.starts_with(car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX) {
658 Self::CatalogPrecondition(message)
659 } else if message.starts_with(car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX) {
660 Self::RunOwnershipConflict(message)
661 } else if message.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
662 Self::RunTraceCorruption(message)
663 } else {
664 Self::Dispatch(message)
665 }
666 }
667
668 fn json_rpc_code(&self) -> i32 {
669 match self {
670 Self::Dispatch(_) => -32603,
671 Self::MethodNotFound(_) => -32601,
672 Self::InvalidParams(_) => -32602,
673 Self::Deadline(_) => HANDLER_DEADLINE_ERROR_CODE,
674 Self::ContentRefused(_) => car_proto::CONTENT_REFUSED_ERROR_CODE,
675 Self::ProtocolCapability(_) => car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
676 Self::CatalogPrecondition(_) => car_proto::CATALOG_PRECONDITION_MISMATCH_ERROR_CODE,
677 Self::RunOwnershipConflict(_) => car_proto::RUN_OWNERSHIP_CONFLICT_ERROR_CODE,
678 Self::RunTraceCorruption(_) => car_proto::RUN_TRACE_CORRUPTION_ERROR_CODE,
679 }
680 }
681
682 fn message(&self) -> &str {
683 match self {
684 Self::Dispatch(message)
685 | Self::MethodNotFound(message)
686 | Self::InvalidParams(message)
687 | Self::Deadline(message)
688 | Self::ContentRefused(message)
689 | Self::ProtocolCapability(message)
690 | Self::CatalogPrecondition(message)
691 | Self::RunOwnershipConflict(message)
692 | Self::RunTraceCorruption(message) => message,
693 }
694 }
695}
696
697fn json_rpc_response_for_handler_result(
698 id: Value,
699 result: Result<Value, HandlerFailure>,
700) -> JsonRpcResponse {
701 match result {
702 Ok(value) => JsonRpcResponse::success(id, value),
703 Err(error) => JsonRpcResponse::error(id, error.json_rpc_code(), error.message()),
704 }
705}
706
707async fn dispatch_with_server_deadline<F>(
708 method: &str,
709 params: &Value,
710 dispatch: F,
711) -> Result<Value, HandlerFailure>
712where
713 F: std::future::Future<Output = Result<Value, String>>,
714{
715 match server_deadline_for(method, params) {
716 Some(deadline) => match tokio::time::timeout(deadline, dispatch).await {
717 Ok(result) => result.map_err(HandlerFailure::from_dispatch),
718 Err(_) => Err(HandlerFailure::Deadline(format!(
719 "handler '{}' exceeded the server-side deadline of {}s and was abandoned",
720 method,
721 deadline.as_secs()
722 ))),
723 },
724 None => dispatch.await.map_err(HandlerFailure::from_dispatch),
725 }
726}
727
728async fn await_daemon_owned_auth_result(
729 method: &str,
730 result_rx: tokio::sync::oneshot::Receiver<Result<Value, HandlerFailure>>,
731) -> Result<Value, HandlerFailure> {
732 let receive = async {
733 match result_rx.await {
734 Ok(result) => result,
735 Err(_) => Err(HandlerFailure::Dispatch(format!(
736 "daemon-owned auth operation `{method}` stopped before publishing a result"
737 ))),
738 }
739 };
740 if !matches!(
741 method,
742 "auth.start" | "auth.complete" | "auth.completion_status"
743 ) {
744 return receive.await;
745 }
746
747 tokio::time::timeout(AUTH_STATE_RESPONSE_DEADLINE, receive)
748 .await
749 .unwrap_or_else(|_| match method {
750 "auth.complete" => Err(HandlerFailure::Deadline(format!(
751 "auth.complete durable claim outcome is ambiguous after {}s; do not replay the \
752 authorization code and reconcile the exact attempt through auth.completion_status",
753 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
754 ))),
755 "auth.start" => Err(HandlerFailure::Deadline(format!(
756 "auth.start reservation outcome is ambiguous after {}s; the daemon-owned \
757 reservation continues, so do not overlap another auth.start",
758 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
759 ))),
760 _ => Err(HandlerFailure::Deadline(format!(
761 "handler 'auth.completion_status' exceeded its {}s response deadline; \
762 daemon-owned reconciliation continues and a later proof read is safe",
763 AUTH_STATE_RESPONSE_DEADLINE.as_secs()
764 ))),
765 })
766}
767
768fn classified_auth_failure(method: &str, error: car_auth::AuthOperationError) -> HandlerFailure {
769 match error {
770 car_auth::AuthOperationError::CoordinationDeadline(detail) => {
771 let message = match method {
772 "auth.complete" => format!("{AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX}: {detail}"),
773 "auth.start" => format!("{AUTH_START_RETRY_PREFIX}: {detail}"),
774 "auth.completion_status" => format!("{AUTH_STATUS_RETRY_PREFIX}: {detail}"),
775 _ => format!(
776 "{method} coordination deadline before state work; retry the request safely: \
777 {detail}"
778 ),
779 };
780 HandlerFailure::Deadline(message)
781 }
782 car_auth::AuthOperationError::Terminal(message) => HandlerFailure::from_dispatch(message),
783 }
784}
785
786#[cfg(test)]
787mod assistant_identity_tests {
788 use super::assistant_identity_wire;
789 use car_identity::AssistantIdentity;
790
791 #[test]
792 fn the_wire_shape_carries_everything_a_host_needs_to_address_the_assistant() {
793 let identity = AssistantIdentity::new("Jarvis")
799 .expect("valid name")
800 .with_spellings(vec!["jervis".into()])
801 .expect("valid spellings");
802 let wire = assistant_identity_wire(&identity);
803
804 assert_eq!(wire["name"], "Jarvis");
805 assert_eq!(wire["spellings"][0], "jervis");
806 assert_eq!(wire["brand"], car_identity::BRAND_NAME);
807
808 let aliases: Vec<String> =
809 serde_json::from_value(wire["aliases"].clone()).expect("aliases");
810 for expected in ["jarvis", "hey jarvis", "jervis", "hey jervis"] {
811 assert!(
812 aliases.contains(&expected.to_string()),
813 "{expected} missing from {aliases:?}"
814 );
815 }
816 }
817
818 #[test]
819 fn the_brand_travels_alongside_the_name_never_instead_of_it() {
820 let wire = assistant_identity_wire(&AssistantIdentity::new("Friday").unwrap());
823 assert_eq!(wire["name"], "Friday");
824 assert_eq!(wire["brand"], "Parslee Core");
825 }
826}
827
828#[cfg(test)]
829mod deadline_policy_tests {
830 use super::{
831 auth_completion_network_with_deadline, auth_completion_value,
832 await_daemon_owned_auth_result, classified_auth_failure, dispatch_with_server_deadline,
833 handler_default_deadline_secs, inference_dispatch_error, is_daemon_owned_auth_method,
834 json_rpc_response_for_handler_result, select_auth_start_api_base, server_deadline_for,
835 stream_dispatch_error, HandlerFailure, AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX,
836 AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON, AUTH_START_RETRY_PREFIX,
837 AUTH_STATE_RESPONSE_DEADLINE, AUTH_STATUS_RETRY_PREFIX, DAEMON_OWNED_AUTH_METHODS,
838 DEADLINE_FAST_SECS, DEFAULT_PROPOSAL_MAX_RETRIES, HANDLER_DEADLINE_ERROR_CODE,
839 PROPOSAL_DEADLINE_GRACE_SECS,
840 };
841 use serde_json::{json, Value};
842 use std::time::Duration;
843
844 #[test]
845 fn auth_start_base_precedence_is_passive_and_does_not_read_secrets() {
846 const SENTINEL: &str = "CAR_AUTH_START_BASE_CONTRACT_CHILD";
847 if std::env::var_os(SENTINEL).is_none() {
848 let status = std::process::Command::new(std::env::current_exe().unwrap())
849 .arg("--exact")
850 .arg(
851 "handler::deadline_policy_tests::auth_start_base_precedence_is_passive_and_does_not_read_secrets",
852 )
853 .arg("--nocapture")
854 .env(SENTINEL, "1")
855 .status()
856 .expect("spawn isolated auth.start base contract");
857 assert!(status.success(), "isolated auth.start contract failed");
858 return;
859 }
860
861 let before = car_secrets::secret_store_activity();
862
863 assert_eq!(
864 select_auth_start_api_base(
865 &json!({ "api_base": " https://explicit.example.test/ " }),
866 Some("https://environment.example.test/"),
867 ),
868 "https://explicit.example.test"
869 );
870 assert_eq!(
871 select_auth_start_api_base(
872 &json!({ "api_base": " " }),
873 Some(" https://environment.example.test/ "),
874 ),
875 "https://environment.example.test"
876 );
877 assert_eq!(
878 select_auth_start_api_base(&json!({}), Some(" ")),
879 car_auth::DEFAULT_API_BASE
880 );
881 assert_eq!(car_secrets::secret_store_activity(), before);
882 }
883
884 fn action(timeout_ms: u64, failure_behavior: &str) -> Value {
886 json!({
887 "id": "a1",
888 "type": "tool_call",
889 "tool": "shell",
890 "parameters": {},
891 "failure_behavior": failure_behavior,
892 "timeout_ms": timeout_ms,
893 })
894 }
895
896 fn submit_params(actions: Vec<Value>) -> Value {
897 json!({ "proposal": { "id": "p1", "goal": "g", "actions": actions } })
898 }
899
900 #[test]
906 fn proposal_submit_deadline_covers_a_retried_actions_full_budget() {
907 let params = submit_params(vec![action(600_000, "retry")]);
908 let deadline =
909 server_deadline_for("proposal.submit", ¶ms).expect("proposal.submit is bounded");
910
911 assert!(
913 deadline > Duration::from_secs(2400),
914 "must outlast the executor's own 2400s worst case, got {deadline:?}"
915 );
916 assert!(
917 deadline > Duration::from_secs(1800),
918 "must RAISE the flat 1800s generous default, got {deadline:?}"
919 );
920 assert_eq!(
921 deadline,
922 Duration::from_secs(2400 + PROPOSAL_DEADLINE_GRACE_SECS)
923 .max(Duration::from_secs(handler_default_deadline_secs())),
924 "derived deadline is the action budget plus the server transport grace"
925 );
926 }
927
928 #[test]
932 fn proposal_submit_deadline_covers_chained_action_budgets() {
933 let params = submit_params(vec![
934 action(900_000, "abort"),
935 action(900_000, "abort"),
936 action(300_000, "skip"),
937 ]);
938 let sum = Duration::from_secs(900 + 900 + 300);
939 let deadline =
940 server_deadline_for("proposal.submit", ¶ms).expect("proposal.submit is bounded");
941
942 assert!(
943 deadline > sum,
944 "chained budgets summing to {sum:?} must not be reaped, got {deadline:?}"
945 );
946 assert!(
947 deadline > Duration::from_secs(1800),
948 "a 2100s chain must RAISE the flat 1800s default, got {deadline:?}"
949 );
950 assert_eq!(
951 deadline,
952 (sum + Duration::from_secs(PROPOSAL_DEADLINE_GRACE_SECS))
953 .max(Duration::from_secs(handler_default_deadline_secs()))
954 );
955 }
956
957 #[test]
961 fn a_short_proposal_keeps_exactly_the_generous_default() {
962 let params = submit_params(vec![action(5_000, "abort")]);
963 assert_eq!(
964 server_deadline_for("proposal.submit", ¶ms),
965 Some(Duration::from_secs(handler_default_deadline_secs())),
966 "5s of budget must not lower the deadline below the generous default"
967 );
968 }
969
970 #[test]
974 fn actions_without_an_explicit_budget_use_the_daemon_tool_default() {
975 let params = json!({
976 "proposal": {
977 "actions": [
978 { "id": "a1", "type": "tool_call", "failure_behavior": "retry" },
979 { "id": "a2", "type": "tool_call", "failure_behavior": "retry" },
980 ]
981 }
982 });
983 let expected = 2 * 4 * (crate::session::DEFAULT_TOOL_TIMEOUT_MS / 1000);
985 assert_eq!(
986 server_deadline_for("proposal.submit", ¶ms),
987 Some(
988 Duration::from_secs(expected + PROPOSAL_DEADLINE_GRACE_SECS)
989 .max(Duration::from_secs(handler_default_deadline_secs()))
990 )
991 );
992 }
993
994 #[test]
1000 fn server_grace_is_strictly_inside_the_client_transport_grace() {
1001 assert!(
1002 PROPOSAL_DEADLINE_GRACE_SECS < 30,
1003 "server grace must fire before the client's 30s PROPOSAL_TRANSPORT_GRACE_SECS"
1004 );
1005 }
1006
1007 #[test]
1010 fn derived_retry_default_matches_car_ir() {
1011 let action: car_ir::Action = serde_json::from_value(json!({
1012 "id": "a1",
1013 "type": "tool_call",
1014 "tool": "shell",
1015 }))
1016 .expect("minimal action should deserialize");
1017 assert_eq!(u64::from(action.max_retries), DEFAULT_PROPOSAL_MAX_RETRIES);
1018 }
1019
1020 #[test]
1021 fn classifies_methods_into_expected_buckets() {
1022 let default = Duration::from_secs(handler_default_deadline_secs());
1023 let fast = Duration::from_secs(DEADLINE_FAST_SECS);
1024
1025 for m in [
1027 "infer_stream",
1028 "runs.subscribe",
1029 "scheduler.run_loop",
1030 "voice.dispatch_turn",
1031 "voice.tts_stream.start",
1032 ] {
1033 assert_eq!(
1034 server_deadline_for(m, &Value::Null),
1035 None,
1036 "{m} should be exempt"
1037 );
1038 }
1039 for method in DAEMON_OWNED_AUTH_METHODS {
1040 assert!(
1041 is_daemon_owned_auth_method(method),
1042 "{method} must bypass connection-owned dispatch"
1043 );
1044 }
1045 for m in [
1047 "memory.query",
1048 "memory.evaluate",
1049 "memory.intervene",
1050 "agents.list",
1051 "meeting.get",
1052 "runs.list",
1053 "agents.health",
1054 "server.handshake",
1055 ] {
1056 assert_eq!(
1057 server_deadline_for(m, &Value::Null),
1058 Some(fast),
1059 "{m} should be fast"
1060 );
1061 }
1062 for m in [
1067 "memory.persist",
1068 "memory.add_fact",
1069 "memory.update_status",
1070 "memory.maintain",
1071 "memory.save_knowledge",
1072 "memory.save_procedural",
1073 "memory.delete",
1074 "memory.consolidate",
1075 "proposal.submit",
1076 "workflow.run",
1077 "multi.swarm",
1078 ] {
1079 assert_eq!(
1080 server_deadline_for(m, &Value::Null),
1081 Some(default),
1082 "{m} should take the generous default"
1083 );
1084 }
1085 }
1086
1087 #[tokio::test(start_paused = true)]
1088 async fn bounded_handler_deadline_has_a_distinct_json_rpc_code() {
1089 let task = tokio::spawn(async {
1090 dispatch_with_server_deadline(
1091 "memory.query",
1092 &Value::Null,
1093 std::future::pending::<Result<Value, String>>(),
1094 )
1095 .await
1096 });
1097
1098 tokio::time::advance(Duration::from_secs(DEADLINE_FAST_SECS)).await;
1099 let failure = task
1100 .await
1101 .expect("deadline task should join")
1102 .expect_err("pending handler should exceed its deadline");
1103
1104 assert!(matches!(failure, HandlerFailure::Deadline(_)));
1105 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1106 }
1107
1108 #[test]
1109 fn auth_state_response_bound_exceeds_every_serial_storage_component() {
1110 let component_total = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1111 + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1112 + car_auth::AUTH_STATE_OPERATION_BUDGET
1113 + car_auth::AUTH_STATE_OPERATION_BUDGET;
1114 assert!(
1115 AUTH_STATE_RESPONSE_DEADLINE > component_total,
1116 "auth state response bound must include scheduling margin"
1117 );
1118 }
1119
1120 #[test]
1121 fn one_safe_preclaim_retry_and_proof_fit_the_host_reconciliation_horizon() {
1122 let first_safe_retry_budget =
1127 car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT + car_auth::AUTH_PROCESS_LOCK_TIMEOUT;
1128 let pre_lease_claim_budget = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1129 + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1130 + car_auth::AUTH_STATE_OPERATION_BUDGET;
1131 let hydration_reserve = Duration::from_secs(30);
1132 let total_budget = first_safe_retry_budget
1133 + pre_lease_claim_budget
1134 + car_auth::LOGIN_ATTEMPT_WORKER_TTL
1135 + AUTH_STATE_RESPONSE_DEADLINE
1136 + hydration_reserve;
1137
1138 assert!(
1139 total_budget < AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON,
1140 "one safe preclaim retry must still expire, prove, and hydrate before the host stops"
1141 );
1142 }
1143
1144 #[tokio::test(start_paused = true)]
1145 async fn proof_response_timeout_reports_that_daemon_reconciliation_continues() {
1146 let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1147 let task = tokio::spawn(await_daemon_owned_auth_result(
1148 "auth.completion_status",
1149 result_rx,
1150 ));
1151
1152 tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1153 let failure = task
1154 .await
1155 .expect("proof response task should join")
1156 .expect_err("an unpublished proof must hit its explicit response bound");
1157
1158 assert!(matches!(failure, HandlerFailure::Deadline(_)));
1159 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1160 assert!(failure.message().contains("reconciliation continues"));
1161 }
1162
1163 #[tokio::test(start_paused = true)]
1164 async fn auth_start_and_complete_have_explicit_state_response_bounds() {
1165 for method in ["auth.start", "auth.complete"] {
1166 let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1167 let task = tokio::spawn(await_daemon_owned_auth_result(method, result_rx));
1168
1169 tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1170 let failure = task
1171 .await
1172 .expect("auth response task should join")
1173 .expect_err("unpublished auth state response must hit its explicit bound");
1174
1175 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1176 assert!(failure.message().contains("95s"), "{method}: {failure:?}");
1177 }
1178 }
1179
1180 #[test]
1181 fn coordination_deadlines_are_retryable_but_terminal_state_errors_are_not() {
1182 for (method, prefix) in [
1183 ("auth.start", AUTH_START_RETRY_PREFIX),
1184 ("auth.complete", AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX),
1185 ("auth.completion_status", AUTH_STATUS_RETRY_PREFIX),
1186 ] {
1187 let failure = classified_auth_failure(
1188 method,
1189 car_auth::AuthOperationError::CoordinationDeadline("contended".into()),
1190 );
1191 assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1192 assert!(failure.message().starts_with(prefix), "{failure:?}");
1193 }
1194
1195 let failure = classified_auth_failure(
1196 "auth.completion_status",
1197 car_auth::AuthOperationError::Terminal("corrupt state".into()),
1198 );
1199 assert_eq!(failure.json_rpc_code(), -32603);
1200 assert_eq!(failure.message(), "corrupt state");
1201 }
1202
1203 #[tokio::test(start_paused = true)]
1204 async fn auth_completion_network_phase_has_an_injectable_total_deadline() {
1205 let task = tokio::spawn(auth_completion_network_with_deadline(
1206 Duration::from_secs(1),
1207 std::future::pending::<Result<(), String>>(),
1208 ));
1209
1210 tokio::time::advance(Duration::from_secs(1)).await;
1211 let error = task
1212 .await
1213 .expect("network deadline task should join")
1214 .expect_err("stalled auth network work must time out");
1215
1216 assert_eq!(
1217 error,
1218 "Parslee login network phase timed out after 1s; no credentials were saved"
1219 );
1220 }
1221
1222 #[test]
1223 fn protocol_routing_failures_use_standard_json_rpc_codes() {
1224 let unknown =
1225 HandlerFailure::from_dispatch("unknown method: future.namespace.call".to_string());
1226 assert!(matches!(unknown, HandlerFailure::MethodNotFound(_)));
1227 assert_eq!(unknown.json_rpc_code(), -32601);
1228
1229 let invalid = HandlerFailure::from_dispatch("invalid params: key is required".to_string());
1230 assert!(matches!(invalid, HandlerFailure::InvalidParams(_)));
1231 assert_eq!(invalid.json_rpc_code(), -32602);
1232
1233 let wire =
1234 serde_json::to_value(json_rpc_response_for_handler_result(json!(7), Err(invalid)))
1235 .expect("serialize response");
1236 assert_eq!(wire["error"]["code"], -32602);
1237 assert_eq!(wire["error"]["message"], "invalid params: key is required");
1238 }
1239
1240 #[tokio::test]
1241 async fn ordinary_handler_failure_remains_internal_error() {
1242 let failure = dispatch_with_server_deadline(
1243 "auth.complete",
1244 &Value::Null,
1245 std::future::ready(Err("token exchange failed".to_string())),
1246 )
1247 .await
1248 .expect_err("handler failure should propagate");
1249
1250 assert!(matches!(failure, HandlerFailure::Dispatch(_)));
1251 assert_eq!(failure.json_rpc_code(), -32603);
1252 }
1253
1254 #[test]
1255 fn a_gateway_content_refusal_survives_the_trip_to_its_own_error_code() {
1256 let refusal = car_inference::InferenceError::ContentRefused {
1257 provider: "parslee".to_string(),
1258 kind: Some("invalid_request_error".to_string()),
1259 code: Some("content_policy_violation".to_string()),
1260 message: "The response was filtered due to the prompt triggering the content \
1261 management policy"
1262 .to_string(),
1263 };
1264
1265 let failure = HandlerFailure::from_dispatch(inference_dispatch_error(&refusal));
1268
1269 assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1270 assert_eq!(
1271 failure.json_rpc_code(),
1272 car_proto::CONTENT_REFUSED_ERROR_CODE
1273 );
1274 assert!(
1277 failure.message().contains("code=content_policy_violation"),
1278 "{failure:?}"
1279 );
1280 assert!(
1281 failure
1282 .message()
1283 .starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
1284 "{failure:?}"
1285 );
1286 }
1287
1288 #[test]
1289 fn ordinary_inference_failures_are_not_classified_as_content_refusals() {
1290 let generic: Vec<car_inference::InferenceError> = vec![
1295 car_inference::InferenceError::InferenceFailed("managed inference failed".to_string()),
1296 car_inference::InferenceError::InferenceFailed(
1297 "content refused by the operator's local policy".to_string(),
1298 ),
1299 car_inference::InferenceError::ModelNotFound("gpt-5.6-sol".to_string()),
1300 car_inference::InferenceError::TokenizationError("bad utf-8 boundary".to_string()),
1301 ];
1302
1303 assert_eq!(
1306 generic[0].to_string(),
1307 "inference failed: managed inference failed"
1308 );
1309
1310 for error in &generic {
1311 let failure = HandlerFailure::from_dispatch(inference_dispatch_error(error));
1312 assert!(
1313 matches!(failure, HandlerFailure::Dispatch(_)),
1314 "must stay a generic dispatch failure: {failure:?}"
1315 );
1316 assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1317 }
1318 }
1319
1320 #[test]
1321 fn a_refusal_that_lands_mid_stream_reaches_the_same_error_code() {
1322 let flattened = "The response was filtered due to the prompt triggering the content \
1325 management policy (type=invalid_request_error, \
1326 code=content_policy_violation)";
1327
1328 let failure = HandlerFailure::from_dispatch(stream_dispatch_error(flattened.to_string()));
1329
1330 assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1331 assert_eq!(
1332 failure.json_rpc_code(),
1333 car_proto::CONTENT_REFUSED_ERROR_CODE
1334 );
1335 assert!(failure.message().contains(flattened), "{failure:?}");
1337 }
1338
1339 #[test]
1340 fn an_untagged_stream_failure_is_not_promoted_to_a_refusal() {
1341 for plain in [
1345 "managed inference failed",
1346 "upstream connection reset",
1347 "the model discussed content policy at length",
1349 "boom (type=server_error, code=internal)",
1351 ] {
1352 let failure = HandlerFailure::from_dispatch(stream_dispatch_error(plain.to_string()));
1353 assert!(
1354 matches!(failure, HandlerFailure::Dispatch(_)),
1355 "must stay generic: {failure:?}"
1356 );
1357 assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1358 }
1359 }
1360
1361 #[test]
1362 fn a_content_refusal_is_distinguishable_from_a_handler_deadline() {
1363 let refusal = HandlerFailure::from_dispatch(inference_dispatch_error(
1364 &car_inference::InferenceError::ContentRefused {
1365 provider: "parslee".to_string(),
1366 kind: None,
1367 code: None,
1368 message: "declined".to_string(),
1369 },
1370 ));
1371 let deadline = HandlerFailure::Deadline("handler deadline".to_string());
1372
1373 assert_eq!(
1374 refusal.json_rpc_code(),
1375 car_proto::CONTENT_REFUSED_ERROR_CODE
1376 );
1377 assert_eq!(deadline.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1378 assert_ne!(refusal.json_rpc_code(), deadline.json_rpc_code());
1379 assert_ne!(refusal.json_rpc_code(), -32603);
1380 }
1381
1382 #[test]
1383 fn deadline_failure_serializes_as_the_actual_json_rpc_envelope() {
1384 let response = json_rpc_response_for_handler_result(
1385 json!(42),
1386 Err(HandlerFailure::Deadline("handler deadline".to_string())),
1387 );
1388 let wire = serde_json::to_value(response).expect("serialize response");
1389
1390 assert_eq!(
1391 wire,
1392 json!({
1393 "jsonrpc": "2.0",
1394 "error": {
1395 "code": HANDLER_DEADLINE_ERROR_CODE,
1396 "message": "handler deadline",
1397 },
1398 "id": 42,
1399 })
1400 );
1401 }
1402
1403 #[test]
1404 fn attempt_completion_wire_value_carries_the_generation_and_session() {
1405 let record = car_auth::AuthCompletionRecord {
1406 attempt_id: "attempt-7".to_string(),
1407 generation: 19,
1408 account_id: Some("account-b".to_string()),
1409 session: Some(r#"{"Account":{"Id":"account-b"}}"#.to_string()),
1410 };
1411
1412 assert_eq!(
1413 auth_completion_value(&record),
1414 json!({
1415 "state": "complete",
1416 "attempt_id": "attempt-7",
1417 "generation": 19,
1418 "account_id": "account-b",
1419 "session": {"Account": {"Id": "account-b"}},
1420 })
1421 );
1422 }
1423}
1424
1425#[instrument(
1435 name = "ws.dispatch",
1436 skip_all,
1437 fields(client_id = tracing::field::Empty, peer = %peer),
1438)]
1439pub async fn run_dispatch<R>(
1440 mut read: R,
1441 write: crate::session::WsSink,
1442 peer: String,
1443 state: Arc<ServerState>,
1444) -> Result<(), Box<dyn std::error::Error>>
1445where
1446 R: futures::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
1447 + Unpin
1448 + Send,
1449{
1450 let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
1451 tracing::Span::current().record("client_id", client_id.as_str());
1452
1453 info!("New connection from {}", peer);
1454
1455 let channel = Arc::new(WsChannel {
1456 write: Mutex::new(write),
1457 pending: Mutex::new(HashMap::new()),
1458 active_actions: Mutex::new(HashMap::new()),
1459 next_id: AtomicU64::new(1),
1460 });
1461
1462 let session = match state.create_session(&client_id, channel.clone()).await {
1467 Ok(session) => session,
1468 Err(e) => {
1469 error!("refusing connection from {}: {}", peer, e);
1470 return Err(e.into());
1471 }
1472 };
1473
1474 let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1483
1484 let mut openrouter_updates = crate::openrouter_auth::subscribe_authority_updates();
1490 let openrouter_session = session.clone();
1491 let openrouter_auth_required = state.auth_token.get().is_some();
1492 conn_tasks.spawn(async move {
1493 loop {
1494 if openrouter_updates.changed().await.is_err() {
1495 break;
1496 }
1497 if openrouter_auth_required
1498 && !openrouter_session
1499 .authenticated
1500 .load(std::sync::atomic::Ordering::Acquire)
1501 {
1502 continue;
1503 }
1504 let Some(status) = openrouter_updates.borrow_and_update().clone() else {
1505 continue;
1506 };
1507 let notification = serde_json::json!({
1508 "jsonrpc": "2.0",
1509 "method": "openrouter.auth.event",
1510 "params": status,
1511 });
1512 let Ok(text) = serde_json::to_string(¬ification) else {
1513 continue;
1514 };
1515 if openrouter_session
1516 .channel
1517 .write
1518 .lock()
1519 .await
1520 .send(Message::Text(text.into()))
1521 .await
1522 .is_err()
1523 {
1524 break;
1525 }
1526 }
1527 });
1528
1529 let credential_session = session.clone();
1535 let credential_transport_auth_required = state.auth_token.get().is_some();
1536 let credential_host_role_required = state.host_token.get().is_some();
1537 let (credential_activation_tx, mut credential_activation_rx) =
1538 tokio::sync::watch::channel(false);
1539 let (credential_ready_tx, credential_ready_rx) = tokio::sync::watch::channel(false);
1540 let (credential_lag_tx, mut credential_lag_rx) = tokio::sync::oneshot::channel();
1541 conn_tasks.spawn(async move {
1542 pause_credential_fanout_for_test().await;
1543 let mut credential_lag_tx = Some(credential_lag_tx);
1544
1545 while !credential_event_is_eligible(
1552 &credential_session,
1553 credential_transport_auth_required,
1554 credential_host_role_required,
1555 ) {
1556 if credential_activation_rx.changed().await.is_err() {
1557 return;
1558 }
1559 }
1560
1561 let car_auth::CredentialReadEventHandoff {
1567 snapshot,
1568 events: mut credential_events,
1569 } = car_auth::subscribe_credential_read_event_handoff();
1570 pause_credential_handoff_for_test().await;
1571 let mut reconciled = snapshot.into_iter();
1572 let mut last_status = None;
1573 let _ = credential_ready_tx.send(true);
1574 loop {
1575 let status = match reconciled.next() {
1576 Some(status) => status,
1577 None => {
1578 let closed = credential_events.closed();
1579 tokio::select! {
1580 biased;
1581 reason = closed => {
1582 if let Some(sender) = credential_lag_tx.take() {
1583 let _ = sender.send(reason);
1584 }
1585 break;
1586 }
1587 event = credential_events.recv() => match event {
1588 Ok(status) => status,
1589 Err(reason) => {
1590 if let Some(sender) = credential_lag_tx.take() {
1591 let _ = sender.send(reason);
1592 }
1593 break;
1594 }
1595 }
1596 }
1597 }
1598 };
1599 if !credential_status_advances(last_status, status) {
1600 continue;
1601 }
1602 let notification = serde_json::json!({
1603 "jsonrpc": "2.0",
1604 "method": "auth.credential.event",
1605 "params": status,
1606 });
1607 let Ok(text) = serde_json::to_string(¬ification) else {
1608 continue;
1609 };
1610 let closed = credential_events.closed();
1611 tokio::select! {
1612 biased;
1613 reason = closed => {
1614 if let Some(sender) = credential_lag_tx.take() {
1615 let _ = sender.send(reason);
1616 }
1617 break;
1618 }
1619 result = async {
1620 credential_session
1621 .channel
1622 .write
1623 .lock()
1624 .await
1625 .send(Message::Text(text.into()))
1626 .await
1627 } => {
1628 if result.is_err() {
1629 break;
1630 }
1631 last_status = Some(status);
1632 }
1633 }
1634 }
1635 });
1636
1637 let mut keepalive = tokio::time::interval(KEEPALIVE_PING_INTERVAL);
1648 keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1649 keepalive.tick().await;
1652 let mut last_inbound = tokio::time::Instant::now();
1653 loop {
1654 while conn_tasks.try_join_next().is_some() {}
1657
1658 let msg = tokio::select! {
1662 closed = &mut credential_lag_rx => {
1663 match closed {
1664 Ok(reason) => info!(
1665 client = %client_id,
1666 ?reason,
1667 "credential event subscriber closed; terminating connection"
1668 ),
1669 Err(_) => info!(
1670 client = %client_id,
1671 "credential event monitor ended; terminating connection"
1672 ),
1673 }
1674 break;
1675 }
1676 maybe = read.next() => match maybe {
1677 Some(Ok(m)) => {
1680 last_inbound = tokio::time::Instant::now();
1681 m
1682 }
1683 Some(Err(e)) => {
1692 info!("read error from {}: {}; closing", client_id, e);
1693 break;
1694 }
1695 None => break,
1696 },
1697 _ = keepalive.tick() => {
1698 if last_inbound.elapsed() >= KEEPALIVE_DEAD_AFTER {
1699 info!(
1700 "keepalive: no frames from {} for {}s; closing",
1701 client_id,
1702 last_inbound.elapsed().as_secs()
1703 );
1704 break;
1705 }
1706 let sent = tokio::time::timeout(KEEPALIVE_WRITE_TIMEOUT, async {
1715 let mut guard = channel.write.lock().await;
1716 guard.send(Message::Ping(Vec::new().into())).await
1717 })
1718 .await;
1719 if !matches!(sent, Ok(Ok(()))) {
1720 info!("keepalive: ping to {} failed/timed out; closing", client_id);
1721 break;
1722 }
1723 continue;
1724 }
1725 };
1726 if msg.is_text() {
1727 let text = match msg.to_text() {
1728 Ok(t) => t,
1729 Err(e) => {
1730 info!("non-text frame from {}: {}; closing", client_id, e);
1731 break;
1732 }
1733 };
1734 let parsed: JsonRpcMessage = match serde_json::from_str(text) {
1735 Ok(m) => m,
1736 Err(e) => {
1737 send_response(
1738 &session.channel,
1739 JsonRpcResponse::error(Value::Null, -32700, &format!("Parse error: {}", e)),
1740 )
1741 .await
1742 .ok();
1743 continue;
1744 }
1745 };
1746
1747 if parsed.method.is_none() && (parsed.result.is_some() || parsed.error.is_some()) {
1749 if let Some(id_str) = parsed.id.as_str() {
1750 let mut pending = session.channel.pending.lock().await;
1751 if let Some(tx) = pending.remove(id_str) {
1752 let tool_resp = if let Some(result) = parsed.result {
1753 ToolExecuteResponse {
1754 action_id: id_str.to_string(),
1755 output: Some(result),
1756 error: None,
1757 terminal: false,
1758 }
1759 } else {
1760 let err_msg = parsed
1761 .error
1762 .as_ref()
1763 .and_then(|e| e.get("message"))
1764 .and_then(|m| m.as_str())
1765 .unwrap_or("unknown error")
1766 .to_string();
1767 let terminal = parsed
1770 .error
1771 .as_ref()
1772 .and_then(|error| error.get("data"))
1773 .and_then(|data| data.get("terminal"))
1774 .and_then(Value::as_bool)
1775 .unwrap_or(false);
1776 ToolExecuteResponse {
1777 action_id: id_str.to_string(),
1778 output: None,
1779 error: Some(err_msg),
1780 terminal,
1781 }
1782 };
1783 let _ = tx.send(tool_resp);
1784 continue;
1785 }
1786 }
1787 }
1788
1789 if let Some(method) = parsed.method.as_deref() {
1796 if !agent_method_scope_allows(&session, method) {
1797 if !parsed.id.is_null() {
1798 let response = JsonRpcResponse::error(
1799 parsed.id.clone(),
1800 AGENT_METHOD_NOT_ALLOWED_ERROR_CODE,
1801 &format!("{AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX}`{method}`"),
1802 );
1803 let _ = send_response(&session.channel, response).await;
1804 }
1805 info!(
1806 client = %client_id,
1807 method = %method,
1808 "agent_method_not_allowed: scoped supervised-agent session denied before dispatch"
1809 );
1810 continue;
1811 }
1812 }
1813
1814 if try_forward_agent_chat_event(&parsed, &state).await {
1823 continue;
1824 }
1825
1826 if crate::browser_relay::try_handle_producer_push(&parsed, &state, &session).await {
1831 continue;
1832 }
1833
1834 if let Some(method) = &parsed.method {
1836 info!(method = %method, "dispatching JSON-RPC method");
1837
1838 if state.auth_token.get().is_some()
1846 && !session
1847 .authenticated
1848 .load(std::sync::atomic::Ordering::Acquire)
1849 && method != "session.auth"
1850 {
1851 let resp =
1852 JsonRpcResponse::error(parsed.id.clone(), -32001, &auth_required_message());
1853 let _ = send_response(&session.channel, resp).await;
1854 info!(client = %client_id, method = %method,
1855 "rejecting non-auth method on unauthenticated session; closing");
1856 break;
1857 }
1858
1859 if method == "session.auth"
1865 && parsed
1866 .params
1867 .get("agent_id")
1868 .and_then(Value::as_str)
1869 .is_some()
1870 {
1871 let result = dispatch_with_server_deadline(
1872 method,
1873 &parsed.params,
1874 handle_session_auth(&parsed, &session, &state),
1875 )
1876 .await;
1877 if result.is_ok()
1878 && credential_event_is_eligible(
1879 &session,
1880 credential_transport_auth_required,
1881 credential_host_role_required,
1882 )
1883 {
1884 let _ = credential_activation_tx.send(true);
1885 let mut ready = credential_ready_rx.clone();
1886 while !*ready.borrow_and_update() {
1887 if ready.changed().await.is_err() {
1888 break;
1889 }
1890 }
1891 }
1892 let response = json_rpc_response_for_handler_result(parsed.id, result);
1893 let _ = send_response(&session.channel, response).await;
1894 continue;
1895 }
1896
1897 if method == "server.handshake" {
1905 let (response, handshake_succeeded) =
1906 match handle_server_handshake(&parsed, &session) {
1907 Ok(result) => {
1908 (JsonRpcResponse::success(parsed.id.clone(), result), true)
1909 }
1910 Err(error) => (
1911 JsonRpcResponse::error(
1912 parsed.id.clone(),
1913 error.code,
1914 &error.message,
1915 ),
1916 false,
1917 ),
1918 };
1919 if handshake_succeeded
1920 && credential_event_is_eligible(
1921 &session,
1922 credential_transport_auth_required,
1923 credential_host_role_required,
1924 )
1925 {
1926 let _ = credential_activation_tx.send(true);
1927 let mut ready = credential_ready_rx.clone();
1928 while !*ready.borrow_and_update() {
1929 if ready.changed().await.is_err() {
1930 break;
1931 }
1932 }
1933 }
1934 let _ = send_response(&session.channel, response).await;
1935 continue;
1936 }
1937
1938 if requires_protocol_handshake(method)
1939 && session
1940 .negotiated_protocol_version
1941 .load(std::sync::atomic::Ordering::Acquire)
1942 != car_proto::PROTOCOL_VERSION
1943 {
1944 let message = format!(
1945 "{} call `server.handshake` with {{\"protocol_version\":{}}} \
1946 and wait for an exact-version success before `{}`",
1947 car_proto::PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX,
1948 car_proto::PROTOCOL_VERSION,
1949 method,
1950 );
1951 let response = JsonRpcResponse::error(
1952 parsed.id.clone(),
1953 car_proto::PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE,
1954 &message,
1955 );
1956 let _ = send_response(&session.channel, response).await;
1957 info!(
1958 client = %client_id,
1959 method = %method,
1960 "rejecting handshake-gated method before negotiation"
1961 );
1962 continue;
1963 }
1964
1965 if crate::HOST_MANAGEMENT_METHODS.contains(&method.as_str()) {
1971 if let Err(error) = require_approval_authority(&session, &state) {
1972 let response = json_rpc_response_for_handler_result(
1973 parsed.id.clone(),
1974 Err(HandlerFailure::Dispatch(error)),
1975 );
1976 let _ = send_response(&session.channel, response).await;
1977 continue;
1978 }
1979 }
1980
1981 if state.approval_gate.requires_approval(method.as_str()) {
1993 match gate_high_risk_method(method.as_str(), &parsed.params, &state).await {
1994 Ok(()) => {}
1995 Err(reason) => {
1996 let resp = JsonRpcResponse::error(parsed.id.clone(), -32003, &reason);
1997 let _ = send_response(&session.channel, resp).await;
1998 info!(
1999 client = %client_id,
2000 method = %method,
2001 reason = %reason,
2002 "approval gate blocked dispatch"
2003 );
2004 continue;
2005 }
2006 }
2007 }
2008
2009 if is_daemon_owned_auth_method(method) {
2017 let response_id = parsed.id.clone();
2018 if let Err(error) = require_approval_authority(&session, &state) {
2019 let response = json_rpc_response_for_handler_result(
2020 response_id,
2021 Err(HandlerFailure::Dispatch(error)),
2022 );
2023 let _ = send_response(&session.channel, response).await;
2024 continue;
2025 }
2026
2027 let method_owned = method.clone();
2028 let method_for_waiter = method_owned.clone();
2029 let parsed_owned = parsed;
2030 let state_owned = state.clone();
2031 let result_rx = state
2032 .spawn_durable_operation(method_owned.clone(), async move {
2033 dispatch_daemon_owned_auth(
2034 method_owned.as_str(),
2035 &parsed_owned,
2036 &state_owned,
2037 )
2038 .await
2039 })
2040 .await;
2041 let response_channel = session.channel.clone();
2042 conn_tasks.spawn(async move {
2043 let result =
2044 await_daemon_owned_auth_result(method_for_waiter.as_str(), result_rx)
2045 .await;
2046 let response = json_rpc_response_for_handler_result(response_id, result);
2047 let _ = send_response(&response_channel, response).await;
2048 });
2049 continue;
2050 }
2051
2052 let session_task = session.clone();
2067 let state_task = state.clone();
2068 let method_owned = method.clone();
2069 let parsed_task = parsed;
2070 let credential_activation_tx_task = credential_activation_tx.clone();
2071 let credential_ready_rx_task = credential_ready_rx.clone();
2072 conn_tasks.spawn(async move {
2075 let session = session_task;
2076 let state = state_task;
2077 let parsed = parsed_task;
2078 let bound_agent = session.agent_id.lock().await.clone();
2092 let bound_agent = bound_agent.as_deref();
2093 let dispatch = async {
2097 match method_owned.as_str() {
2098 "session.auth" => handle_session_auth(&parsed, &session, &state).await,
2099 "capabilities.list" => Ok(handle_capabilities_list(
2100 bound_agent,
2101 session.is_host.load(std::sync::atomic::Ordering::Acquire),
2102 )),
2103 "parslee.auth" => handle_parslee_auth(&state).await,
2104 "parslee.capabilities" => crate::parslee_capabilities::discover().await,
2105 "parslee.m365.generate_document" => {
2106 crate::parslee_m365::generate_document(&parsed.params).await
2107 }
2108 "openrouter.status" => handle_openrouter_status(&session, &state),
2109 "openrouter.auth_start" => {
2110 handle_openrouter_auth_start(&parsed, &session, &state).await
2111 }
2112 "openrouter.auth_cancel" => {
2113 handle_openrouter_auth_cancel(&parsed, &session, &state)
2114 }
2115 "openrouter.disconnect" => {
2116 handle_openrouter_disconnect(&session, &state).await
2117 }
2118 "session.init" => handle_session_init(&parsed, &session).await,
2119 "session.bindSandbox" => {
2120 handle_session_bind_sandbox(&parsed, &session, &state).await
2121 }
2122 "session.bindSubstrate" => {
2123 handle_session_bind_substrate(&parsed, &session, &state).await
2124 }
2125 "session.clear_halt" => handle_session_clear_halt(&session).await,
2126 "host.subscribe" => handle_host_subscribe(&session, &state).await,
2127 "supervision.subscribe" => {
2128 handle_supervision_subscribe(&parsed, &session, &state).await
2129 }
2130 "supervision.unsubscribe" => {
2131 handle_supervision_unsubscribe(&session, &state).await
2132 }
2133 "supervision.pending" => handle_supervision_pending(&state).await,
2134 "supervision.decide" => {
2135 handle_supervision_decide(&parsed, &state).await
2136 }
2137 "host.agents" => handle_host_agents(&session, &state).await,
2138 "host.events" => handle_host_events(&parsed, &session).await,
2139 "host.approvals" => handle_host_approvals(&session).await,
2140 "host.register_agent" => {
2141 handle_host_register_agent(&parsed, &session).await
2142 }
2143 "host.unregister_agent" => {
2144 handle_host_unregister_agent(&parsed, &session).await
2145 }
2146 "host.set_status" => handle_host_set_status(&parsed, &session).await,
2147 "host.register_device" => {
2148 handle_host_register_device(&parsed, &session).await
2149 }
2150 "host.update_device" => {
2151 handle_host_update_device(&parsed, &session).await
2152 }
2153 "host.devices" => handle_host_devices(&session).await,
2154 "mobile.runtime" => handle_mobile_runtime(&state).await,
2155 "host.notify" => handle_host_notify(&parsed, &session).await,
2156 "host.request_approval" => {
2157 handle_host_request_approval(&parsed, &session).await
2158 }
2159 "host.resolve_approval" => {
2160 handle_host_resolve_approval(&parsed, &session).await
2161 }
2162 "tools.register" => handle_tools_register(&parsed, &session).await,
2163 "tools.list" => handle_tools_list(&session).await,
2164 "tools.unregister" => handle_tools_unregister(&parsed, &session).await,
2165 "tools.poll" => handle_tools_poll(&parsed, &session).await,
2166 "tools.cancel" => handle_tools_cancel(&parsed, &session).await,
2167 "tools.stream.subscribe" => {
2168 handle_tools_stream_subscribe(&session).await
2169 }
2170 "proposal.submit" => {
2171 handle_proposal_submit(&parsed, &session, &state).await
2172 }
2173 "policy.register" => handle_policy_register(&parsed, &session).await,
2174 "policy.unregister" => {
2175 handle_policy_unregister(&parsed, &session).await
2176 }
2177 "policy.list" => handle_policy_list(&parsed, &session).await,
2178 "session.policy.open" => handle_session_policy_open(&session).await,
2179 "session.policy.close" => {
2180 handle_session_policy_close(&parsed, &session).await
2181 }
2182 "verify" => handle_verify(&parsed, &session).await,
2183 "verify.monte_carlo" => {
2184 handle_verify_monte_carlo(&parsed, &session).await
2185 }
2186 "permission.get_tier" => handle_permission_get_tier(&session).await,
2187 "permission.set_tier" => {
2188 handle_permission_set_tier(&parsed, &session, &state).await
2189 }
2190 "permission.classify" => {
2191 handle_permission_classify(&parsed, &session).await
2192 }
2193 "permission.evaluate" => {
2194 handle_permission_evaluate(&parsed, &session, &state).await
2195 }
2196 "permission.pending" => {
2197 handle_permission_pending(&parsed, &session, &state).await
2198 }
2199 "permission.approve" => {
2200 handle_permission_decision(&parsed, &session, &state, true).await
2201 }
2202 "permission.reject" => {
2203 handle_permission_decision(&parsed, &session, &state, false).await
2204 }
2205 "agent_permissions.get" => {
2206 crate::agent_permissions::handle_get(&parsed)
2207 }
2208 "agent_permissions.set" => {
2209 require_agent_permissions_authority(&session, &state).await?;
2210 crate::agent_permissions::handle_set(&parsed)
2211 }
2212 "agent_permissions.set_default" => {
2213 require_agent_permissions_authority(&session, &state).await?;
2214 crate::agent_permissions::handle_set_default(&parsed)
2215 }
2216 "agent_permissions.reset" => {
2217 require_agent_permissions_authority(&session, &state).await?;
2218 crate::agent_permissions::handle_reset(&parsed)
2219 }
2220 "agent_permissions.evaluate" => {
2221 crate::agent_permissions::handle_evaluate(&parsed)
2222 }
2223 "agent_permissions.set_tool" => {
2224 require_agent_permissions_authority(&session, &state).await?;
2225 require_agent_tool_override_capability(&session)?;
2226 handle_agent_permission_set_tool(&parsed, &state).await
2227 }
2228 "agent_permissions.reset_tool" => {
2229 require_agent_permissions_authority(&session, &state).await?;
2230 require_agent_tool_override_capability(&session)?;
2231 crate::agent_permissions::handle_reset_tool(&parsed)
2232 }
2233 "agent_permissions.evaluate_tool" => {
2234 require_agent_permissions_authority(&session, &state).await?;
2235 require_agent_tool_override_capability(&session)?;
2236 handle_agent_permission_evaluate_tool(&parsed, &state).await
2237 }
2238 "sync.status" => handle_sync_status(&state).await,
2240 "sync.append" => handle_sync_append(&parsed, &state).await,
2241 "sync.record_turn" => handle_sync_record_turn(&parsed, &state).await,
2242 "sync.record_intent" => {
2243 handle_sync_record_intent(&parsed, &state, &session).await
2244 }
2245 "sync.pump" => handle_sync_pump(&state).await,
2246 "sync.knowledge" => handle_sync_knowledge(&parsed, &state).await,
2247 "sync.checkpoint" => handle_sync_checkpoint(&state).await,
2248 "sync.rebase" => handle_sync_rebase(&state).await,
2249 "sync.transcript" => handle_sync_transcript(&parsed, &state).await,
2250 "sync.resume" => handle_sync_resume(&parsed, &state).await,
2251 "sync.assistant_checkpoint.put" => {
2252 handle_sync_assistant_checkpoint_put(&parsed, &state).await
2253 }
2254 "sync.assistant_checkpoint.get" => {
2255 handle_sync_assistant_checkpoint_get(&parsed, &state).await
2256 }
2257 "sync.assistant_action.put" => {
2258 handle_sync_assistant_action_put(&parsed, &state).await
2259 }
2260 "sync.assistant_action.get" => {
2261 handle_sync_assistant_action_get(&parsed, &state).await
2262 }
2263 "sync.fence_check" => {
2264 handle_sync_fence_check(&parsed, &state, &session).await
2265 }
2266 "lease.acquire" => {
2267 handle_lease_acquire(&parsed, &state, &session).await
2268 }
2269 "lease.renew" => handle_lease_renew(&parsed, &state, &session).await,
2270 "lease.release" => {
2271 handle_lease_release(&parsed, &state, &session).await
2272 }
2273 "lease.status" => {
2274 handle_lease_status(&parsed, &state, bound_agent).await
2275 }
2276 "messaging.config.get" => {
2277 handle_messaging_config_get(&parsed, &session, &state).await
2278 }
2279 "messaging.config.set" => {
2280 handle_messaging_config_set(&parsed, &session, &state).await
2281 }
2282 "messaging.pairing.start" => {
2283 handle_messaging_pairing_start(&parsed, &session, &state).await
2284 }
2285 "messaging.pairing.status" => {
2286 handle_messaging_pairing_status(&parsed, &session, &state).await
2287 }
2288 "messaging.status" => {
2289 handle_messaging_status(&parsed, &session, &state).await
2290 }
2291 "messaging.test_send" => {
2292 handle_messaging_test_send(&parsed, &session, &state).await
2293 }
2294 "state.get" => handle_state_get(&parsed, &session).await,
2295 "state.set" => handle_state_set(&parsed, &session).await,
2296 "state.exists" => handle_state_exists(&parsed, &session).await,
2297 "state.keys" => handle_state_keys(&parsed, &session).await,
2298 "state.snapshot" => handle_state_snapshot(&parsed, &session).await,
2299 "memory.add_fact" => handle_memory_add_fact(&parsed, &session).await,
2300 "memory.set_admission_table" => {
2301 handle_memory_set_admission_table(&parsed, &session).await
2302 }
2303 "memory.admission_table" => {
2304 handle_memory_admission_table(&parsed, &session).await
2305 }
2306 "memory.update_status" => {
2307 handle_memory_update_status(&parsed, &session).await
2308 }
2309 "memory.maintain" => handle_memory_maintain(&parsed, &session).await,
2310 "memory.save_knowledge" => {
2311 handle_memory_save_knowledge(&parsed, &session).await
2312 }
2313 "memory.save_procedural" => {
2314 handle_memory_save_procedural(&parsed, &session).await
2315 }
2316 "memory.delete" => handle_memory_delete(&parsed, &session).await,
2317 "memory.query" => handle_memory_query(&parsed, &session).await,
2318 "memory.evaluate" => handle_memory_evaluate(&parsed, &session).await,
2319 "memory.intervene" => handle_memory_intervene(&parsed, &session).await,
2320 "memory.build_context" => {
2321 handle_memory_build_context(&parsed, &session).await
2322 }
2323 "memory.build_context_fast" => {
2324 handle_memory_build_context_fast(&parsed, &session).await
2325 }
2326 "memory.consolidate" => handle_memory_consolidate(&session).await,
2327 "memory.utility_get" => handle_memory_utility_get(&session).await,
2328 "memory.utility_set" => {
2329 handle_memory_utility_set(&parsed, &session).await
2330 }
2331 "cascade.run" => handle_cascade_run(&parsed, &session, &state).await,
2332 "evolution.plan" => {
2333 handle_evolution_plan(&parsed, &session, &state).await
2334 }
2335 "evolution.run" => {
2336 handle_evolution_run(&parsed, &session, &state).await
2337 }
2338 "selfheal.status" => handle_selfheal_status(&state).await,
2339 "selfheal.detections" => {
2340 handle_selfheal_detections(&parsed, &state).await
2341 }
2342 "selfheal.dismiss" => handle_selfheal_dismiss(&parsed, &state).await,
2343 "selfheal.fix" => handle_selfheal_fix(&parsed, &state).await,
2344 "selfheal.run" => handle_selfheal_run(&state).await,
2345 "heal.status" => handle_heal_status(&state).await,
2346 "heal.run" => handle_heal_run(&state).await,
2347 "memory.fact_count" => handle_memory_fact_count(&session).await,
2348 "memory.persist" => handle_memory_persist(&parsed, &session).await,
2349 "memory.load" => handle_memory_load(&parsed, &session).await,
2350 "skill.ingest" => handle_skill_ingest(&parsed, &session).await,
2351 "skill.find" => handle_skill_find(&parsed, &session).await,
2352 "skill.report" => handle_skill_report(&parsed, &session).await,
2353 "skill.gate_deployment" => {
2354 handle_skill_gate_deployment(&parsed, &session).await
2355 }
2356 "skill.enforce_deployment" => {
2357 handle_skill_enforce_deployment(&parsed, &session, &state).await
2358 }
2359 "skill.ingest_governed" => {
2360 handle_skill_ingest_governed(&parsed, &session, &state).await
2361 }
2362 "skill.adopt_pack" => {
2363 handle_skill_adopt_pack(&parsed, &session, &state).await
2364 }
2365 "skill.repair" => handle_skill_repair(&parsed, &session).await,
2366 "skills.ingest_distilled" => {
2367 handle_skills_ingest_distilled(&parsed, &session).await
2368 }
2369 "skills.evolve" => handle_skills_evolve(&parsed, &session).await,
2370 "skills.domains_needing_evolution" => {
2371 handle_skills_domains_needing_evolution(&parsed, &session).await
2372 }
2373 "skills.ingest_provisional" => {
2374 handle_skills_ingest_provisional(&parsed, &session).await
2375 }
2376 "skills.gate" => handle_skills_gate(&parsed, &session).await,
2377 "skill.meta" => handle_skill_meta(&parsed, &session).await,
2378 "skill.export" => handle_skill_export(&parsed, &session).await,
2379 "skill.import" => handle_skill_import(&parsed, &session).await,
2380 "multi.swarm" => handle_multi_swarm(&parsed, &session).await,
2381 "multi.pipeline" => handle_multi_pipeline(&parsed, &session).await,
2382 "multi.supervisor" => handle_multi_supervisor(&parsed, &session).await,
2383 "multi.map_reduce" => handle_multi_map_reduce(&parsed, &session).await,
2384 "multi.vote" => handle_multi_vote(&parsed, &session).await,
2385 "multi.tournament" => handle_multi_tournament(&parsed, &session).await,
2386 "multi.subtask" => handle_multi_subtask(&parsed, &session).await,
2387 "scheduler.create" => handle_scheduler_create(&parsed),
2388 "scheduler.run" => handle_scheduler_run(&parsed, &session).await,
2389 "scheduler.run_loop" => {
2390 handle_scheduler_run_loop(&parsed, &session).await
2391 }
2392 "scheduler.os_render" => handle_scheduler_os_render(&parsed),
2393 "scheduler.os_install" => handle_scheduler_os_install(&parsed),
2394 "scheduler.os_uninstall" => handle_scheduler_os_uninstall(&parsed),
2395 "scheduler.os_list" => handle_scheduler_os_list(&parsed),
2396 "scheduler.os_reconcile" => handle_scheduler_os_reconcile(&parsed),
2397 "tasks.schedule" => handle_tasks_schedule(&parsed, &session, &state),
2398 "tasks.list" => handle_tasks_list(&parsed),
2399 "tasks.unschedule" => {
2400 handle_tasks_unschedule(&parsed, &session, &state)
2401 }
2402 "diagnostics.secret_store_activity" => {
2403 handle_secret_store_activity(&session, &state)
2404 }
2405 "feedback.compose_preview" => {
2412 require_feedback_capability(&session)?;
2413 crate::feedback::handle_compose_preview(&parsed, &state, &session)
2414 .await
2415 }
2416 "feedback.submit" => {
2417 require_feedback_capability(&session)?;
2418 crate::feedback::handle_submit(&parsed, &state, &session).await
2419 }
2420 "feedback.status" => {
2421 require_feedback_capability(&session)?;
2422 crate::feedback::handle_status(&parsed, &state).await
2423 }
2424 "feedback.list" => {
2425 require_feedback_capability(&session)?;
2426 crate::feedback::handle_list(&parsed, &state).await
2427 }
2428 "infer" => handle_infer(&parsed, state.clone(), session.clone()).await,
2429 "infer.cancel" => handle_infer_cancel(&parsed, &session).await,
2430 "infer.deadline" => handle_infer_deadline(&parsed, &session).await,
2431 "image.generate" => handle_image_generate(&parsed, &state).await,
2432 "video.generate" => handle_video_generate(&parsed, &state).await,
2433 "embed" => handle_embed(&parsed, &state).await,
2434 "classify" => handle_classify(&parsed, &state).await,
2435 "tokenize" => handle_tokenize(&parsed, &state).await,
2436 "detokenize" => handle_detokenize(&parsed, &state).await,
2437 "rerank" => handle_rerank(&parsed, &state).await,
2438 "transcribe" => handle_transcribe(&parsed, &state).await,
2439 "synthesize" => handle_synthesize(&parsed, &state).await,
2440 "search" => handle_search(&parsed, &state).await,
2441 "web_fetch" => handle_web_fetch(&parsed, &state).await,
2442 "infer_stream" => handle_infer_stream(&parsed, &session, &state).await,
2443 "speech.prepare" => handle_speech_prepare(&state).await,
2444 "models.route" => handle_models_route(&parsed, &state).await,
2445 "models.stats" => handle_models_stats(&state).await,
2446 "outcomes.scoreboard" => handle_outcomes_scoreboard(&state).await,
2447 "outcomes.resolve_pending" => {
2448 handle_outcomes_resolve_pending(&parsed, &state).await
2449 }
2450 "events.count" => handle_events_count(&session).await,
2451 "events.stats" => handle_events_stats(&session).await,
2452 "events.truncate" => handle_events_truncate(&parsed, &session).await,
2453 "events.clear" => handle_events_clear(&session).await,
2454 "events.query" => handle_events_query(&parsed, &session).await,
2455 "events.retention" => handle_events_retention(&parsed, &session).await,
2456 "events.cost_by_agent" => handle_events_cost_by_agent(&session).await,
2457 "events.chain.enable" => handle_events_chain_enable(&session).await,
2458 "events.chain.verify" => handle_events_chain_verify(&session).await,
2459 "metrics.summary" => handle_metrics_summary(&session).await,
2460 "metrics.alerts" => handle_metrics_alerts(&parsed, &session).await,
2461 "nlp.identify_language" => handle_nlp(&parsed, NlpOp::IdentifyLanguage),
2462 "nlp.tokenize" => handle_nlp(&parsed, NlpOp::Tokenize),
2463 "nlp.extract_entities" => handle_nlp(&parsed, NlpOp::ExtractEntities),
2464 "runs.start" => handle_runs_start(&parsed, &session, &state).await,
2471 "runs.resume" => handle_runs_resume(&parsed, &session, &state).await,
2472 "runs.complete" => {
2473 handle_runs_complete(&parsed, &session, &state).await
2474 }
2475 "runs.cancel" => handle_runs_cancel(&parsed, &session, &state).await,
2476 "runs.record_turns" => {
2485 handle_runs_record_turns(&parsed, &session, &state).await
2486 }
2487 "runs.subscribe" => {
2492 handle_runs_subscribe(&parsed, &session, &state).await
2493 }
2494 "runs.unsubscribe" => {
2495 handle_runs_unsubscribe(&parsed, &session, &state).await
2496 }
2497 "runs.list" => handle_runs_list(&parsed, &session, &state).await,
2503 "runs.get_trace" => {
2504 handle_runs_get_trace(&parsed, &session, &state).await
2505 }
2506 "replan.set_config" => {
2507 handle_replan_set_config(&parsed, &session).await
2508 }
2509 "models.list" => handle_models_list(&state),
2510 "models.register" => handle_models_register(&parsed, &state).await,
2511 "models.unregister" => handle_models_unregister(&parsed, &state).await,
2512 "models.list_unified" => {
2513 handle_models_list_unified(&parsed, &state).await
2514 }
2515 "models.catalog_snapshot" => {
2516 handle_models_catalog_snapshot(&session, &state)
2517 }
2518 "models.resource_policy.get" => {
2519 handle_models_resource_policy_get(&parsed, &state)
2520 }
2521 "models.resource_policy.set" => {
2522 handle_models_resource_policy_set(&parsed, &session, &state)
2523 }
2524 "models.preflight" => handle_models_preflight(&parsed, &state),
2525 "models.storage_roots" => {
2526 handle_models_storage_roots(&parsed, &session, &state)
2527 }
2528 "models.remove" => {
2529 handle_models_remove(&parsed, &session, &state).await
2530 }
2531 "models.adopt" => handle_models_adopt(&parsed, &session, &state).await,
2532 "models.route_provenance" => {
2533 handle_models_route_provenance(&parsed, &state).await
2534 }
2535 "models.search" => handle_models_search(&parsed, &state).await,
2536 "models.recommend" => handle_models_recommend(&parsed, &state),
2537 "models.setup_plan" => handle_models_setup_plan(&parsed, &state),
2538 "models.upgrades" => handle_models_upgrades(&state),
2539 "models.detect_upgrades" => handle_models_detect_upgrades(&state).await,
2540 "models.check_upgrade_nudge" => {
2541 handle_models_check_upgrade_nudge(&parsed, &state).await
2542 }
2543 "models.dismiss_upgrade" => {
2544 handle_models_dismiss_upgrade(&parsed, &state)
2545 }
2546 "models.check_concierge" => {
2547 handle_models_check_concierge(&parsed, &state).await
2548 }
2549 "models.dismiss_suggestion" => {
2550 handle_models_dismiss_suggestion(&parsed, &state)
2551 }
2552 "concierge.status" => handle_concierge_status(&parsed, &state).await,
2556 "concierge.dismiss" => handle_concierge_dismiss(&parsed, &state),
2557 "concierge.defaults" => handle_concierge_defaults(&state),
2558 "concierge.set_default" => {
2559 handle_concierge_set_default(&parsed, &state).await
2560 }
2561 "concierge.clear_default" => {
2562 handle_concierge_clear_default(&parsed, &state).await
2563 }
2564 "concierge.apply" => handle_concierge_apply(&parsed, &state).await,
2565 "concierge.rollback" => {
2566 handle_concierge_rollback(&parsed, &state).await
2567 }
2568 "concierge.actions" => handle_concierge_actions(&parsed, &state),
2569 "concierge.refresh_catalog" => {
2570 handle_concierge_refresh_catalog(&state).await
2571 }
2572 "concierge.ask" => handle_concierge_ask(&parsed, &state).await,
2573 "models.update_prefs_get" => handle_models_update_prefs_get(&state),
2574 "models.update_prefs_set" => {
2575 handle_models_update_prefs_set(&parsed, &state)
2576 }
2577 "models.pull" | "models.install" => {
2578 handle_models_pull(&parsed, &session, &state).await
2579 }
2580 "skills.distill" => handle_skills_distill(&parsed, &state).await,
2581 "skills.list" => handle_skills_list(&parsed, &session).await,
2582 "browser.run" => handle_browser_run(&parsed, &session).await,
2583 "browser.close" => handle_browser_close(&session).await,
2584 "browser.view.subscribe" => {
2593 crate::browser_view::handle_subscribe(&parsed, &session, &state)
2594 .await
2595 }
2596 "browser.view.unsubscribe" => {
2597 crate::browser_view::handle_unsubscribe(&parsed, &session, &state)
2598 .await
2599 }
2600 "browser.view.take_control" => {
2601 crate::browser_view::handle_take_control(&parsed, &session, &state)
2602 .await
2603 }
2604 "browser.view.hand_back" => {
2605 crate::browser_view::handle_hand_back(&parsed, &session, &state)
2606 .await
2607 }
2608 "browser.view.navigate" => {
2609 crate::browser_view::handle_input(
2610 crate::browser_view::InputOp::Navigate,
2611 &parsed,
2612 &session,
2613 &state,
2614 )
2615 .await
2616 }
2617 "browser.view.click" => {
2618 crate::browser_view::handle_input(
2619 crate::browser_view::InputOp::Click,
2620 &parsed,
2621 &session,
2622 &state,
2623 )
2624 .await
2625 }
2626 "browser.view.type" => {
2627 crate::browser_view::handle_input(
2628 crate::browser_view::InputOp::Type,
2629 &parsed,
2630 &session,
2631 &state,
2632 )
2633 .await
2634 }
2635 "browser.view.keypress" => {
2636 crate::browser_view::handle_input(
2637 crate::browser_view::InputOp::Keypress,
2638 &parsed,
2639 &session,
2640 &state,
2641 )
2642 .await
2643 }
2644 "browser.view.scroll" => {
2645 crate::browser_view::handle_input(
2646 crate::browser_view::InputOp::Scroll,
2647 &parsed,
2648 &session,
2649 &state,
2650 )
2651 .await
2652 }
2653 "browser.view.paste" => {
2658 crate::browser_view::handle_input(
2659 crate::browser_view::InputOp::Paste,
2660 &parsed,
2661 &session,
2662 &state,
2663 )
2664 .await
2665 }
2666 "browser.view.back" => {
2670 crate::browser_view::handle_input(
2671 crate::browser_view::InputOp::Back,
2672 &parsed,
2673 &session,
2674 &state,
2675 )
2676 .await
2677 }
2678 "browser.view.forward" => {
2679 crate::browser_view::handle_input(
2680 crate::browser_view::InputOp::Forward,
2681 &parsed,
2682 &session,
2683 &state,
2684 )
2685 .await
2686 }
2687 "browser.view.reload" => {
2688 crate::browser_view::handle_input(
2689 crate::browser_view::InputOp::Reload,
2690 &parsed,
2691 &session,
2692 &state,
2693 )
2694 .await
2695 }
2696 "browser.view.tab_open" => {
2697 crate::browser_view::handle_input(
2698 crate::browser_view::InputOp::TabOpen,
2699 &parsed,
2700 &session,
2701 &state,
2702 )
2703 .await
2704 }
2705 "browser.view.tab_close" => {
2706 crate::browser_view::handle_input(
2707 crate::browser_view::InputOp::TabClose,
2708 &parsed,
2709 &session,
2710 &state,
2711 )
2712 .await
2713 }
2714 "browser.view.tab_switch" => {
2715 crate::browser_view::handle_input(
2716 crate::browser_view::InputOp::TabSwitch,
2717 &parsed,
2718 &session,
2719 &state,
2720 )
2721 .await
2722 }
2723 "browser.producer.register" => {
2728 crate::browser_relay::handle_producer_register(
2729 &parsed, &session, &state,
2730 )
2731 .await
2732 }
2733 "secret.put" => handle_secret_put(&parsed).await,
2734 "secret.get" => handle_secret_get(&parsed, &session, &state).await,
2735 "secret.delete" => handle_secret_delete(&parsed).await,
2736 "secret.status" => handle_secret_status(&parsed),
2737 "secret.available" => Ok(car_ffi_common::secrets::is_available()),
2738 "secret.list" => car_ffi_common::secrets::list(),
2739 "permissions.status" => handle_perm_status(&parsed),
2740 "permissions.request" => handle_perm_request(&parsed),
2741 "permissions.explain" => handle_perm_explain(&parsed),
2742 "permissions.domains" => Ok(car_ffi_common::permissions::domains()),
2743 "accounts.list" => car_ffi_common::accounts::list(),
2744 "accounts.open" => {
2745 #[derive(serde::Deserialize, Default)]
2746 struct OpenParams {
2747 #[serde(default)]
2748 account_id: Option<String>,
2749 }
2750 let p: OpenParams = serde_json::from_value(parsed.params.clone())
2751 .unwrap_or_default();
2752 car_ffi_common::accounts::open_settings(p.account_id.as_deref())
2753 }
2754 "calendar.list" => car_ffi_common::integrations::calendar_list(),
2755 "calendar.events" => handle_calendar_events(&parsed),
2756 "calendar.create_event" => handle_calendar_create_event(&parsed),
2757 "calendar.update_event" => handle_calendar_update_event(&parsed),
2758 "calendar.delete_event" => handle_calendar_delete_event(&parsed),
2759 "contacts.containers" => {
2760 car_ffi_common::integrations::contacts_containers()
2761 }
2762 "contacts.find" => handle_contacts_find(&parsed),
2763 "mail.accounts" => car_ffi_common::integrations::mail_accounts(),
2764 "mail.inbox" => handle_mail_inbox(&parsed),
2765 "mail.mailboxes" => handle_mail_mailboxes(&parsed),
2766 "mail.messages" => handle_mail_messages(&parsed),
2767 "mail.message_body" => handle_mail_message_body(&parsed),
2768 "mail.send" => handle_mail_send(&parsed),
2769 "messages.services" => {
2770 car_ffi_common::integrations::messages_services()
2771 }
2772 "messages.chats" => handle_messages_chats(&parsed),
2773 "messages.read" => handle_messages_read(&parsed),
2774 "messages.send" => handle_messages_send(&parsed),
2775 "notes.accounts" => car_ffi_common::integrations::notes_accounts(),
2776 "notes.find" => handle_notes_find(&parsed),
2777 "reminders.lists" => car_ffi_common::integrations::reminders_lists(),
2778 "reminders.items" => handle_reminders_items(&parsed),
2779 "photos.albums" => car_ffi_common::integrations::photos_albums(),
2780 "bookmarks.list" => handle_bookmarks_list(&parsed),
2781 "files.locations" => car_ffi_common::integrations::files_locations(),
2782 "keychain.status" => car_ffi_common::integrations::keychain_status(),
2783 "health.status" => car_ffi_common::health::status(),
2784 "health.sleep" => handle_health_sleep(&parsed),
2785 "health.workouts" => handle_health_workouts(&parsed),
2786 "health.activity" => handle_health_activity(&parsed),
2787 "voice.transcribe_stream.start" => {
2788 handle_voice_transcribe_stream_start(&parsed, &state, &session)
2789 .await
2790 }
2791 "voice.transcribe_stream.stop" => {
2792 handle_voice_transcribe_stream_stop(&parsed, &state).await
2793 }
2794 "voice.transcribe_stream.push" => {
2795 handle_voice_transcribe_stream_push(&parsed, &state).await
2796 }
2797 "voice.tts_stream.start" => {
2798 handle_voice_tts_stream_start(&parsed, &session).await
2799 }
2800 "voice.tts_stream.cancel" => {
2801 handle_voice_tts_stream_cancel(&parsed).await
2802 }
2803 "voice.tts_stream.list" => Ok(handle_voice_tts_stream_list()),
2804 "voice.sessions.list" => Ok(handle_voice_sessions_list(&state)),
2805 "voice.dispatch_turn" => {
2806 handle_voice_dispatch_turn(&parsed, &state, &session).await
2807 }
2808 "voice.cancel_turn" => handle_voice_cancel_turn().await,
2809 "voice.prewarm_turn" => handle_voice_prewarm_turn(&state).await,
2810 "inference.register_runner" => {
2811 handle_inference_register_runner(&session).await
2812 }
2813 "inference.runner.event" => {
2814 handle_inference_runner_event(&parsed).await
2815 }
2816 "inference.runner.complete" => {
2817 handle_inference_runner_complete(&parsed).await
2818 }
2819 "inference.runner.fail" => handle_inference_runner_fail(&parsed).await,
2820 "voice.providers.list" => {
2821 serde_json::from_str::<serde_json::Value>(
2825 &car_voice::list_voice_providers_json(),
2826 )
2827 .map_err(|e| e.to_string())
2828 }
2829 "voice.prepare_parakeet" => car_ffi_common::voice::prepare_parakeet()
2830 .await
2831 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2832 "voice.prepare_diarizer" => car_ffi_common::voice::prepare_diarizer()
2833 .await
2834 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2835 "voice.enroll_speaker" => handle_enroll_speaker(&parsed).await,
2836 "voice.list_enrollments" => car_ffi_common::voice::list_enrollments()
2837 .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2838 "voice.remove_enrollment" => handle_remove_enrollment(&parsed),
2839 "workflow.run" => handle_workflow_run(&parsed, &session).await,
2840 "workflow.chain" => handle_workflow_chain(&parsed, &session).await,
2841 "workflow.resume" => handle_workflow_resume(&parsed, &session).await,
2842 "workflow.list_paused" => handle_workflow_list_paused().await,
2843 "builder.build" => {
2844 handle_builder_build(&parsed, &state, &session).await
2845 }
2846 "workflow.verify" => handle_workflow_verify(&parsed),
2847 "workflow.build_automation" => {
2848 handle_workflow_build_automation(&parsed)
2849 }
2850 "meeting.start" => {
2851 handle_meeting_start(&parsed, &state, &session).await
2852 }
2853 "meeting.stop" => handle_meeting_stop(&parsed, &state, &session).await,
2854 "meeting.list" => handle_meeting_list(&parsed),
2855 "meeting.get" => handle_meeting_get(&parsed),
2856 "registry.register" => handle_registry_register(&parsed),
2857 "registry.heartbeat" => handle_registry_heartbeat(&parsed),
2858 "registry.unregister" => handle_registry_unregister(&parsed),
2859 "registry.list" => handle_registry_list(&parsed),
2860 "registry.reap" => handle_registry_reap(&parsed),
2861 "admission.status" => handle_admission_status(&state),
2862 "a2a.start" => handle_a2a_start(&parsed, &state, &session).await,
2863 "a2a.stop" => handle_a2a_stop(),
2864 "a2a.status" => handle_a2a_status(),
2865 "a2a.send" => handle_a2a_send(&parsed, &state).await,
2866 "a2a.peers.add" => handle_a2a_peers_add(&parsed),
2867 "a2a.peers.list" => handle_a2a_peers_list(),
2868 "a2a.peers.remove" => handle_a2a_peers_remove(&parsed),
2869 "a2ui.apply" => handle_a2ui_apply(&parsed, &state).await,
2870 "a2ui.ingest" => handle_a2ui_ingest(&parsed, &state).await,
2871 "a2ui.capabilities" => handle_a2ui_capabilities(&state),
2872 "a2ui.reap" => handle_a2ui_reap(&state).await,
2873 "a2ui.surfaces" => handle_a2ui_surfaces(&state).await,
2874 "a2ui.get" => handle_a2ui_get(&parsed, &state).await,
2875 "a2ui.action" => handle_a2ui_action(&parsed, &state).await,
2876 "a2ui.render_report" => {
2877 handle_a2ui_render_report(&parsed, &state).await
2878 }
2879 "a2ui/subscribe" => handle_a2ui_subscribe(&session, &state).await,
2880 "a2ui/unsubscribe" => handle_a2ui_unsubscribe(&session, &state).await,
2881 "a2ui/replay" => handle_a2ui_replay(&parsed, &state).await,
2882 "automation.run_applescript" => handle_run_applescript(&parsed).await,
2883 "automation.run_powershell" => handle_run_powershell(&parsed).await,
2884 "automation.shortcuts.list" => handle_list_shortcuts(&parsed).await,
2885 "automation.shortcuts.run" => handle_run_shortcut(&parsed).await,
2886 "notifications.local" => handle_local_notification(&parsed).await,
2887 "vision.ocr" => handle_vision_ocr(&parsed).await,
2888 "coder.start" => {
2889 crate::coder::rpc::handle_coder_start(&parsed, &state, &session)
2890 .await
2891 }
2892 "coder.projects.list" => {
2893 crate::coder::rpc::handle_coder_projects_list(&state).await
2894 }
2895 "coder.projects.create" => {
2896 crate::coder::rpc::handle_coder_projects_create(&parsed, &state)
2897 .await
2898 }
2899 "coder.projects.get" => {
2900 crate::coder::rpc::handle_coder_projects_get(&parsed, &state).await
2901 }
2902 "coder.confirm_contract" => {
2903 crate::coder::rpc::handle_coder_confirm_contract(&parsed, &state)
2904 .await
2905 }
2906 "coder.list" => crate::coder::rpc::handle_coder_list(&state).await,
2907 "coder.get" => {
2908 crate::coder::rpc::handle_coder_get(&parsed, &state).await
2909 }
2910 "coder.subscribe" => {
2911 crate::coder::rpc::handle_coder_subscribe(&parsed, &state, &session)
2912 .await
2913 }
2914 "coder.unsubscribe" => {
2915 crate::coder::rpc::handle_coder_unsubscribe(
2916 &parsed, &state, &session,
2917 )
2918 .await
2919 }
2920 "coder.respond" => {
2921 crate::coder::rpc::handle_coder_respond(&parsed, &state).await
2922 }
2923 "coder.watch" => {
2924 crate::coder::rpc::handle_coder_watch(&parsed, &state, &session)
2925 .await
2926 }
2927 "coder.unwatch" => {
2928 crate::coder::rpc::handle_coder_unwatch(&state, &session).await
2929 }
2930 "coder.revise_contract" => {
2931 crate::coder::rpc::handle_coder_revise_contract(&parsed, &state)
2932 .await
2933 }
2934 "coder.approve_merge" => {
2935 crate::coder::rpc::handle_coder_approve_merge(&parsed, &state).await
2936 }
2937 "coder.cancel" => {
2938 crate::coder::rpc::handle_coder_cancel(&parsed, &state).await
2939 }
2940 "coder.discuss.start" => {
2941 crate::coder::discuss::handle_discuss_start(
2942 &parsed, &state, &session,
2943 )
2944 .await
2945 }
2946 "coder.discuss.send" => {
2947 crate::coder::discuss::handle_discuss_send(
2948 &parsed, &state, &session,
2949 )
2950 .await
2951 }
2952 "coder.discuss.subscribe" => {
2953 crate::coder::discuss::handle_discuss_subscribe(
2954 &parsed, &state, &session,
2955 )
2956 .await
2957 }
2958 "coder.discuss.unsubscribe" => {
2959 crate::coder::discuss::handle_discuss_unsubscribe(
2960 &parsed, &state, &session,
2961 )
2962 .await
2963 }
2964 "coder.discuss.promote" => {
2965 crate::coder::discuss::handle_discuss_promote(
2966 &parsed, &state, &session,
2967 )
2968 .await
2969 }
2970 "coder.discuss.close" => {
2971 crate::coder::discuss::handle_discuss_close(
2972 &parsed, &state, &session,
2973 )
2974 .await
2975 }
2976 "coder.discuss.list" => {
2977 crate::coder::discuss::handle_discuss_list(&state, &session).await
2978 }
2979 "declagents.list" => {
2980 crate::coder::rpc::handle_declagents_list(&state).await
2981 }
2982 "declagents.get" => {
2983 crate::coder::rpc::handle_declagents_get(&parsed, &state).await
2984 }
2985 "declagents.remove" => {
2986 require_host_lifecycle_authority(&session, &state).await?;
2987 crate::coder::rpc::handle_declagents_remove(
2988 &parsed, &state, &session,
2989 )
2990 .await
2991 }
2992 "declagents.set_enabled" => {
2993 require_host_lifecycle_authority(&session, &state).await?;
2994 crate::coder::rpc::handle_declagents_set_enabled(
2995 &parsed, &state, &session,
2996 )
2997 .await
2998 }
2999 "declagents.invoke" => {
3000 crate::coder::rpc::handle_declagents_invoke(
3001 &parsed, &state, &session,
3002 )
3003 .await
3004 }
3005 "declagents.route" => {
3006 crate::coder::rpc::handle_declagents_route(
3007 &parsed, &state, &session,
3008 )
3009 .await
3010 }
3011 "declagents.route_split" => {
3012 crate::coder::rpc::handle_declagents_route_split(
3013 &parsed, &state, &session,
3014 )
3015 .await
3016 }
3017 "declagents.routing_stats" => {
3018 crate::coder::rpc::handle_declagents_routing_stats(&state).await
3019 }
3020 "discovery.resolve" => {
3021 crate::coder::rpc::handle_discovery_resolve(&parsed, &state).await
3022 }
3023 "discovery.route_compose" => {
3024 crate::coder::rpc::handle_discovery_route_compose(&parsed, &state)
3025 .await
3026 }
3027 "discovery.report" => {
3028 crate::coder::rpc::handle_discovery_report(&parsed, &state).await
3029 }
3030 "agents.list" => handle_agents_list(&state).await,
3031 "agents.health" => handle_agents_health(&state).await,
3032 "agents.upsert" => {
3033 handle_agents_upsert(&parsed, &state, &session).await
3034 }
3035 "agents.install" => {
3036 handle_agents_install(&parsed, &state, &session).await
3037 }
3038 "agents.remove" => {
3039 handle_agents_remove(&parsed, &state, &session).await
3040 }
3041 "agents.start" => handle_agents_start(&parsed, &state, &session).await,
3042 "agents.stop" => handle_agents_stop(&parsed, &state, &session).await,
3043 "agents.restart" => {
3044 handle_agents_restart(&parsed, &state, &session).await
3045 }
3046 "agents.wait" => handle_agents_wait(&parsed, &state, bound_agent).await,
3047 "agents.tail_log" => {
3048 handle_agents_tail_log(&parsed, &state, bound_agent).await
3049 }
3050 "agents.list_external" => handle_agents_list_external(&parsed).await,
3051 "agents.detect_external" => {
3052 handle_agents_detect_external(&parsed).await
3053 }
3054 "agents.health_external" => {
3055 handle_agents_health_external(&parsed).await
3056 }
3057 "assistant.identity.get" => handle_assistant_identity_get(),
3058 "assistant.identity.set" => {
3059 handle_assistant_identity_set(&parsed, &session, &state).await
3060 }
3061 "assistants.invoke" => {
3062 handle_assistants_invoke(&parsed, &state, &session).await
3063 }
3064 "agents.invoke_external" => {
3065 handle_agents_invoke_external(&parsed, &state, &session).await
3066 }
3067 "agents.chat" => handle_agents_chat(&parsed, &state, &session).await,
3068 "agents.peers" => {
3069 crate::peers::handle_agents_peers(&state, &session).await
3070 }
3071 "agents.message" => {
3072 crate::peers::handle_agents_message(&parsed, &state, &session).await
3073 }
3074 "agents.message.pending" => {
3075 crate::peers::handle_agents_message_pending(&state, &session).await
3076 }
3077 "agents.message.approve" => {
3078 crate::peers::handle_agents_message_approve(
3079 &parsed, &state, &session,
3080 )
3081 .await
3082 }
3083 "agents.chat.cancel" => {
3084 handle_agents_chat_cancel(
3085 &parsed,
3086 &state,
3087 &session.client_id,
3088 session.is_host.load(std::sync::atomic::Ordering::Acquire),
3089 )
3090 .await
3091 }
3092 "agents.chat.approve" => {
3093 handle_agents_chat_approve(
3094 &parsed,
3095 &state,
3096 &session.client_id,
3097 session.is_host.load(std::sync::atomic::Ordering::Acquire),
3098 )
3099 .await
3100 }
3101 "goal.suggest" => handle_goal_suggest(&parsed, &state).await,
3102 "goal.set" => handle_goal_set(&parsed, &state).await,
3103 "goal.status" => handle_goal_status(&parsed, &state).await,
3104 "goal.clear" => handle_goal_clear(&parsed, &state).await,
3105 "foreman.plan" => handle_foreman_plan(&parsed, &state).await,
3110 "foreman.run" => handle_foreman_run(&parsed, &state, &session).await,
3111 "fleet.inventory" => {
3114 crate::fleet::handle_fleet_inventory(&state, &session).await
3115 }
3116 "fleet.composite" => {
3117 crate::fleet::handle_fleet_composite(&parsed, &state, &session)
3118 .await
3119 }
3120 "fleet.worker.get" => crate::fleet::handle_fleet_worker_get().await,
3121 "fleet.worker.set" => {
3122 crate::fleet::handle_fleet_worker_set(&parsed, &session).await
3123 }
3124 "connectors.add" => handle_connectors_add(&parsed, &state).await,
3129 "connectors.add_stdio" => {
3130 handle_connectors_add_stdio(&parsed, &state).await
3131 }
3132 "connectors.authenticate" => {
3133 handle_connectors_authenticate(&parsed, &state).await
3134 }
3135 "connectors.complete_authentication" => {
3136 handle_connectors_complete_authentication(&parsed, &state).await
3137 }
3138 "connectors.list" => handle_connectors_list(&state).await,
3139 "connectors.tools" => handle_connectors_tools(&parsed, &state).await,
3140 "connectors.enable_tools" => {
3141 handle_connectors_enable_tools(&parsed, &state).await
3142 }
3143 "connectors.disable_tools" => {
3144 handle_connectors_disable_tools(&parsed, &state).await
3145 }
3146 "connectors.refresh" => {
3147 handle_connectors_refresh(&parsed, &state).await
3148 }
3149 "connectors.remove" => handle_connectors_remove(&parsed, &state).await,
3150 "message/send"
3157 | "SendMessage"
3158 | "message/stream"
3159 | "SendStreamingMessage"
3160 | "tasks/get"
3161 | "GetTask"
3162 | "tasks/list"
3163 | "ListTasks"
3164 | "tasks/cancel"
3165 | "CancelTask"
3166 | "tasks/resubscribe"
3167 | "SubscribeToTask"
3168 | "tasks/pushNotificationConfig/set"
3169 | "CreateTaskPushNotificationConfig"
3170 | "tasks/pushNotificationConfig/get"
3171 | "GetTaskPushNotificationConfig"
3172 | "tasks/pushNotificationConfig/list"
3173 | "ListTaskPushNotificationConfigs"
3174 | "tasks/pushNotificationConfig/delete"
3175 | "DeleteTaskPushNotificationConfig"
3176 | "agent/getAuthenticatedExtendedCard"
3177 | "GetExtendedAgentCard" => {
3178 handle_a2a_dispatch(method_owned.as_str(), &parsed, &state).await
3179 }
3180 _ => Err(format!("unknown method: {}", method_owned)),
3181 }
3182 };
3183
3184 let result = dispatch_with_server_deadline(
3196 method_owned.as_str(),
3197 &parsed.params,
3198 dispatch,
3199 )
3200 .await;
3201
3202 if method_owned == "session.auth"
3203 && result.is_ok()
3204 && credential_event_is_eligible(
3205 &session,
3206 credential_transport_auth_required,
3207 credential_host_role_required,
3208 )
3209 {
3210 let _ = credential_activation_tx_task.send(true);
3211 let mut ready = credential_ready_rx_task;
3212 while !*ready.borrow_and_update() {
3213 if ready.changed().await.is_err() {
3214 break;
3215 }
3216 }
3217 }
3218
3219 let resp = json_rpc_response_for_handler_result(parsed.id, result);
3220 let _ = send_response(&session.channel, resp).await;
3221 });
3222 }
3223 } else if msg.is_binary() {
3224 let bytes = msg.into_data();
3231 let parsed = match car_ffi_common::voice::binary::parse_frame(&bytes) {
3232 Ok(p) => p,
3233 Err(e) => {
3234 tracing::warn!("binary frame from {} rejected: {}", client_id, e);
3235 continue;
3236 }
3237 };
3238 match parsed.frame_type {
3239 car_ffi_common::voice::binary::FRAME_TYPE_INBOUND_PCM => {
3240 let registry = state.voice_sessions.clone();
3241 let payload_owned = parsed.payload.to_vec();
3242 let session_id_owned = parsed.session_id_hex.clone();
3243 conn_tasks.spawn(async move {
3244 if let Err(e) = car_ffi_common::voice::transcribe_stream_push(
3245 &session_id_owned,
3246 &payload_owned,
3247 registry,
3248 )
3249 .await
3250 {
3251 tracing::warn!(
3252 "binary PCM push to session {} failed: {}",
3253 session_id_owned,
3254 e
3255 );
3256 }
3257 });
3258 }
3259 other => {
3260 tracing::debug!(
3261 "binary frame type {:#04x} from {} not accepted server-side",
3262 other,
3263 client_id
3264 );
3265 }
3266 }
3267 } else if msg.is_close() {
3268 info!("Client {} disconnected", client_id);
3269 break;
3270 }
3271 }
3272
3273 conn_tasks.abort_all();
3278 session.inference_control.abort_all();
3282
3283 session.host.unsubscribe(&client_id).await;
3284 state.supervision.unsubscribe(&client_id).await;
3289 session.host.reap_session_approvals(&client_id).await;
3295 state.a2ui_subscribers.lock().await.remove(&client_id);
3296
3297 let _removed = state.remove_session(&client_id).await;
3309 {
3310 let mut pending = session.channel.pending.lock().await;
3311 pending.clear();
3312 }
3313
3314 Ok(())
3315}
3316
3317async fn send_response(
3318 channel: &WsChannel,
3319 resp: JsonRpcResponse,
3320) -> Result<(), Box<dyn std::error::Error>> {
3321 use futures::SinkExt;
3322 let json = serde_json::to_string(&resp)?;
3323 channel
3324 .write
3325 .lock()
3326 .await
3327 .send(Message::Text(json.into()))
3328 .await?;
3329 Ok(())
3330}
3331
3332async fn handle_host_subscribe(
3335 session: &crate::session::ClientSession,
3336 state: &Arc<ServerState>,
3337) -> Result<Value, String> {
3338 session
3339 .host
3340 .subscribe(&session.client_id, session.channel.clone())
3341 .await;
3342 let event_sequence = session.host.event_sequence();
3346 let snapshot = serde_json::to_value(HostSnapshot {
3347 subscribed: true,
3348 agents: session.host.agents().await,
3349 devices: session.host.devices().await,
3350 approvals: session.host.approvals().await,
3351 events: session.host.events(50).await,
3352 pending_signins: state.browser_views.pending_signins().await,
3353 event_sequence,
3354 identity: Some(daemon_identity(state)),
3355 })
3356 .map_err(|e| e.to_string())?;
3357 Ok(snapshot)
3358}
3359
3360async fn handle_supervision_subscribe(
3369 request: &JsonRpcMessage,
3370 session: &crate::session::ClientSession,
3371 state: &Arc<ServerState>,
3372) -> Result<Value, String> {
3373 let filter: crate::supervision::SupervisionFilter = match request.params.get("filter") {
3374 Some(Value::Null) | None => Default::default(),
3375 Some(v) => serde_json::from_value(v.clone())
3376 .map_err(|e| format!("invalid supervision filter: {e}"))?,
3377 };
3378 state
3379 .supervision
3380 .subscribe(&session.client_id, filter, session.channel.clone())
3381 .await;
3382 Ok(serde_json::json!({
3383 "subscribed": true,
3384 "decision_timeout_ms": state.supervision.timeout().as_millis() as u64,
3385 "supervisors": state.supervision.subscriber_count().await,
3386 }))
3387}
3388
3389async fn handle_supervision_unsubscribe(
3392 session: &crate::session::ClientSession,
3393 state: &Arc<ServerState>,
3394) -> Result<Value, String> {
3395 let was = state.supervision.unsubscribe(&session.client_id).await;
3396 Ok(serde_json::json!({ "subscribed": false, "was_subscribed": was }))
3397}
3398
3399async fn handle_supervision_pending(state: &Arc<ServerState>) -> Result<Value, String> {
3406 Ok(serde_json::json!({ "intents": state.supervision.pending().await }))
3407}
3408
3409async fn handle_supervision_decide(
3417 request: &JsonRpcMessage,
3418 state: &Arc<ServerState>,
3419) -> Result<Value, String> {
3420 let intent_id = request
3421 .params
3422 .get("intent_id")
3423 .and_then(|v| v.as_str())
3424 .ok_or("supervision.decide requires 'intent_id'")?;
3425 let decision_value = request
3426 .params
3427 .get("decision")
3428 .ok_or("supervision.decide requires 'decision'")?;
3429 let decision: crate::supervision::SupervisionDecision =
3430 serde_json::from_value(decision_value.clone())
3431 .map_err(|e| format!("invalid supervision decision: {e}"))?;
3432 state.supervision.decide(intent_id, decision).await?;
3433 Ok(serde_json::json!({ "decided": true }))
3434}
3435
3436fn daemon_identity(state: &Arc<ServerState>) -> car_proto::HostIdentity {
3444 let (manifest_path, manifest_role) = if let Some(p) = state.observer_manifest_path() {
3451 (
3452 Some(p.to_string_lossy().into_owned()),
3453 car_proto::HostManifestRole::Observer,
3454 )
3455 } else if let Some(s) = state.supervisor_if_installed() {
3456 (
3457 Some(s.manifest_path().to_string_lossy().into_owned()),
3458 car_proto::HostManifestRole::Owner,
3459 )
3460 } else {
3461 (None, car_proto::HostManifestRole::None)
3462 };
3463 car_proto::HostIdentity {
3464 version: env!("CARGO_PKG_VERSION").to_string(),
3465 pid: std::process::id(),
3466 manifest_path,
3467 manifest_role,
3468 parslee: state
3469 .parslee_session
3470 .get()
3471 .map(|session| session.identity.clone()),
3472 }
3473}
3474
3475async fn handle_parslee_auth(state: &ServerState) -> Result<Value, String> {
3486 let (session, api_base) = crate::parslee_auth::load_or_refresh_with_authority()
3487 .await?
3488 .ok_or_else(|| "Parslee account not authenticated; run `car auth login`".to_string())?;
3489 activate_parslee_session(state, session.clone(), api_base);
3490 Ok(serde_json::json!({
3491 "authenticated": true,
3492 "token_type": "Bearer",
3493 "access_token": session.access_token,
3494 "authorization_header": format!("Bearer {}", session.access_token),
3495 "identity": session.identity,
3496 }))
3497}
3498
3499async fn dispatch_daemon_owned_auth(
3533 method: &str,
3534 req: &JsonRpcMessage,
3535 state: &ServerState,
3536) -> Result<Value, HandlerFailure> {
3537 match method {
3538 "auth.authority_hint" => serde_json::to_value(car_auth::credential_authority_hint())
3539 .map_err(|error| HandlerFailure::Dispatch(error.to_string())),
3540 "auth.start" => handle_auth_start(req)
3541 .await
3542 .map_err(|error| classified_auth_failure(method, error)),
3543 "auth.complete" => accept_auth_completion(req, state)
3544 .await
3545 .map_err(|error| classified_auth_failure(method, error)),
3546 "auth.completion_status" => handle_auth_completion_status(req, state)
3547 .await
3548 .map_err(|error| classified_auth_failure(method, error)),
3549 "auth.snapshot" => handle_auth_snapshot()
3550 .await
3551 .map_err(HandlerFailure::from_dispatch),
3552 "auth.status" => handle_auth_status(req, state)
3553 .await
3554 .map_err(HandlerFailure::from_dispatch),
3555 "auth.switch_org" => handle_auth_switch_org(req)
3556 .await
3557 .map_err(HandlerFailure::from_dispatch),
3558 "auth.accounts" => handle_auth_accounts(req)
3559 .await
3560 .map_err(HandlerFailure::from_dispatch),
3561 "auth.switch_account" => handle_auth_switch_account(req)
3562 .await
3563 .map_err(HandlerFailure::from_dispatch),
3564 "auth.remove_account" => handle_auth_remove_account(req)
3565 .await
3566 .map_err(HandlerFailure::from_dispatch),
3567 "auth.logout" => handle_auth_logout()
3568 .await
3569 .map_err(HandlerFailure::from_dispatch),
3570 _ => Err(HandlerFailure::Dispatch(format!(
3571 "unknown daemon-owned auth method: {method}"
3572 ))),
3573 }
3574}
3575
3576async fn handle_auth_start(req: &JsonRpcMessage) -> Result<Value, car_auth::AuthOperationError> {
3577 let environment_api_base = std::env::var(car_auth::PARSLEE_API_BASE_KEY).ok();
3580 let api_base = select_auth_start_api_base(&req.params, environment_api_base.as_deref());
3581 let client_id = str_or(&req.params, "client_id", "parslee-car");
3582 let redirect_uri =
3583 require_str(&req.params, "redirect_uri").map_err(car_auth::AuthOperationError::Terminal)?;
3584 let provider = opt_str(&req.params, "provider");
3585 let prompt = opt_str(&req.params, "prompt");
3588 let state = car_auth::new_state();
3589 let attempt_id = uuid::Uuid::new_v4().simple().to_string();
3590 let verifier = car_auth::pkce_verifier();
3591 let challenge = car_auth::pkce_challenge(&verifier);
3592 let url = car_auth::authorize_url(
3593 &api_base,
3594 client_id,
3595 redirect_uri,
3596 &state,
3597 &challenge,
3598 provider,
3599 prompt,
3600 )
3601 .map_err(car_auth::AuthOperationError::Terminal)?;
3602 let lease = car_auth::reserve_login_attempt_classified(&attempt_id).await?;
3606 Ok(serde_json::json!({
3607 "authorize_url": url,
3608 "state": state,
3609 "verifier": verifier,
3610 "attempt_id": attempt_id,
3611 "expires_at_unix_ms": lease.attempt_expires_at_unix_ms,
3612 }))
3613}
3614
3615fn select_auth_start_api_base(params: &Value, environment_api_base: Option<&str>) -> String {
3616 opt_str(params, "api_base")
3617 .map(str::trim)
3618 .filter(|value| !value.is_empty())
3619 .map(str::to_string)
3620 .or_else(|| {
3621 environment_api_base
3622 .map(str::trim)
3623 .filter(|value| !value.is_empty())
3624 .map(str::to_string)
3625 })
3626 .unwrap_or_else(|| car_auth::DEFAULT_API_BASE.to_string())
3627 .trim_end_matches('/')
3628 .to_string()
3629}
3630
3631fn auth_completion_value(record: &car_auth::AuthCompletionRecord) -> Value {
3632 let session = record
3633 .session
3634 .as_deref()
3635 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
3636 serde_json::json!({
3637 "state": "complete",
3638 "attempt_id": record.attempt_id,
3639 "generation": record.generation,
3640 "account_id": record.account_id,
3641 "session": session,
3642 })
3643}
3644
3645#[derive(Clone)]
3646struct AuthCompletionRequest {
3647 api_base: String,
3648 client_id: String,
3649 redirect_uri: String,
3650 code: String,
3651 verifier: String,
3652 attempt_id: String,
3653}
3654
3655fn parse_auth_completion_request(req: &JsonRpcMessage) -> Result<AuthCompletionRequest, String> {
3656 Ok(AuthCompletionRequest {
3657 api_base: car_auth::api_base(opt_str(&req.params, "api_base")),
3658 client_id: str_or(&req.params, "client_id", "parslee-car").to_string(),
3659 redirect_uri: require_str(&req.params, "redirect_uri")?.to_string(),
3660 code: require_str(&req.params, "code")?.to_string(),
3661 verifier: require_str(&req.params, "verifier")?.to_string(),
3662 attempt_id: require_str(&req.params, "attempt_id")?.to_string(),
3663 })
3664}
3665
3666async fn accept_auth_completion(
3667 req: &JsonRpcMessage,
3668 state: &ServerState,
3669) -> Result<Value, car_auth::AuthOperationError> {
3670 let request =
3671 parse_auth_completion_request(req).map_err(car_auth::AuthOperationError::Terminal)?;
3672 let attempt_id = request.attempt_id.clone();
3673 let task_attempt_id = attempt_id.clone();
3674 let lease =
3675 car_auth::claim_login_attempt_classified(&attempt_id, &state.auth_completion_owner_id)
3676 .await?;
3677 state
3678 .spawn_durable_task(format!("auth.complete:{attempt_id}"), async move {
3679 if let Err(error) = complete_auth_completion(request, lease).await {
3680 tracing::warn!(attempt_id = %task_attempt_id, error = %error, "Parslee login completion failed");
3681 }
3682 })
3683 .await;
3684 Ok(serde_json::json!({
3685 "state": "accepted",
3686 "attempt_id": attempt_id,
3687 }))
3688}
3689
3690async fn complete_auth_completion(
3691 request: AuthCompletionRequest,
3692 lease: car_auth::LoginAttemptLease,
3693) -> Result<Value, String> {
3694 let worker_lease = lease.clone();
3698 let result = match tokio::spawn(complete_claimed_auth_completion(request, worker_lease)).await {
3699 Ok(result) => result,
3700 Err(error) => Err(format!(
3701 "Parslee login worker stopped unexpectedly: {error}"
3702 )),
3703 };
3704 if result.is_err() {
3705 match car_auth::fail_login_attempt(
3706 &lease,
3707 car_auth::AuthAttemptFailure::completion_failed(),
3708 )
3709 .await
3710 {
3711 Ok(true) => {}
3712 Ok(false) => {
3713 tracing::debug!(
3714 attempt_id = %lease.attempt_id,
3715 "stale auth worker exit could not replace a newer attempt"
3716 );
3717 }
3718 Err(error) => {
3719 tracing::warn!(
3720 attempt_id = %lease.attempt_id,
3721 error = %error,
3722 "failed to persist terminal auth worker lifecycle"
3723 );
3724 }
3725 }
3726 }
3727 result
3728}
3729
3730async fn complete_claimed_auth_completion(
3731 request: AuthCompletionRequest,
3732 lease: car_auth::LoginAttemptLease,
3733) -> Result<Value, String> {
3734 let (token, completion_session) =
3735 auth_completion_network_with_deadline(car_auth::AUTH_COMPLETION_NETWORK_DEADLINE, async {
3736 let token = car_auth::exchange_code(
3737 &request.api_base,
3738 &request.client_id,
3739 &request.redirect_uri,
3740 &request.code,
3741 &request.verifier,
3742 )
3743 .await?;
3744 let session = fetch_completion_session(&request.api_base, &token.access_token).await?;
3745 Ok((token, session))
3746 })
3747 .await?;
3748
3749 let record =
3752 car_auth::commit_login(&request.api_base, &token, &completion_session, Some(lease)).await?;
3753
3754 Ok(auth_completion_value(&record))
3755}
3756
3757async fn auth_completion_network_with_deadline<T, F>(
3758 deadline: std::time::Duration,
3759 operation: F,
3760) -> Result<T, String>
3761where
3762 F: std::future::Future<Output = Result<T, String>>,
3763{
3764 tokio::time::timeout(deadline, operation)
3765 .await
3766 .map_err(|_| {
3767 format!(
3768 "Parslee login network phase timed out after {}s; no credentials were saved",
3769 deadline.as_secs()
3770 )
3771 })?
3772}
3773
3774async fn fetch_completion_session(api_base: &str, access_token: &str) -> Result<String, String> {
3779 match car_auth::fetch_status_with_access(api_base, access_token).await {
3780 Ok(session) => Ok(session),
3781 Err(first_error) => {
3782 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3783 car_auth::fetch_status_with_access(api_base, access_token)
3784 .await
3785 .map_err(|second_error| {
3786 format!(
3787 "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}"
3788 )
3789 })
3790 }
3791 }
3792}
3793
3794async fn handle_auth_completion_status(
3799 req: &JsonRpcMessage,
3800 state: &ServerState,
3801) -> Result<Value, car_auth::AuthOperationError> {
3802 let attempt_id =
3803 require_str(&req.params, "attempt_id").map_err(car_auth::AuthOperationError::Terminal)?;
3804 let mut value = serde_json::to_value(
3805 car_auth::auth_completion_status_classified(attempt_id, &state.auth_completion_owner_id)
3806 .await?,
3807 )
3808 .map_err(|error| {
3809 car_auth::AuthOperationError::Terminal(format!("serialize auth completion status: {error}"))
3810 })?;
3811 if let Some(raw_session) = value.get("session").and_then(Value::as_str) {
3812 let decoded = serde_json::from_str::<Value>(raw_session).unwrap_or(Value::Null);
3813 value["session"] = decoded;
3814 }
3815 Ok(value)
3816}
3817
3818async fn handle_auth_snapshot() -> Result<Value, String> {
3821 serde_json::to_value(car_auth::local_auth_snapshot().await?).map_err(|e| e.to_string())
3822}
3823
3824async fn handle_auth_status(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
3825 let mode = if req
3826 .params
3827 .get("retry_keychain_access")
3828 .and_then(Value::as_bool)
3829 == Some(true)
3830 {
3831 car_auth::CredentialReadMode::Retry
3832 } else {
3833 car_auth::CredentialReadMode::Use
3834 };
3835 let Some(mut credential) = car_auth::resolve_credential(mode)
3836 .await
3837 .map_err(|error| error.to_string())?
3838 else {
3839 return Ok(serde_json::json!({ "authenticated": false }));
3840 };
3841
3842 let session_json =
3845 match car_auth::fetch_status_with_access(&credential.api_base, &credential.access_token)
3846 .await
3847 {
3848 Ok(session) => session,
3849 Err(error) if error.contains("HTTP 401") => {
3850 let Some(refreshed) = car_auth::refresh_credential()
3851 .await
3852 .map_err(|refresh_error| refresh_error.to_string())?
3853 else {
3854 return Err(error);
3855 };
3856 let session = car_auth::fetch_status_with_access(
3857 &refreshed.api_base,
3858 &refreshed.access_token,
3859 )
3860 .await?;
3861 credential = refreshed;
3862 session
3863 }
3864 Err(error) => return Err(error),
3865 };
3866 if let Ok(session) =
3867 crate::parslee_auth::session_from_status(credential.access_token, &session_json)
3868 {
3869 activate_parslee_session(state, session, credential.api_base);
3870 }
3871 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
3872 Ok(serde_json::json!({ "authenticated": true, "session": session }))
3873}
3874
3875fn activate_parslee_session(
3876 state: &ServerState,
3877 session: crate::parslee_auth::ParsleeSession,
3878 api_base: String,
3879) {
3880 let email = session
3881 .identity
3882 .email
3883 .clone()
3884 .unwrap_or_else(|| "<unknown>".to_string());
3885 let org = session
3886 .identity
3887 .active_organization
3888 .clone()
3889 .unwrap_or_else(|| "<none>".to_string());
3890 let access_token = session.access_token.clone();
3891 if state.install_parslee_session(session).is_err() {
3892 return;
3893 }
3894
3895 info!(
3896 email = %email,
3897 active_org = %org,
3898 "Parslee account auth activated for this CAR daemon"
3899 );
3900 let Some(runtime_url) = state.mobile_registration_url.get().cloned() else {
3901 return;
3902 };
3903 let token = state.auth_token.get().cloned();
3904 tokio::spawn(async move {
3905 let registration =
3906 crate::mobile_runtime::MobileRuntimeRegistration::new(runtime_url, token);
3907 match crate::mobile_runtime::register(&api_base, &access_token, ®istration).await {
3908 Ok(()) => info!("registered this CAR machine for Parslee mobile discovery"),
3909 Err(error) => tracing::warn!(
3910 error = %error,
3911 "failed to register this CAR machine for Parslee mobile discovery; \
3912 local Parslee Core runtime remains available"
3913 ),
3914 }
3915 });
3916}
3917
3918async fn handle_auth_logout() -> Result<Value, String> {
3919 car_auth::logout().await?;
3920 Ok(serde_json::json!({ "ok": true }))
3921}
3922
3923fn handle_assistant_identity_get() -> Result<Value, String> {
3973 let identity = car_identity::IdentityStore::from_home().load()?;
3974 Ok(assistant_identity_wire(&identity))
3975}
3976
3977async fn handle_assistant_identity_set(
3984 req: &JsonRpcMessage,
3985 session: &crate::session::ClientSession,
3986 state: &Arc<ServerState>,
3987) -> Result<Value, String> {
3988 require_approval_authority(session, state)?;
3989
3990 let store = car_identity::IdentityStore::from_home();
3991 let mut identity = store.load()?;
3992
3993 if let Some(name) = req.params.get("name").and_then(Value::as_str) {
3997 identity.set_name(name)?;
3998 }
3999 if let Some(Value::Array(items)) = req.params.get("spellings") {
4000 identity.spellings = car_identity::validate_spellings(
4001 items
4002 .iter()
4003 .filter_map(|v| v.as_str().map(str::to_string))
4004 .collect(),
4005 )?;
4006 }
4007 match req.params.get("user_name") {
4008 Some(Value::Null) => identity = identity.with_user_name(None)?,
4009 Some(Value::String(user)) => {
4010 identity = identity.with_user_name(Some(user.clone()))?;
4011 }
4012 _ => {}
4013 }
4014 identity.touch();
4015 store.save(&identity)?;
4016
4017 let wire = assistant_identity_wire(&identity);
4018 session
4019 .host
4020 .record_event(
4021 "assistant.identity.changed",
4022 None,
4023 format!("the assistant now answers to {}", identity.name),
4024 wire.clone(),
4025 )
4026 .await;
4027 Ok(wire)
4028}
4029
4030fn assistant_identity_wire(identity: &car_identity::AssistantIdentity) -> Value {
4037 serde_json::json!({
4038 "name": identity.name,
4039 "spellings": identity.spellings,
4040 "aliases": identity.aliases(),
4041 "user_name": identity.user_name,
4042 "brand": car_identity::BRAND_NAME,
4043 "updated_at_unix": identity.updated_at_unix,
4044 })
4045}
4046
4047fn handle_openrouter_status(
4048 session: &crate::session::ClientSession,
4049 state: &ServerState,
4050) -> Result<Value, String> {
4051 require_approval_authority(session, state)?;
4052 serde_json::to_value(crate::openrouter_auth::status()).map_err(|e| e.to_string())
4053}
4054
4055async fn handle_openrouter_auth_start(
4056 req: &JsonRpcMessage,
4057 session: &crate::session::ClientSession,
4058 state: &ServerState,
4059) -> Result<Value, String> {
4060 require_approval_authority(session, state)?;
4061 let authorization_base_url = opt_str(&req.params, "authorization_base_url");
4062 let exchange_url = opt_str(&req.params, "exchange_url");
4063 let timeout_seconds = req.params.get("timeout_seconds").and_then(Value::as_u64);
4064 let started =
4065 crate::openrouter_auth::start(authorization_base_url, exchange_url, timeout_seconds)
4066 .await?;
4067 serde_json::to_value(started).map_err(|e| e.to_string())
4068}
4069
4070fn handle_openrouter_auth_cancel(
4071 req: &JsonRpcMessage,
4072 session: &crate::session::ClientSession,
4073 state: &ServerState,
4074) -> Result<Value, String> {
4075 require_approval_authority(session, state)?;
4076 let status = crate::openrouter_auth::cancel(opt_str(&req.params, "flow_id"));
4077 serde_json::to_value(status).map_err(|e| e.to_string())
4078}
4079
4080async fn handle_openrouter_disconnect(
4081 session: &crate::session::ClientSession,
4082 state: &ServerState,
4083) -> Result<Value, String> {
4084 require_approval_authority(session, state)?;
4085 serde_json::to_value(crate::openrouter_auth::disconnect().await?).map_err(|e| e.to_string())
4086}
4087
4088async fn handle_auth_accounts(req: &JsonRpcMessage) -> Result<Value, String> {
4091 let api_base = opt_str(&req.params, "api_base");
4092 let accounts = car_auth::list_accounts(api_base).await?;
4093 Ok(serde_json::json!({ "accounts": accounts }))
4094}
4095
4096async fn handle_auth_switch_account(req: &JsonRpcMessage) -> Result<Value, String> {
4099 let account_id = req
4100 .params
4101 .get("account_id")
4102 .and_then(|v| v.as_str())
4103 .ok_or("invalid params: account_id is required")?;
4104 let api_base = opt_str(&req.params, "api_base");
4105 car_auth::switch_account(account_id).await?;
4106 let _ = car_auth::access_token_refreshing().await;
4108 match car_auth::fetch_status(api_base).await? {
4109 Some(session_json) => {
4110 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4111 Ok(serde_json::json!({ "authenticated": true, "session": session }))
4112 }
4113 None => Ok(serde_json::json!({ "authenticated": false })),
4114 }
4115}
4116
4117async fn handle_auth_remove_account(req: &JsonRpcMessage) -> Result<Value, String> {
4120 let account_id = req
4121 .params
4122 .get("account_id")
4123 .and_then(|v| v.as_str())
4124 .ok_or("invalid params: account_id is required")?;
4125 let accounts = car_auth::remove_account(account_id).await?;
4126 Ok(serde_json::json!({ "ok": true, "accounts": accounts }))
4127}
4128
4129async fn handle_auth_switch_org(req: &JsonRpcMessage) -> Result<Value, String> {
4134 let org_id = req
4135 .params
4136 .get("organization_id")
4137 .and_then(|v| v.as_str())
4138 .ok_or("invalid params: organization_id is required")?;
4139 let api_base = opt_str(&req.params, "api_base");
4140 car_auth::switch_org(api_base, org_id).await?;
4141 match car_auth::fetch_status(api_base).await? {
4142 Some(session_json) => {
4143 let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4144 Ok(serde_json::json!({ "authenticated": true, "session": session }))
4145 }
4146 None => Ok(serde_json::json!({ "authenticated": false })),
4147 }
4148}
4149
4150async fn handle_host_agents(
4151 session: &crate::session::ClientSession,
4152 state: &Arc<ServerState>,
4153) -> Result<Value, String> {
4154 let mut agents = session.host.agents().await;
4157
4158 if let Ok(supervisor) = state.supervisor() {
4169 use car_registry::supervisor::AgentStatus;
4170 let have: std::collections::HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
4171 for m in supervisor.list().await {
4172 if m.spec.capabilities.is_empty() || have.contains(&m.spec.id) {
4173 continue;
4174 }
4175 let status = match m.status {
4176 AgentStatus::Running => car_proto::HostAgentStatus::Running,
4177 AgentStatus::Starting => car_proto::HostAgentStatus::Idle,
4178 AgentStatus::Backoff | AgentStatus::Errored => car_proto::HostAgentStatus::Errored,
4179 AgentStatus::Stopped => car_proto::HostAgentStatus::Stopped,
4180 };
4181 agents.push(car_proto::HostAgent {
4182 id: m.spec.id.clone(),
4183 name: m.spec.name.clone(),
4184 kind: "supervised".to_string(),
4185 capabilities: m.spec.capabilities.clone(),
4186 project: None,
4187 session_id: None,
4188 status,
4189 current_task: None,
4190 pid: m.pid,
4191 display: Default::default(),
4192 updated_at: chrono::Utc::now(),
4193 metadata: Value::Null,
4194 });
4195 }
4196 }
4197
4198 serde_json::to_value(agents).map_err(|e| e.to_string())
4199}
4200
4201async fn handle_host_events(
4202 req: &JsonRpcMessage,
4203 session: &crate::session::ClientSession,
4204) -> Result<Value, String> {
4205 let limit = req
4206 .params
4207 .get("limit")
4208 .and_then(|v| v.as_u64())
4209 .unwrap_or(100) as usize;
4210 serde_json::to_value(session.host.events(limit).await).map_err(|e| e.to_string())
4211}
4212
4213async fn handle_host_approvals(session: &crate::session::ClientSession) -> Result<Value, String> {
4214 serde_json::to_value(session.host.approvals().await).map_err(|e| e.to_string())
4215}
4216
4217async fn handle_a2ui_apply(
4218 req: &JsonRpcMessage,
4219 state: &Arc<ServerState>,
4220) -> Result<Value, String> {
4221 #[derive(Deserialize)]
4222 struct Params {
4223 #[serde(default)]
4224 envelope: Option<car_a2ui::A2uiEnvelope>,
4225 #[serde(default)]
4226 message: Option<car_a2ui::A2uiEnvelope>,
4227 }
4228
4229 let envelope = if req.params.get("createSurface").is_some()
4230 || req.params.get("updateComponents").is_some()
4231 || req.params.get("updateDataModel").is_some()
4232 || req.params.get("deleteSurface").is_some()
4233 {
4234 serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4235 .map_err(|e| e.to_string())?
4236 } else {
4237 match serde_json::from_value::<Params>(req.params.clone()) {
4238 Ok(params) => params
4239 .envelope
4240 .or(params.message)
4241 .ok_or_else(|| "`a2ui.apply` requires an A2UI envelope".to_string())?,
4242 Err(_) => serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4243 .map_err(|e| e.to_string())?,
4244 }
4245 };
4246
4247 apply_a2ui_envelope(state, envelope, None, None).await
4248}
4249
4250async fn handle_a2ui_ingest(
4251 req: &JsonRpcMessage,
4252 state: &Arc<ServerState>,
4253) -> Result<Value, String> {
4254 #[derive(Deserialize)]
4255 #[serde(rename_all = "camelCase")]
4256 struct Params {
4257 #[serde(default)]
4258 endpoint: Option<String>,
4259 #[serde(default)]
4260 a2a_endpoint: Option<String>,
4261 #[serde(default)]
4262 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4263 #[serde(default)]
4264 route_auth: Option<A2aRouteAuth>,
4265 #[serde(default)]
4266 allow_untrusted_endpoint: bool,
4267 }
4268
4269 let params = serde_json::from_value::<Params>(req.params.clone()).unwrap_or(Params {
4270 endpoint: None,
4271 a2a_endpoint: None,
4272 owner: None,
4273 route_auth: None,
4274 allow_untrusted_endpoint: false,
4275 });
4276 let payload = req.params.get("payload").unwrap_or(&req.params);
4277 state
4278 .a2ui
4279 .validate_payload(payload)
4280 .map_err(|e| e.to_string())?;
4281 let envelopes = car_a2ui::envelopes_from_value(payload).map_err(|e| e.to_string())?;
4282 if envelopes.is_empty() {
4283 return Err("no A2UI envelopes found in payload".into());
4284 }
4285 let endpoint = params.endpoint.or(params.a2a_endpoint);
4286 let endpoint = trusted_route_endpoint(endpoint, params.allow_untrusted_endpoint);
4287 let owner = params
4288 .owner
4289 .or_else(|| car_a2ui::owner_from_value(payload))
4290 .map(|owner| match endpoint.clone() {
4291 Some(endpoint) => owner.with_endpoint(Some(endpoint)),
4292 None => owner,
4293 });
4294
4295 let mut results = Vec::new();
4296 for envelope in envelopes {
4297 let value =
4298 apply_a2ui_envelope(state, envelope, owner.clone(), params.route_auth.clone()).await?;
4299 results.push(value);
4300 }
4301 Ok(serde_json::json!({ "applied": results }))
4302}
4303
4304async fn apply_a2ui_envelope(
4305 state: &Arc<ServerState>,
4306 envelope: car_a2ui::A2uiEnvelope,
4307 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4308 route_auth: Option<A2aRouteAuth>,
4309) -> Result<Value, String> {
4310 let result = state
4311 .a2ui
4312 .apply_with_owner(envelope, owner)
4313 .await
4314 .map_err(|e| e.to_string())?;
4315 update_a2ui_route_auth(state, &result, route_auth).await;
4316 let kind = if result.deleted {
4317 "a2ui.surface_deleted"
4318 } else {
4319 "a2ui.surface_updated"
4320 };
4321 let message = if result.deleted {
4322 format!("A2UI surface {} deleted", result.surface_id)
4323 } else {
4324 format!("A2UI surface {} updated", result.surface_id)
4325 };
4326 let payload = serde_json::to_value(&result).map_err(|e| e.to_string())?;
4327 state
4328 .host
4329 .record_event(kind, None, message, payload.clone())
4330 .await;
4331 broadcast_a2ui_event(state, kind, &payload).await;
4335 serde_json::to_value(result).map_err(|e| e.to_string())
4336}
4337
4338async fn broadcast_a2ui_event(state: &Arc<ServerState>, kind: &str, result: &Value) {
4339 use futures::SinkExt;
4340 use tokio_tungstenite::tungstenite::Message;
4341 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
4342 .a2ui_subscribers
4343 .lock()
4344 .await
4345 .values()
4346 .cloned()
4347 .collect();
4348 if subscribers.is_empty() {
4349 return;
4350 }
4351 let Ok(json) = serde_json::to_string(&serde_json::json!({
4352 "jsonrpc": "2.0",
4353 "method": "a2ui.event",
4354 "params": {
4355 "kind": kind,
4356 "result": result,
4357 },
4358 })) else {
4359 return;
4360 };
4361 for channel in subscribers {
4362 let _ = channel
4363 .write
4364 .lock()
4365 .await
4366 .send(Message::Text(json.clone().into()))
4367 .await;
4368 }
4369}
4370
4371async fn update_a2ui_route_auth(
4372 state: &Arc<ServerState>,
4373 result: &car_a2ui::A2uiApplyResult,
4374 route_auth: Option<A2aRouteAuth>,
4375) {
4376 let mut auth = state.a2ui_route_auth.lock().await;
4377 if result.deleted {
4378 auth.remove(&result.surface_id);
4379 return;
4380 }
4381
4382 let has_route_endpoint = result
4383 .surface
4384 .as_ref()
4385 .and_then(|surface| surface.owner.as_ref())
4386 .and_then(|owner| owner.endpoint.as_ref())
4387 .is_some();
4388 match (has_route_endpoint, route_auth) {
4389 (true, Some(route_auth)) => {
4390 auth.insert(result.surface_id.clone(), route_auth);
4391 }
4392 _ => {
4393 auth.remove(&result.surface_id);
4394 }
4395 }
4396}
4397
4398fn handle_a2ui_capabilities(state: &Arc<ServerState>) -> Result<Value, String> {
4399 serde_json::to_value(state.a2ui.capabilities()).map_err(|e| e.to_string())
4400}
4401
4402async fn handle_a2ui_reap(state: &Arc<ServerState>) -> Result<Value, String> {
4403 let removed = state.a2ui.reap_expired(chrono::Utc::now()).await;
4404 if !removed.is_empty() {
4405 let mut auth = state.a2ui_route_auth.lock().await;
4406 for surface_id in &removed {
4407 auth.remove(surface_id);
4408 }
4409 }
4410 Ok(serde_json::json!({ "removed": removed }))
4411}
4412
4413async fn handle_a2ui_surfaces(state: &Arc<ServerState>) -> Result<Value, String> {
4414 serde_json::to_value(state.a2ui.list().await).map_err(|e| e.to_string())
4415}
4416
4417async fn handle_a2ui_get(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
4418 let surface_id = req
4419 .params
4420 .get("surface_id")
4421 .or_else(|| req.params.get("surfaceId"))
4422 .and_then(Value::as_str)
4423 .ok_or_else(|| "`a2ui.get` requires surface_id".to_string())?;
4424 serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4425}
4426
4427async fn handle_a2ui_subscribe(
4433 session: &crate::session::ClientSession,
4434 state: &Arc<ServerState>,
4435) -> Result<Value, String> {
4436 state
4437 .a2ui_subscribers
4438 .lock()
4439 .await
4440 .insert(session.client_id.clone(), session.channel.clone());
4441 Ok(serde_json::json!({ "subscribed": true }))
4442}
4443
4444async fn handle_a2ui_unsubscribe(
4448 session: &crate::session::ClientSession,
4449 state: &Arc<ServerState>,
4450) -> Result<Value, String> {
4451 state
4452 .a2ui_subscribers
4453 .lock()
4454 .await
4455 .remove(&session.client_id);
4456 Ok(serde_json::json!({ "subscribed": false }))
4457}
4458
4459async fn handle_a2ui_replay(
4466 req: &JsonRpcMessage,
4467 state: &Arc<ServerState>,
4468) -> Result<Value, String> {
4469 let surface_id = req
4470 .params
4471 .get("surface_id")
4472 .or_else(|| req.params.get("surfaceId"))
4473 .and_then(Value::as_str)
4474 .ok_or_else(|| "`a2ui/replay` requires surface_id".to_string())?;
4475 serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4476}
4477
4478async fn handle_a2ui_action(
4479 req: &JsonRpcMessage,
4480 state: &Arc<ServerState>,
4481) -> Result<Value, String> {
4482 let action: car_a2ui::ClientAction =
4483 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4484 let owner = state.a2ui.owner(&action.surface_id).await;
4485
4486 let action_result = serde_json::json!({
4496 "surfaceId": action.surface_id,
4497 "action": action,
4498 "owner": owner,
4499 });
4500 broadcast_a2ui_event(state, "a2ui.action", &action_result).await;
4501
4502 let route = route_a2ui_action(state, &action, owner.clone()).await;
4503 let payload = serde_json::json!({
4504 "action": action,
4505 "owner": owner,
4506 "route": route,
4507 });
4508 let event = state
4509 .host
4510 .record_event(
4511 "a2ui.action",
4512 None,
4513 format!(
4514 "A2UI action {} from {}",
4515 action.name, action.source_component_id
4516 ),
4517 payload,
4518 )
4519 .await;
4520 Ok(serde_json::json!({
4521 "event": event,
4522 "route": route,
4523 }))
4524}
4525
4526async fn handle_a2ui_render_report(
4533 req: &JsonRpcMessage,
4534 state: &Arc<ServerState>,
4535) -> Result<Value, String> {
4536 let report: car_a2ui::RenderReport =
4540 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4541 let payload = serde_json::to_value(&report).map_err(|e| e.to_string())?;
4542 let kind = "a2ui.render_report";
4543 let message = format!("A2UI render report for surface {}", report.surface_id);
4544 let event = state
4545 .host
4546 .record_event(kind, None, message, payload.clone())
4547 .await;
4548 broadcast_a2ui_event(state, kind, &payload).await;
4549
4550 if let Some(surface) = state.a2ui.get(&report.surface_id).await {
4558 if !state.ui_agent_budget.try_consume(&report.surface_id) {
4564 tracing::warn!(
4565 surface_id = %report.surface_id,
4566 count = state.ui_agent_budget.count(&report.surface_id),
4567 max = state.ui_agent_budget.max(),
4568 "ui-agent iteration budget exhausted; skipping agent invocation"
4569 );
4570 return Ok(serde_json::json!({ "event": event }));
4571 }
4572 match state.ui_agent.on_render_report(&report, &surface) {
4576 car_ui_agent::Decision::Patch {
4577 envelope,
4578 strategy_id,
4579 patch_hash,
4580 elapsed_ns,
4581 } => {
4582 if !state
4590 .ui_agent_oscillation
4591 .check_and_record(&report.surface_id, patch_hash)
4592 {
4593 tracing::warn!(
4594 surface_id = %report.surface_id,
4595 strategy = %strategy_id,
4596 patch_hash,
4597 "ui-agent oscillation detected; suppressing patch"
4598 );
4599 state.ui_agent_budget.refund(&report.surface_id);
4602 return Ok(serde_json::json!({ "event": event }));
4603 }
4604 let a2ui_envelope = car_a2ui::A2uiEnvelope {
4605 patch_components: Some(envelope),
4606 ..Default::default()
4607 };
4608 if let Err(e) = apply_a2ui_envelope(state, a2ui_envelope, None, None).await {
4609 tracing::warn!(
4610 surface_id = %report.surface_id,
4611 strategy = %strategy_id,
4612 patch_hash,
4613 elapsed_ns,
4614 error = %e,
4615 "ui-agent patch apply failed",
4616 );
4617 state.ui_agent_budget.refund(&report.surface_id);
4619 } else {
4620 tracing::debug!(
4621 surface_id = %report.surface_id,
4622 strategy = %strategy_id,
4623 patch_hash,
4624 elapsed_ns,
4625 iteration = state.ui_agent_budget.count(&report.surface_id),
4626 "ui-agent patch applied",
4627 );
4628 if let Some(memgine) = state.shared_memgine.clone() {
4638 let speaker = format!("ui-agent/{}", report.surface_id);
4639 let text = format!("strategy applied: {}", strategy_id);
4640 tokio::spawn(async move {
4641 let mut guard = memgine.lock().await;
4642 guard.ingest_conversation(&speaker, &text, chrono::Utc::now());
4643 });
4644 }
4645 }
4646 }
4647 car_ui_agent::Decision::StableNoChange => {
4648 state.ui_agent_budget.refund(&report.surface_id);
4650 }
4651 car_ui_agent::Decision::HardStop { reason } => {
4652 state.ui_agent_budget.refund(&report.surface_id);
4653 tracing::error!(
4659 surface_id = %report.surface_id,
4660 reason = %reason,
4661 "ui-agent hard-stopped improvement loop",
4662 );
4663 }
4664 }
4665 } else {
4666 tracing::debug!(
4667 surface_id = %report.surface_id,
4668 "ui-agent skipped — surface not found in store",
4669 );
4670 }
4671
4672 Ok(serde_json::json!({ "event": event }))
4673}
4674
4675async fn route_a2ui_action(
4676 state: &Arc<ServerState>,
4677 action: &car_a2ui::ClientAction,
4678 owner: Option<car_a2ui::A2uiSurfaceOwner>,
4679) -> Value {
4680 let Some(owner) = owner else {
4681 return serde_json::json!({ "delivered": false, "reason": "surface has no owner" });
4682 };
4683 if owner.kind != "a2a" {
4684 return serde_json::json!({ "delivered": false, "reason": "unsupported owner kind", "owner": owner });
4685 }
4686 let Some(endpoint) = owner.endpoint.clone() else {
4687 return serde_json::json!({
4688 "delivered": false,
4689 "reason": "surface owner has no endpoint",
4690 "owner": owner
4691 });
4692 };
4693
4694 let message = car_a2a::Message {
4695 message_id: format!("a2ui-action-{}", uuid::Uuid::new_v4().simple()),
4696 role: car_a2a::MessageRole::User,
4697 parts: vec![car_a2a::Part::Data(car_a2a::types::DataPart {
4698 data: serde_json::json!({
4699 "a2uiAction": action,
4700 }),
4701 metadata: Default::default(),
4702 })],
4703 task_id: owner.task_id.clone(),
4704 context_id: owner.context_id.clone(),
4705 metadata: Default::default(),
4706 };
4707
4708 let auth = state
4709 .a2ui_route_auth
4710 .lock()
4711 .await
4712 .get(&action.surface_id)
4713 .cloned()
4714 .map(client_auth_from_route_auth)
4715 .unwrap_or(car_a2a::ClientAuth::None);
4716
4717 match car_a2a::A2aClient::new(endpoint.clone())
4718 .with_auth(auth)
4719 .send_message(message, false)
4720 .await
4721 {
4722 Ok(result) => serde_json::json!({
4723 "delivered": true,
4724 "owner": owner,
4725 "endpoint": endpoint,
4726 "result": result,
4727 }),
4728 Err(error) => serde_json::json!({
4729 "delivered": false,
4730 "owner": owner,
4731 "endpoint": endpoint,
4732 "error": error.to_string(),
4733 }),
4734 }
4735}
4736
4737fn client_auth_from_route_auth(auth: A2aRouteAuth) -> car_a2a::ClientAuth {
4738 match auth {
4739 A2aRouteAuth::None => car_a2a::ClientAuth::None,
4740 A2aRouteAuth::Bearer { token } => car_a2a::ClientAuth::Bearer(token),
4741 A2aRouteAuth::Header { name, value } => car_a2a::ClientAuth::Header { name, value },
4742 }
4743}
4744
4745fn trusted_route_endpoint(endpoint: Option<String>, allow_untrusted: bool) -> Option<String> {
4746 let endpoint = endpoint?;
4747 if allow_untrusted || is_loopback_http_endpoint(&endpoint) {
4748 Some(endpoint)
4749 } else {
4750 None
4751 }
4752}
4753
4754fn is_loopback_http_endpoint(endpoint: &str) -> bool {
4755 endpoint == "http://localhost"
4756 || endpoint.starts_with("http://localhost:")
4757 || endpoint.starts_with("http://localhost/")
4758 || endpoint == "http://127.0.0.1"
4759 || endpoint.starts_with("http://127.0.0.1:")
4760 || endpoint.starts_with("http://127.0.0.1/")
4761 || endpoint == "http://[::1]"
4762 || endpoint.starts_with("http://[::1]:")
4763 || endpoint.starts_with("http://[::1]/")
4764}
4765
4766async fn handle_host_register_agent(
4767 req: &JsonRpcMessage,
4768 session: &crate::session::ClientSession,
4769) -> Result<Value, String> {
4770 let request: RegisterHostAgentRequest =
4771 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4772 serde_json::to_value(
4773 session
4774 .host
4775 .register_agent(&session.client_id, request)
4776 .await?,
4777 )
4778 .map_err(|e| e.to_string())
4779}
4780
4781async fn handle_host_unregister_agent(
4782 req: &JsonRpcMessage,
4783 session: &crate::session::ClientSession,
4784) -> Result<Value, String> {
4785 let agent_id = req
4786 .params
4787 .get("agent_id")
4788 .and_then(|v| v.as_str())
4789 .ok_or("missing agent_id")?;
4790 session
4791 .host
4792 .unregister_agent(&session.client_id, agent_id)
4793 .await?;
4794 Ok(serde_json::json!({"ok": true}))
4795}
4796
4797async fn handle_host_set_status(
4798 req: &JsonRpcMessage,
4799 session: &crate::session::ClientSession,
4800) -> Result<Value, String> {
4801 let request: SetHostAgentStatusRequest =
4802 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4803 serde_json::to_value(session.host.set_status(&session.client_id, request).await?)
4804 .map_err(|e| e.to_string())
4805}
4806
4807async fn handle_host_register_device(
4808 req: &JsonRpcMessage,
4809 session: &crate::session::ClientSession,
4810) -> Result<Value, String> {
4811 let request: RegisterHostDeviceRequest =
4812 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4813 serde_json::to_value(
4814 session
4815 .host
4816 .register_device(&session.client_id, request)
4817 .await?,
4818 )
4819 .map_err(|e| e.to_string())
4820}
4821
4822async fn handle_host_update_device(
4823 req: &JsonRpcMessage,
4824 session: &crate::session::ClientSession,
4825) -> Result<Value, String> {
4826 let request: UpdateHostDeviceRequest =
4827 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4828 serde_json::to_value(
4829 session
4830 .host
4831 .update_device(&session.client_id, request)
4832 .await?,
4833 )
4834 .map_err(|e| e.to_string())
4835}
4836
4837async fn handle_host_devices(session: &crate::session::ClientSession) -> Result<Value, String> {
4838 serde_json::to_value(session.host.devices().await).map_err(|e| e.to_string())
4839}
4840
4841async fn handle_mobile_runtime(state: &Arc<ServerState>) -> Result<Value, String> {
4842 let url = state
4843 .mobile_runtime_url
4844 .get()
4845 .cloned()
4846 .ok_or_else(|| "mobile Parslee Core connection is not configured".to_string())?;
4847 let token = state.auth_token.get().cloned();
4848 Ok(serde_json::json!({
4849 "name": "Parslee Core",
4850 "source": "car",
4851 "url": url,
4852 "token": token,
4853 }))
4854}
4855
4856async fn handle_host_notify(
4857 req: &JsonRpcMessage,
4858 session: &crate::session::ClientSession,
4859) -> Result<Value, String> {
4860 let kind = req
4861 .params
4862 .get("kind")
4863 .and_then(|v| v.as_str())
4864 .unwrap_or("host.notification");
4865 let agent_id = req
4866 .params
4867 .get("agent_id")
4868 .and_then(|v| v.as_str())
4869 .map(str::to_string);
4870 let message = req
4871 .params
4872 .get("message")
4873 .and_then(|v| v.as_str())
4874 .unwrap_or("");
4875 let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
4876 serde_json::to_value(
4877 session
4878 .host
4879 .record_event(kind, agent_id, message, payload)
4880 .await,
4881 )
4882 .map_err(|e| e.to_string())
4883}
4884
4885async fn handle_host_request_approval(
4886 req: &JsonRpcMessage,
4887 session: &crate::session::ClientSession,
4888) -> Result<Value, String> {
4889 let mut request: CreateHostApprovalRequest =
4890 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4891 request.agent_id = stamped_requester(
4902 authenticated_bound_agent_id(session).await,
4903 request.agent_id.take(),
4904 );
4905 if let Some(agent_id) = &request.agent_id {
4906 let _ = session
4911 .host
4912 .set_status(
4913 &session.client_id,
4914 SetHostAgentStatusRequest {
4915 agent_id: agent_id.clone(),
4916 status: HostAgentStatus::WaitingForApproval,
4917 current_task: None,
4918 message: Some("Waiting for approval".to_string()),
4919 payload: Value::Null,
4920 },
4921 )
4922 .await;
4923 }
4924 let owner_client_id = if request.system_level {
4931 None
4932 } else {
4933 Some(session.client_id.as_str())
4934 };
4935 serde_json::to_value(
4936 session
4937 .host
4938 .create_approval(owner_client_id, request)
4939 .await?,
4940 )
4941 .map_err(|e| e.to_string())
4942}
4943
4944async fn handle_host_resolve_approval(
4945 req: &JsonRpcMessage,
4946 session: &crate::session::ClientSession,
4947) -> Result<Value, String> {
4948 let request: ResolveHostApprovalRequest =
4949 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4950 let bound_agent = authenticated_bound_agent_id(session).await;
4954 let resolver =
4955 car_server_types::host::Resolver::agent_session(&session.client_id, bound_agent.as_deref());
4956 serde_json::to_value(session.host.resolve_approval(resolver, request).await?)
4957 .map_err(|e| e.to_string())
4958}
4959
4960const AGENT_METHOD_NOT_ALLOWED_ERROR_CODE: i32 = -32601;
4965const AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX: &str =
4966 "agent_method_not_allowed: supervised agent token does not allow daemon method ";
4967
4968fn agent_method_scope_allows(session: &crate::session::ClientSession, method: &str) -> bool {
4973 if matches!(method, "session.auth" | "server.handshake")
4974 || session.is_host.load(std::sync::atomic::Ordering::Acquire)
4975 {
4976 return true;
4977 }
4978 match session.agent_method_allowlist.read() {
4979 Ok(scope) => scope
4980 .as_ref()
4981 .is_none_or(|methods| methods.contains(method)),
4982 Err(_) => false,
4985 }
4986}
4987
4988async fn handle_session_auth(
4999 req: &JsonRpcMessage,
5000 session: &crate::session::ClientSession,
5001 state: &Arc<ServerState>,
5002) -> Result<Value, String> {
5003 let tenant_binding = match req.params.get("tenant_id").and_then(Value::as_str) {
5008 Some(t) if !t.is_empty() => {
5009 validate_tenant_id(t)?;
5010 Some(t.to_string())
5011 }
5012 _ => None,
5013 };
5014
5015 let memory_namespace = req
5024 .params
5025 .get("memory_namespace")
5026 .and_then(Value::as_str)
5027 .filter(|s| !s.trim().is_empty())
5028 .map(str::to_string);
5029
5030 if let Some(host_supplied) = req.params.get("host_token").and_then(Value::as_str) {
5040 let expected = state.host_token.get().ok_or_else(|| {
5041 "host auth unavailable: this daemon has no host token (started with --no-auth?)"
5042 .to_string()
5043 })?;
5044 if !constant_time_eq(host_supplied.as_bytes(), expected.as_bytes()) {
5045 return Err("auth failed: host token mismatch".to_string());
5046 }
5047 session
5048 .authenticated
5049 .store(true, std::sync::atomic::Ordering::Release);
5050 session
5051 .is_host
5052 .store(true, std::sync::atomic::Ordering::Release);
5053 state.browser_views.broadcast_host_connected(true).await;
5059 *session.tenant.lock().await = tenant_binding;
5060 if let Some(ns) = memory_namespace.as_deref() {
5061 bind_memory_namespace(state, session, ns).await?;
5062 }
5063 return Ok(serde_json::json!({
5064 "ok": true,
5065 "auth_enabled": true,
5066 "role": "host",
5067 "memory_namespace": memory_namespace,
5068 }));
5069 }
5070
5071 let supplied = req
5072 .params
5073 .get("token")
5074 .and_then(Value::as_str)
5075 .ok_or_else(|| "session.auth requires { token: string }".to_string())?;
5076 let agent_id = req
5083 .params
5084 .get("agent_id")
5085 .and_then(Value::as_str)
5086 .map(str::to_string);
5087
5088 if let Some(id) = agent_id {
5089 let supervisor = state.supervisor()?;
5090 let binding = supervisor
5091 .authenticate_agent_token(&id, supplied)
5092 .await
5093 .map_err(|error| format!("auth failed for agent_id `{id}`: {error}"))?
5094 .ok_or_else(|| {
5095 format!("auth failed: agent_id `{id}` is not supervised, or token mismatch")
5096 })?;
5097 *session
5103 .agent_method_allowlist
5104 .write()
5105 .map_err(|_| "auth failed: agent method allowlist lock poisoned".to_string())? =
5106 binding
5107 .method_allowlist
5108 .clone()
5109 .map(|methods| methods.into_iter().collect());
5110 *session.agent_id.lock().await = Some(id.clone());
5121 {
5125 let mut attached = state.attached_agents.lock().await;
5126 if let Some(prior) = attached.get(&id) {
5127 if prior != &session.client_id {
5128 tracing::warn!(
5145 agent_id = %id,
5146 prior_client = %prior,
5147 new_client = %session.client_id,
5148 "superseding stale agent attach claim (respawn took over)"
5149 );
5150 }
5151 }
5152 attached.insert(id.clone(), session.client_id.clone());
5153 }
5154 let agent_eng = get_or_load_agent_memgine(state, &id).await?;
5159 let bound = match memory_namespace.as_deref() {
5164 Some(ns) => {
5165 *session.memory_namespace.lock().await = Some(ns.to_string());
5169 get_or_load_namespace_memgine(state, ns).await?
5170 }
5171 None => agent_eng,
5172 };
5173 *session.bound_memgine.lock().await = Some(bound);
5174 session
5175 .authenticated
5176 .store(true, std::sync::atomic::Ordering::Release);
5177 *session.tenant.lock().await = tenant_binding;
5178 let mut response = serde_json::json!({
5179 "ok": true,
5180 "auth_enabled": true,
5181 "agent_id": id,
5182 });
5183 if let Some(methods) = binding.method_allowlist {
5184 response["method_allowlist"] = serde_json::json!(methods);
5185 }
5186 return Ok(response);
5187 }
5188
5189 let expected = match state.auth_token.get() {
5190 Some(t) => t,
5191 None => {
5192 if let Some(ns) = memory_namespace.as_deref() {
5198 bind_memory_namespace(state, session, ns).await?;
5199 }
5200 session
5201 .authenticated
5202 .store(true, std::sync::atomic::Ordering::Release);
5203 *session.tenant.lock().await = tenant_binding;
5204 return Ok(serde_json::json!({
5205 "ok": true,
5206 "auth_enabled": false,
5207 "memory_namespace": memory_namespace,
5208 }));
5209 }
5210 };
5211 if !constant_time_eq(supplied.as_bytes(), expected.as_bytes()) {
5212 return Err("auth failed: token mismatch".to_string());
5213 }
5214 if let Some(ns) = memory_namespace.as_deref() {
5215 bind_memory_namespace(state, session, ns).await?;
5216 }
5217 session
5218 .authenticated
5219 .store(true, std::sync::atomic::Ordering::Release);
5220 *session.tenant.lock().await = tenant_binding;
5221 Ok(serde_json::json!({
5222 "ok": true,
5223 "auth_enabled": true,
5224 "memory_namespace": memory_namespace,
5225 "parslee": state.parslee_session.get().map(|session| session.identity.clone()),
5226 }))
5227}
5228
5229fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5233 if a.len() != b.len() {
5234 return false;
5235 }
5236 let mut diff: u8 = 0;
5237 for (x, y) in a.iter().zip(b.iter()) {
5238 diff |= x ^ y;
5239 }
5240 diff == 0
5241}
5242
5243async fn gate_high_risk_method(
5253 method: &str,
5254 params: &Value,
5255 state: &Arc<ServerState>,
5256) -> Result<(), String> {
5257 let timeout = state.approval_gate.timeout;
5258 let req = CreateHostApprovalRequest {
5259 agent_id: None,
5260 action: format!("ws.method:{method}"),
5261 details: serde_json::json!({
5262 "method": method,
5263 "params_preview": preview_params(params, 2_000),
5267 }),
5268 options: vec!["approve".to_string(), "deny".to_string()],
5269 system_level: true,
5273 };
5274 match state
5275 .host
5276 .request_and_wait_approval(req, "approve", timeout)
5277 .await
5278 {
5279 Ok(crate::host::ApprovalOutcome::Approved) => Ok(()),
5280 Ok(crate::host::ApprovalOutcome::Denied) => Err(format!(
5281 "{method} denied by user (approval gate, audit 2026-05). \
5282 To call this method without an interactive prompt, start \
5283 car-server with --no-approvals on a trusted machine."
5284 )),
5285 Ok(crate::host::ApprovalOutcome::TimedOut) => Err(format!(
5286 "{method} approval timed out after {}s with no resolution. \
5287 The approval is still visible in `host.approvals` for \
5288 forensics; resubmit the request to retry.",
5289 timeout.as_secs()
5290 )),
5291 Err(e) => Err(format!("approval gate error: {e}")),
5292 }
5293}
5294
5295fn preview_params(value: &Value, max_chars: usize) -> Value {
5296 let s = value.to_string();
5297 if s.len() <= max_chars {
5298 value.clone()
5299 } else {
5300 Value::String(format!("{}… (truncated)", &s[..max_chars]))
5301 }
5302}
5303
5304async fn handle_session_init(
5305 req: &JsonRpcMessage,
5306 session: &crate::session::ClientSession,
5307) -> Result<Value, String> {
5308 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5309 let init: SessionInitRequest =
5310 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5311
5312 for tool in &init.tools {
5313 register_callback_definition(session, tool).await?;
5314 }
5315
5316 let mut policy_count = 0;
5317 {
5318 let mut policies = session.runtime.policies.write().await;
5319 for policy_def in &init.policies {
5320 if let Some(check) = build_policy_check(policy_def) {
5321 match blanket_denied_tool(policy_def) {
5322 Some(tool) => policies.register_tool_deny(&policy_def.name, &tool, check, ""),
5323 None => policies.register(&policy_def.name, check, ""),
5324 }
5325 policy_count += 1;
5326 }
5327 }
5328 }
5329
5330 serde_json::to_value(SessionInitResponse {
5331 session_id: session.client_id.clone(),
5332 tools_registered: init.tools.len(),
5333 policies_registered: policy_count,
5334 })
5335 .map_err(|e| e.to_string())
5336}
5337
5338async fn handle_session_clear_halt(
5345 session: &crate::session::ClientSession,
5346) -> Result<Value, String> {
5347 if !session.is_host.load(Ordering::Acquire) {
5348 return Err("session.clear_halt requires the host-management role".to_string());
5349 }
5350 let cleared = session.halted.swap(false, Ordering::AcqRel);
5351 Ok(serde_json::json!({ "cleared": cleared, "halted": false }))
5352}
5353
5354async fn handle_session_bind_substrate(
5366 req: &JsonRpcMessage,
5367 session: &Arc<crate::session::ClientSession>,
5368 state: &Arc<ServerState>,
5369) -> Result<Value, String> {
5370 let connector = req
5371 .params
5372 .get("connector")
5373 .and_then(|v| v.as_str())
5374 .ok_or_else(|| "missing `connector` (MCP connector slug)".to_string())?;
5375
5376 let name = state
5377 .bind_substrate_to_connector(session, connector)
5378 .await?;
5379 Ok(serde_json::json!({ "bound": true, "substrate": name }))
5380}
5381
5382async fn handle_session_bind_sandbox(
5396 req: &JsonRpcMessage,
5397 session: &Arc<crate::session::ClientSession>,
5398 state: &Arc<ServerState>,
5399) -> Result<Value, String> {
5400 if let Some(agent_id) = session.agent_id.lock().await.clone() {
5419 return Err(format!(
5420 "session.bindSandbox is not available to supervised agents \
5421 (agent '{agent_id}'): it would let a confined agent bind-mount any \
5422 host directory into a container it controls. See Parslee-ai/car#480."
5423 ));
5424 }
5425
5426 let working_dir = req
5427 .params
5428 .get("working_dir")
5429 .and_then(|v| v.as_str())
5430 .ok_or_else(|| "missing `working_dir`".to_string())?;
5431 let wd = std::path::Path::new(working_dir);
5432 if !wd.is_dir() {
5433 return Err(format!("working_dir '{working_dir}' is not a directory"));
5434 }
5435
5436 let mut config = car_sandbox::SandboxConfig {
5437 working_dir: wd.to_path_buf(),
5438 ..Default::default()
5439 };
5440 if let Some(image) = opt_str(&req.params, "image") {
5441 config.image = image.to_string();
5442 }
5443 if let Some(net) = opt_str(&req.params, "network") {
5445 config.network = Some(net.to_string());
5446 }
5447 if let Some(mem) = opt_str(&req.params, "memory") {
5448 config.memory = Some(mem.to_string());
5449 }
5450 if let Some(cpus) = opt_str(&req.params, "cpus") {
5451 config.cpus = Some(cpus.to_string());
5452 }
5453 if let Some(pids) = req.params.get("pids_limit").and_then(|v| v.as_u64()) {
5454 config.pids_limit = Some(pids);
5455 }
5456 if let Some(t) = req
5460 .params
5461 .get("command_timeout_secs")
5462 .and_then(|v| v.as_u64())
5463 {
5464 if t == 0 {
5465 return Err("command_timeout_secs must be >= 1".to_string());
5466 }
5467 config.command_timeout_secs = t;
5468 }
5469
5470 let pf = car_sandbox::preflight(&config.image).await;
5474 if !pf.is_ok() {
5475 return Err(format!("sandbox preflight failed: {}", pf.message()));
5476 }
5477
5478 let executor = std::sync::Arc::new(car_sandbox::SandboxExecutor::new(config));
5479 let substrate: std::sync::Arc<dyn car_engine::Substrate> = executor;
5480 let name = substrate.name().to_string();
5481
5482 session.runtime.set_substrate(substrate).await;
5490 session.runtime.register_agent_basics().await;
5491 let ws_executor = std::sync::Arc::new(crate::session::WsToolExecutor::new(
5498 session.channel.clone(),
5499 session.negotiated_capabilities.clone(),
5500 session.halted.clone(),
5501 ));
5502 let composed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5503 std::sync::Arc::new(state.mcp_executor.share_with_fallback(ws_executor));
5504 let shadowed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5505 std::sync::Arc::new(crate::session::SubstrateShadowExecutor::new(composed));
5506 session.runtime.set_executor(shadowed).await;
5507
5508 Ok(serde_json::json!({ "bound": true, "substrate": name }))
5509}
5510
5511fn blanket_denied_tool(def: &PolicyDefinition) -> Option<String> {
5524 (def.rule.as_str() == "deny_tool").then(|| def.target.clone())
5525}
5526
5527fn build_policy_check(def: &PolicyDefinition) -> Option<car_policy::PolicyCheck> {
5528 match def.rule.as_str() {
5529 "deny_tool" => {
5530 let target = def.target.clone();
5531 Some(Box::new(
5532 move |action: &car_ir::Action, _: &car_state::StateStore| {
5533 if action.tool.as_deref() == Some(&target) {
5534 Some(format!("tool '{}' denied", target))
5535 } else {
5536 None
5537 }
5538 },
5539 ))
5540 }
5541 "deny_connector" => {
5546 let slug = def.target.clone();
5547 let prefix = format!("mcp_{slug}_");
5548 Some(Box::new(
5549 move |action: &car_ir::Action, _: &car_state::StateStore| match action
5550 .tool
5551 .as_deref()
5552 {
5553 Some(tool) if tool.starts_with(&prefix) => {
5554 Some(format!("connector '{}' denied", slug))
5555 }
5556 _ => None,
5557 },
5558 ))
5559 }
5560 "require_state" => {
5561 let key = def.key.clone();
5562 let value = def.value.clone();
5563 Some(Box::new(
5564 move |_: &car_ir::Action, state: &car_state::StateStore| {
5565 if state.get(&key).as_ref() != Some(&value) {
5566 Some(format!("state['{}'] must be {:?}", key, value))
5567 } else {
5568 None
5569 }
5570 },
5571 ))
5572 }
5573 "deny_tool_param" => {
5574 let target = def.target.clone();
5575 let param = def.key.clone();
5576 let pattern = def.pattern.clone();
5577 Some(Box::new(
5578 move |action: &car_ir::Action, _: &car_state::StateStore| {
5579 if action.tool.as_deref() != Some(&target) {
5580 return None;
5581 }
5582 if let Some(val) = action.parameters.get(¶m) {
5583 let s = val.as_str().unwrap_or(&val.to_string()).to_string();
5584 if s.contains(&pattern) {
5585 return Some(format!("param '{}' matches '{}'", param, pattern));
5586 }
5587 }
5588 None
5589 },
5590 ))
5591 }
5592 _ => None,
5593 }
5594}
5595
5596async fn handle_tools_register(
5597 req: &JsonRpcMessage,
5598 session: &crate::session::ClientSession,
5599) -> Result<Value, String> {
5600 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5601 let tools: Vec<ToolDefinition> =
5602 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5603 for tool in &tools {
5604 register_callback_definition(session, tool).await?;
5605 }
5606 Ok(Value::from(tools.len()))
5607}
5608
5609fn callback_standing_authority_eligible(def: &ToolDefinition) -> bool {
5617 if def.name != "newsroom.publish"
5622 || def.idempotent
5623 || def.cache_ttl_secs.is_some()
5624 || def.rate_limit.is_some()
5625 {
5626 return false;
5627 }
5628 def.parameters
5629 == serde_json::json!({
5630 "type": "object",
5631 "properties": {
5632 "edition_id": {
5633 "type": "string",
5634 "minLength": 1
5635 }
5636 },
5637 "required": ["edition_id"],
5638 "additionalProperties": false
5639 })
5640}
5641
5642async fn register_callback_definition(
5643 session: &crate::session::ClientSession,
5644 def: &ToolDefinition,
5645) -> Result<(), String> {
5646 if session.runtime.registry.get(&def.name).await.is_some() {
5647 return Err(format!(
5648 "tool '{}' is server-owned and cannot be shadowed by tools.register",
5649 def.name
5650 ));
5651 }
5652 let digest = car_proto::canonical_sha256(def)?;
5653 {
5654 let mut callbacks = session.callback_tool_schema_digests.write().await;
5655 if callback_standing_authority_eligible(def) {
5656 callbacks.insert(def.name.clone(), digest);
5657 } else {
5658 callbacks.remove(&def.name);
5659 }
5660 }
5661 session
5662 .runtime
5663 .register_tool_schema(car_ir::ToolSchema {
5664 name: def.name.clone(),
5665 source: car_ir::ToolSourceKind::UserDefined,
5666 description: def.description.clone(),
5667 parameters: def.parameters.clone(),
5668 returns: def.returns.clone(),
5669 idempotent: def.idempotent,
5670 cache_ttl_secs: def.cache_ttl_secs,
5671 rate_limit: def.rate_limit.as_ref().map(|rl| car_ir::ToolRateLimit {
5672 max_calls: rl.max_calls,
5673 interval_secs: rl.interval_secs,
5674 }),
5675 })
5676 .await;
5677 Ok(())
5678}
5679
5680async fn handle_tools_list(session: &crate::session::ClientSession) -> Result<Value, String> {
5718 let mut schemas = session.runtime.tool_schemas().await;
5719 schemas.sort_by(|a, b| a.name.cmp(&b.name));
5720 let count = schemas.len();
5721 let tools = schemas
5722 .iter()
5723 .map(serde_json::to_value)
5724 .collect::<std::result::Result<Vec<Value>, _>>()
5725 .map_err(|e| format!("serialize tool schema: {e}"))?;
5726 Ok(serde_json::json!({ "tools": tools, "count": count }))
5727}
5728
5729async fn handle_tools_unregister(
5745 req: &JsonRpcMessage,
5746 session: &crate::session::ClientSession,
5747) -> Result<Value, String> {
5748 let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5749 let name = req
5750 .params
5751 .get("name")
5752 .and_then(|v| v.as_str())
5753 .ok_or("missing 'name'")?;
5754 let removed = session.runtime.unregister_tool(name).await;
5755 session
5756 .callback_tool_schema_digests
5757 .write()
5758 .await
5759 .remove(name);
5760 Ok(serde_json::json!({
5761 "unregistered": name,
5762 "removed": u32::from(removed),
5763 }))
5764}
5765
5766async fn handle_tools_poll(
5780 req: &JsonRpcMessage,
5781 session: &crate::session::ClientSession,
5782) -> Result<Value, String> {
5783 let handle = req
5784 .params
5785 .get("handle")
5786 .and_then(|v| v.as_str())
5787 .ok_or_else(|| "missing `handle`".to_string())?;
5788 match session.runtime.tool_poll(handle).await {
5789 Some(res) => serde_json::to_value(&res).map_err(|e| e.to_string()),
5790 None => Ok(Value::Null),
5791 }
5792}
5793
5794async fn handle_tools_cancel(
5800 req: &JsonRpcMessage,
5801 session: &crate::session::ClientSession,
5802) -> Result<Value, String> {
5803 let handle = req
5804 .params
5805 .get("handle")
5806 .and_then(|v| v.as_str())
5807 .ok_or_else(|| "missing `handle`".to_string())?;
5808 let cancelled = session.runtime.tool_cancel(handle).await;
5809 Ok(serde_json::json!({ "cancelled": cancelled }))
5810}
5811
5812async fn handle_tools_stream_subscribe(
5823 session: &crate::session::ClientSession,
5824) -> Result<Value, String> {
5825 use std::sync::atomic::Ordering;
5826 if session
5827 .tool_stream_subscribed
5828 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
5829 .is_err()
5830 {
5831 return Ok(serde_json::json!({ "subscribed": true }));
5833 }
5834 let mut rx = session.runtime.subscribe_tool_events();
5835 let channel = session.channel.clone();
5836 let subscribed = session.tool_stream_subscribed.clone();
5841 tokio::spawn(async move {
5842 use futures::SinkExt;
5843 struct ResetOnExit(std::sync::Arc<std::sync::atomic::AtomicBool>);
5844 impl Drop for ResetOnExit {
5845 fn drop(&mut self) {
5846 self.0.store(false, std::sync::atomic::Ordering::SeqCst);
5847 }
5848 }
5849 let _reset_on_exit = ResetOnExit(subscribed);
5850 loop {
5851 match rx.recv().await {
5852 Ok(ev) => {
5853 let Ok(json) = serde_json::to_string(&serde_json::json!({
5854 "jsonrpc": "2.0",
5855 "method": "tools.stream.event",
5856 "params": { "handle": ev.handle.id, "chunk": ev.chunk },
5857 })) else {
5858 continue;
5859 };
5860 let mut guard = channel.write.lock().await;
5865 let send = tokio::time::timeout(
5866 std::time::Duration::from_secs(10),
5867 guard.send(Message::Text(json.into())),
5868 )
5869 .await;
5870 drop(guard);
5871 match send {
5872 Ok(Ok(())) => {}
5873 _ => break,
5874 }
5875 }
5876 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
5880 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
5881 }
5882 }
5883 });
5884 Ok(serde_json::json!({ "subscribed": true }))
5885}
5886
5887async fn handle_connectors_add(
5901 req: &JsonRpcMessage,
5902 state: &Arc<ServerState>,
5903) -> Result<Value, String> {
5904 state.ensure_connectors_loaded().await;
5905 let name = req
5906 .params
5907 .get("name")
5908 .and_then(|v| v.as_str())
5909 .ok_or_else(|| "missing `name`".to_string())?;
5910 let url = req
5911 .params
5912 .get("url")
5913 .and_then(|v| v.as_str())
5914 .ok_or_else(|| "missing `url`".to_string())?;
5915 let headers: Vec<(String, String)> = req
5916 .params
5917 .get("headers")
5918 .and_then(|v| v.as_object())
5919 .map(|m| {
5920 m.iter()
5921 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
5922 .collect()
5923 })
5924 .unwrap_or_default();
5925
5926 let status = state
5927 .connectors()
5928 .add(name, url, headers)
5929 .await
5930 .map_err(|e| e.to_string())?;
5931 serde_json::to_value(status).map_err(|e| e.to_string())
5932}
5933
5934async fn handle_connectors_add_stdio(
5938 req: &JsonRpcMessage,
5939 state: &Arc<ServerState>,
5940) -> Result<Value, String> {
5941 state.ensure_connectors_loaded().await;
5942 let name = req
5943 .params
5944 .get("name")
5945 .and_then(|v| v.as_str())
5946 .ok_or_else(|| "missing `name`".to_string())?;
5947 let command = req
5948 .params
5949 .get("command")
5950 .and_then(|v| v.as_str())
5951 .ok_or_else(|| "missing `command`".to_string())?;
5952 let args: Vec<String> = req
5953 .params
5954 .get("args")
5955 .and_then(|v| v.as_array())
5956 .map(|a| {
5957 a.iter()
5958 .filter_map(|v| v.as_str().map(str::to_string))
5959 .collect()
5960 })
5961 .unwrap_or_default();
5962 let env: std::collections::BTreeMap<String, String> = req
5963 .params
5964 .get("env")
5965 .and_then(|v| v.as_object())
5966 .map(|m| {
5967 m.iter()
5968 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
5969 .collect()
5970 })
5971 .unwrap_or_default();
5972
5973 let status = state
5974 .connectors()
5975 .add_stdio(name, command, args, env)
5976 .await
5977 .map_err(|e| e.to_string())?;
5978 serde_json::to_value(status).map_err(|e| e.to_string())
5979}
5980
5981async fn handle_connectors_authenticate(
5987 req: &JsonRpcMessage,
5988 state: &Arc<ServerState>,
5989) -> Result<Value, String> {
5990 state.ensure_connectors_loaded().await;
5991 let name = req
5992 .params
5993 .get("name")
5994 .and_then(|v| v.as_str())
5995 .ok_or_else(|| "missing `name`".to_string())?;
5996 let url = req
5997 .params
5998 .get("url")
5999 .and_then(|v| v.as_str())
6000 .ok_or_else(|| "missing `url`".to_string())?;
6001 let redirect_uri = req
6002 .params
6003 .get("redirect_uri")
6004 .and_then(|v| v.as_str())
6005 .ok_or_else(|| "missing `redirect_uri`".to_string())?;
6006
6007 let (authorize_url, oauth_state) = state
6008 .connectors()
6009 .authenticate(name, url, redirect_uri)
6010 .await
6011 .map_err(|e| e.to_string())?;
6012 Ok(serde_json::json!({ "authorize_url": authorize_url, "state": oauth_state }))
6013}
6014
6015async fn handle_connectors_complete_authentication(
6019 req: &JsonRpcMessage,
6020 state: &Arc<ServerState>,
6021) -> Result<Value, String> {
6022 state.ensure_connectors_loaded().await;
6023 let oauth_state = req
6024 .params
6025 .get("state")
6026 .and_then(|v| v.as_str())
6027 .ok_or_else(|| "missing `state`".to_string())?;
6028 let code = req
6029 .params
6030 .get("code")
6031 .and_then(|v| v.as_str())
6032 .ok_or_else(|| "missing `code`".to_string())?;
6033
6034 let status = state
6035 .connectors()
6036 .complete_authentication(oauth_state, code)
6037 .await
6038 .map_err(|e| e.to_string())?;
6039 serde_json::to_value(status).map_err(|e| e.to_string())
6040}
6041
6042async fn handle_connectors_list(state: &Arc<ServerState>) -> Result<Value, String> {
6044 state.ensure_connectors_loaded().await;
6045 let list = state.connectors().list().await;
6046 serde_json::to_value(list).map_err(|e| e.to_string())
6047}
6048
6049async fn handle_connectors_tools(
6052 req: &JsonRpcMessage,
6053 state: &Arc<ServerState>,
6054) -> Result<Value, String> {
6055 state.ensure_connectors_loaded().await;
6056 let slug = req
6057 .params
6058 .get("slug")
6059 .and_then(|v| v.as_str())
6060 .ok_or_else(|| "missing `slug`".to_string())?;
6061 let tools = state
6062 .connectors()
6063 .tools(slug)
6064 .await
6065 .map_err(|e| e.to_string())?;
6066 serde_json::to_value(tools).map_err(|e| e.to_string())
6067}
6068
6069async fn handle_connectors_enable_tools(
6073 req: &JsonRpcMessage,
6074 state: &Arc<ServerState>,
6075) -> Result<Value, String> {
6076 state.ensure_connectors_loaded().await;
6077 let slug = req
6078 .params
6079 .get("slug")
6080 .and_then(|v| v.as_str())
6081 .ok_or_else(|| "missing `slug`".to_string())?;
6082 let tools: Vec<String> = req
6083 .params
6084 .get("tools")
6085 .and_then(|v| v.as_array())
6086 .map(|a| {
6087 a.iter()
6088 .filter_map(|v| v.as_str().map(str::to_string))
6089 .collect()
6090 })
6091 .ok_or_else(|| "missing `tools` array".to_string())?;
6092
6093 let entries = state
6094 .connectors()
6095 .enable_tools(slug, &tools)
6096 .await
6097 .map_err(|e| e.to_string())?;
6098 let enabled = entries.len();
6099 state.register_connector_entries(&entries).await;
6100 Ok(serde_json::json!({ "enabled": enabled }))
6101}
6102
6103async fn handle_connectors_refresh(
6106 req: &JsonRpcMessage,
6107 state: &Arc<ServerState>,
6108) -> Result<Value, String> {
6109 state.ensure_connectors_loaded().await;
6110 let slug = req
6111 .params
6112 .get("slug")
6113 .and_then(|v| v.as_str())
6114 .ok_or_else(|| "missing `slug`".to_string())?;
6115 let tools = state
6116 .connectors()
6117 .refresh(slug)
6118 .await
6119 .map_err(|e| e.to_string())?;
6120 let entries = state.connectors().enabled_tool_entries().await;
6123 state.register_connector_entries(&entries).await;
6124 serde_json::to_value(tools).map_err(|e| e.to_string())
6125}
6126
6127async fn handle_connectors_remove(
6130 req: &JsonRpcMessage,
6131 state: &Arc<ServerState>,
6132) -> Result<Value, String> {
6133 state.ensure_connectors_loaded().await;
6134 let slug = req
6135 .params
6136 .get("slug")
6137 .and_then(|v| v.as_str())
6138 .ok_or_else(|| "missing `slug`".to_string())?;
6139 let canonicals = state
6140 .connectors()
6141 .remove(slug)
6142 .await
6143 .map_err(|e| e.to_string())?;
6144 state.unregister_connector_tools(&canonicals).await;
6145 Ok(serde_json::json!({ "removed": slug }))
6146}
6147
6148async fn run_store_blocking<T, F>(context: &'static str, operation: F) -> Result<T, String>
6149where
6150 T: Send + 'static,
6151 F: FnOnce() -> Result<T, String> + Send + 'static,
6152{
6153 tokio::task::spawn_blocking(operation)
6154 .await
6155 .map_err(|error| format!("{context} blocking task failed: {error}"))?
6156}
6157
6158async fn strict_run_trace_read_blocking<T, F>(
6163 context: &'static str,
6164 state: &Arc<ServerState>,
6165 run_id: String,
6166 operation: F,
6167) -> Result<T, String>
6168where
6169 T: Send + 'static,
6170 F: FnOnce() -> std::io::Result<T> + Send + 'static,
6171{
6172 let result = tokio::task::spawn_blocking(operation)
6173 .await
6174 .map_err(|error| format!("{context} blocking task failed: {error}"))?;
6175 match result {
6176 Ok(value) => Ok(value),
6177 Err(error) if crate::run_store::is_trace_corruption_error(&error) => Err(state
6178 .quarantine_run_trace_from_read(&run_id, error.to_string())
6179 .await),
6180 Err(error) => Err(run_trace_read_error(&run_id, error)),
6181 }
6182}
6183
6184async fn run_lifecycle_durability_blocking<T, F>(
6191 guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6192 context: &'static str,
6193 operation: F,
6194) -> Result<T, String>
6195where
6196 T: Send + 'static,
6197 F: FnOnce() -> Result<T, String> + Send + 'static,
6198{
6199 let owned = guard
6200 .take()
6201 .expect("proposal durability requires the owned lifecycle guard");
6202 match tokio::task::spawn_blocking(move || {
6203 let result = operation();
6204 (owned, result)
6205 })
6206 .await
6207 {
6208 Ok((owned, result)) => {
6209 *guard = Some(owned);
6210 result
6211 }
6212 Err(error) => Err(format!("{context} blocking task failed: {error}")),
6213 }
6214}
6215
6216async fn handle_connectors_disable_tools(
6220 req: &JsonRpcMessage,
6221 state: &Arc<ServerState>,
6222) -> Result<Value, String> {
6223 state.ensure_connectors_loaded().await;
6224 let slug = req
6225 .params
6226 .get("slug")
6227 .and_then(|v| v.as_str())
6228 .ok_or_else(|| "missing `slug`".to_string())?;
6229 let tools: Vec<String> = req
6230 .params
6231 .get("tools")
6232 .and_then(|v| v.as_array())
6233 .map(|a| {
6234 a.iter()
6235 .filter_map(|v| v.as_str().map(str::to_string))
6236 .collect()
6237 })
6238 .ok_or_else(|| "missing `tools` array".to_string())?;
6239
6240 let canonicals = state
6241 .connectors()
6242 .disable_tools(slug, &tools)
6243 .await
6244 .map_err(|e| e.to_string())?;
6245 let disabled = canonicals.len();
6246 state.unregister_connector_tools(&canonicals).await;
6247 Ok(serde_json::json!({ "disabled": disabled }))
6248}
6249
6250async fn handle_proposal_submit(
6251 req: &JsonRpcMessage,
6252 session: &crate::session::ClientSession,
6253 state: &Arc<ServerState>,
6254) -> Result<Value, String> {
6255 let submit: ProposalSubmitRequest =
6256 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
6257 let original_submission = req
6258 .params
6259 .get("proposal")
6260 .cloned()
6261 .ok_or_else(|| "proposal.submit is missing `proposal`".to_string())?;
6262 car_engine::validate_proposal_action_ids(&submit.proposal)
6268 .map_err(|error| format!("invalid proposal: {error}"))?;
6269 let submitted_proposal_digest = canonical_sha256(&submit.proposal)?;
6270 let submitted_proposal_value =
6271 serde_json::to_value(&submit.proposal).map_err(|e| e.to_string())?;
6272 let session_id = match req.params.get("session_id") {
6278 None | Some(Value::Null) => None,
6279 Some(Value::String(value)) if !value.is_empty() => Some(value.clone()),
6280 Some(_) => return Err("invalid `session_id`: expected a non-empty string".to_string()),
6281 };
6282
6283 let scope: Option<car_engine::RuntimeScope> = match req.params.get("scope") {
6290 Some(v) if !v.is_null() => {
6291 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid scope: {e}"))?)
6292 }
6293 _ => None,
6294 };
6295 if session_id.is_some() && scope.is_some() {
6296 return Err(
6297 "proposal.submit cannot combine `session_id` and `scope`: CAR cannot yet enforce both contexts"
6298 .to_string(),
6299 );
6300 }
6301
6302 let mut run_guard = Some(session.run_lifecycle_guard.clone().lock_owned().await);
6307 let current_run = session.current_run_id.lock().await.clone();
6308
6309 let receipt_owner = {
6314 let run_store = state.run_store.clone();
6315 let requested_policy_session_id = session_id.clone();
6316 let original_submission = original_submission.clone();
6317 tokio::task::spawn_blocking(move || {
6318 run_store.completed_proposal_retry_owner(
6319 requested_policy_session_id.as_deref(),
6320 &original_submission,
6321 )
6322 })
6323 .await
6324 .map_err(|error| format!("completed proposal response owner lookup failed: {error}"))?
6325 .map_err(|error| format!("completed proposal response registry is unreadable: {error}"))?
6326 };
6327 if let Some((receipt_run, receipt_client)) = receipt_owner.as_ref() {
6328 let owner_matches = if current_run.as_deref() == Some(receipt_run.as_str()) {
6329 state
6330 .run_owner_binding(receipt_run)
6331 .await
6332 .is_some_and(|(durable, active)| {
6333 durable == *receipt_client && active == session.client_id
6334 })
6335 } else {
6336 false
6337 };
6338 if !owner_matches {
6339 return Err(format!(
6340 "{} completed proposal response belongs to run `{receipt_run}` / durable client `{receipt_client}`; this socket is not the active authenticated owner",
6341 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6342 ));
6343 }
6344 }
6345 if let Some(run_id) = current_run.as_deref() {
6346 let (durable_client, active_client) = state
6347 .run_owner_binding(run_id)
6348 .await
6349 .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6350 let (owner_client, terminal, start_committed, completion_pending, trace_corruption) = state
6351 .run_lifecycle_state_with_corruption(run_id)
6352 .await
6353 .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6354 if let Some(error) = trace_corruption {
6355 return Err(error);
6356 }
6357 if terminal
6358 || !start_committed
6359 || completion_pending
6360 || owner_client != session.client_id
6361 || active_client != session.client_id
6362 {
6363 return Err(format!(
6364 "active run is not writable for run_id `{run_id}` / client_id `{}`",
6365 session.client_id
6366 ));
6367 }
6368 session.require_run_journal_binding(run_id).await?;
6369
6370 let (completed, resumed_policy_rotation) = {
6371 let run_store = state.run_store.clone();
6372 let run_id = run_id.to_string();
6373 let client_id = durable_client.clone();
6374 let requested_policy_session_id = session_id.clone();
6375 let original_submission = original_submission.clone();
6376 run_store_blocking("completed proposal response lookup", move || {
6377 let exact = run_store
6378 .completed_proposal(
6379 &run_id,
6380 &client_id,
6381 requested_policy_session_id.as_deref(),
6382 &original_submission,
6383 )
6384 .map_err(|error| {
6385 proposal_durability_quarantine(&run_id, "completed response", &error)
6386 })?;
6387 let resumed_owner = client_id != active_client;
6388 if exact.is_some() || !resumed_owner {
6389 return Ok((exact, resumed_owner));
6390 }
6391 run_store
6392 .completed_proposal_for_resumed_owner(&run_id, &client_id, &original_submission)
6393 .map(|receipt| (receipt, true))
6394 .map_err(|error| {
6395 proposal_durability_quarantine(
6396 &run_id,
6397 "resumed completed response",
6398 &error,
6399 )
6400 })
6401 })
6402 .await?
6403 };
6404 if let Some(receipt) = completed {
6405 if resumed_policy_rotation {
6406 let original_policy = &receipt.finalization.requested_policy_session_id;
6407 let authenticated_original_policy = &receipt.finalization.policy_session_id;
6408 let replacement_policy_is_live = match session_id.as_deref() {
6409 Some(policy_session_id) => {
6410 session.runtime.session_exists(policy_session_id).await
6411 }
6412 None => false,
6413 };
6414 match (original_policy, authenticated_original_policy, &session_id) {
6415 (None, None, None) => {}
6416 (Some(requested), Some(authenticated), Some(_))
6417 if requested == authenticated && replacement_policy_is_live => {}
6418 _ => {
6419 return Err(
6420 "resumed proposal recovery requires the original authenticated policy provenance and a live replacement policy session"
6421 .to_string(),
6422 );
6423 }
6424 }
6425 }
6426 let run_store = state.run_store.clone();
6427 let (value, cleanup_error) = run_lifecycle_durability_blocking(
6428 &mut run_guard,
6429 "completed proposal guard cleanup",
6430 move || {
6431 let cleanup_error = run_store
6432 .cleanup_completed_proposal_guards(&receipt)
6433 .err()
6434 .map(|error| error.to_string());
6435 let value = run_store
6436 .completed_proposal_response_value(&receipt)
6437 .map_err(|error| {
6438 format!("completed proposal response serialization failed: {error}")
6439 })?;
6440 Ok((value, cleanup_error))
6441 },
6442 )
6443 .await?;
6444 if let Some(error) = cleanup_error {
6445 tracing::warn!(run_id, %error, "completed proposal response remains recoverable while guard cleanup is pending");
6446 }
6447 return Ok(value);
6448 }
6449
6450 let pending = {
6451 let run_store = state.run_store.clone();
6452 let run_id = run_id.to_string();
6453 run_store_blocking("pending proposal lookup", move || {
6454 run_store.pending_proposal(&run_id).map_err(|error| {
6455 proposal_durability_quarantine(&run_id, "finalization", &error)
6456 })
6457 })
6458 .await?
6459 };
6460 if let Some(pending) = pending {
6461 if pending.client_id != durable_client
6462 || pending.original_submission != original_submission
6463 || pending.requested_policy_session_id.as_deref() != session_id.as_deref()
6464 {
6465 return Err(format!(
6466 "proposal finalization pending for run_id `{run_id}`; only an exact retry of original submission `{}` may reconcile it",
6467 pending.original_proposal_id
6468 ));
6469 }
6470 return finish_pending_proposal(
6471 session,
6472 state,
6473 &pending,
6474 &durable_client,
6475 &mut run_guard,
6476 )
6477 .await;
6478 }
6479 let marker = {
6480 let run_store = state.run_store.clone();
6481 let run_id = run_id.to_string();
6482 run_store_blocking("proposal execution marker lookup", move || {
6483 run_store
6484 .execution_marker(&run_id)
6485 .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))
6486 })
6487 .await?
6488 };
6489 if let Some(marker) = marker {
6490 return Err(format!(
6491 "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not redispatch automatically",
6492 marker.original_proposal_id
6493 ));
6494 }
6495 if durable_client != session.client_id {
6496 return Err(format!(
6497 "resumed run `{run_id}` accepts only an exact recovery submission; CAR will not dispatch new actions"
6498 ));
6499 }
6500 }
6501
6502 let authenticated_policy_session = match session_id.as_deref() {
6506 Some(policy_session_id) if session.runtime.session_exists(policy_session_id).await => {
6507 Some(policy_session_id.to_string())
6508 }
6509 _ => None,
6510 };
6511 let execution_marker =
6512 current_run
6513 .as_deref()
6514 .map(|run_id| crate::run_store::ProposalExecutionMarker {
6515 run_id: run_id.to_string(),
6516 client_id: session.client_id.clone(),
6517 requested_policy_session_id: session_id.clone(),
6518 policy_session_id: authenticated_policy_session.clone(),
6519 original_proposal_id: submit.proposal.id.clone(),
6520 original_submission: original_submission.clone(),
6521 original_proposal: submitted_proposal_value.clone(),
6522 proposal_digest: submitted_proposal_digest.clone(),
6523 });
6524 let retry_rollback =
6525 execution_marker
6526 .as_ref()
6527 .map(|marker| crate::run_store::ProposalRetryRollback {
6528 run_id: marker.run_id.clone(),
6529 client_id: marker.client_id.clone(),
6530 requested_policy_session_id: marker.requested_policy_session_id.clone(),
6531 original_submission: original_submission.clone(),
6532 });
6533 if let (Some(marker), Some(rollback)) = (execution_marker.as_ref(), retry_rollback.as_ref()) {
6534 let run_store = state.run_store.clone();
6535 let marker = marker.clone();
6536 let rollback = rollback.clone();
6537 let expected_run_id = marker.run_id.clone();
6538 let reservation = run_lifecycle_durability_blocking(
6539 &mut run_guard,
6540 "proposal pre-execution reservation durability",
6541 move || {
6542 run_store
6543 .claim_proposal_id(
6544 &marker.run_id,
6545 &marker.client_id,
6546 &marker.original_proposal_id,
6547 &marker.original_submission,
6548 )
6549 .map_err(|error| {
6550 format!("proposal id could not be durably claimed: {error}")
6551 })?;
6552 run_store
6553 .write_proposal_retry_rollback(&rollback)
6554 .map_err(|error| {
6555 format!("proposal retry rollback could not be durably prepared: {error}")
6556 })?;
6557 run_store
6558 .reserve_proposal_retry_owner(
6559 &marker.run_id,
6560 &marker.client_id,
6561 marker.requested_policy_session_id.as_deref(),
6562 &marker.original_submission,
6563 )
6564 .map_err(|error| {
6565 format!("proposal retry reservation could not be durably prepared: {error}")
6566 })
6567 },
6568 )
6569 .await?;
6570 if let crate::run_store::ProposalRetryReservation::Existing {
6571 run_id: reserved_run,
6572 client_id: reserved_client,
6573 } = reservation
6574 {
6575 if reserved_run != expected_run_id || reserved_client != session.client_id {
6576 let run_store = state.run_store.clone();
6577 let rollback = retry_rollback
6578 .as_ref()
6579 .expect("active reservation has rollback authority")
6580 .clone();
6581 run_lifecycle_durability_blocking(
6582 &mut run_guard,
6583 "losing proposal retry rollback cleanup",
6584 move || {
6585 run_store
6586 .clear_proposal_retry_rollback(&rollback)
6587 .map_err(|error| {
6588 format!("losing retry rollback cleanup failed: {error}")
6589 })
6590 },
6591 )
6592 .await?;
6593 return Err(format!(
6594 "{} proposal retry tuple belongs to run `{reserved_run}` / client `{reserved_client}`; concurrent submission cannot dispatch it",
6595 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6596 ));
6597 }
6598 }
6599 }
6600 if let Some(marker) = execution_marker.as_ref() {
6601 let run_store = state.run_store.clone();
6602 let durable_marker = marker.clone();
6603 let write_result = run_lifecycle_durability_blocking(
6604 &mut run_guard,
6605 "proposal execution marker durability",
6606 move || {
6607 run_store
6608 .write_execution_marker(&durable_marker)
6609 .map_err(|error| {
6610 format!("proposal execution could not be durably prepared: {error}")
6611 })
6612 },
6613 )
6614 .await;
6615 if let Err(error) = write_result {
6616 rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6617 .await
6618 .map_err(|rollback| format!("{error}; exact guard rollback failed: {rollback}"))?;
6619 return Err(error);
6620 }
6621 }
6622 if current_run.is_some() {
6623 if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6624 if let Err(error) = session
6625 .runtime
6626 .event_log_handle()
6627 .lock()
6628 .await
6629 .bind_policy_session(policy_session_id)
6630 {
6631 if let Some(marker) = execution_marker.as_ref() {
6632 rollback_pre_execution_guards(
6633 state,
6634 &mut run_guard,
6635 marker,
6636 &original_submission,
6637 )
6638 .await
6639 .map_err(|rollback| {
6640 format!("{error}; exact guard rollback failed: {rollback}")
6641 })?;
6642 }
6643 return Err(error);
6644 }
6645 }
6646 }
6647
6648 if let Some(rollback) = retry_rollback.as_ref() {
6649 let run_store = state.run_store.clone();
6650 let rollback = rollback.clone();
6651 let clear_result = run_lifecycle_durability_blocking(
6652 &mut run_guard,
6653 "proposal retry rollback intent cleanup",
6654 move || {
6655 run_store
6656 .clear_proposal_retry_rollback(&rollback)
6657 .map_err(|error| format!("proposal retry rollback cleanup failed: {error}"))
6658 },
6659 )
6660 .await;
6661 if let Err(error) = clear_result {
6662 if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6663 session
6664 .runtime
6665 .event_log_handle()
6666 .lock()
6667 .await
6668 .clear_policy_session(policy_session_id)
6669 .map_err(|unbind| {
6670 format!("{error}; policy rollback failed before dispatch: {unbind}")
6671 })?;
6672 }
6673 if let Some(marker) = execution_marker.as_ref() {
6674 rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6675 .await
6676 .map_err(|rollback| {
6677 format!("{error}; exact guard rollback failed: {rollback}")
6678 })?;
6679 }
6680 return Err(error);
6681 }
6682 }
6683
6684 let proposal_event_start = if current_run.is_some() {
6685 Some(session.runtime.event_log_handle().lock().await.len())
6686 } else {
6687 None
6688 };
6689
6690 let result = match (session_id.as_deref(), scope) {
6691 (Some(sid), None) => {
6692 if current_run.is_some() {
6693 session
6694 .runtime
6695 .execute_with_session_and_stable_replan_id(&submit.proposal, sid)
6696 .await
6697 } else {
6698 session
6699 .runtime
6700 .execute_with_session(&submit.proposal, sid)
6701 .await
6702 }
6703 }
6704 (None, Some(s)) => {
6705 if current_run.is_some() {
6706 session
6707 .runtime
6708 .execute_scoped_with_stable_replan_id(&submit.proposal, &s)
6709 .await
6710 } else {
6711 session.runtime.execute_scoped(&submit.proposal, &s).await
6712 }
6713 }
6714 (None, None) => {
6715 if current_run.is_some() {
6716 session
6717 .runtime
6718 .execute_with_stable_replan_id(&submit.proposal)
6719 .await
6720 } else {
6721 session.runtime.execute(&submit.proposal).await
6722 }
6723 }
6724 (Some(_), Some(_)) => unreachable!("combined execution context rejected before binding"),
6725 };
6726
6727 if result.results.iter().any(|action| action.terminal) {
6733 session.halted.store(true, Ordering::Release);
6734 }
6735
6736 if current_run.is_some() {
6737 let proposal_result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
6738 let result_digest = canonical_sha256(&proposal_result_value)?;
6739 let final_proposal = result
6740 .final_proposal
6741 .clone()
6742 .ok_or_else(|| "active v3 runtime result is missing final_proposal".to_string())?;
6743 let event_start = proposal_event_start
6744 .expect("active run captured its proposal event boundary before dispatch");
6745 let accepted_proposal_preimages = {
6746 let log = session.runtime.event_log_handle();
6747 let log = log.lock().await;
6748 collect_accepted_proposal_preimages(
6749 &submit.proposal,
6750 &result,
6751 &log.events()[event_start..],
6752 )?
6753 };
6754 let run_id = current_run
6755 .as_deref()
6756 .expect("current run checked before proposal finalization");
6757 let pending = crate::run_store::PendingProposalFinalization {
6758 run_id: run_id.to_string(),
6759 client_id: session.client_id.clone(),
6760 requested_policy_session_id: session_id,
6761 policy_session_id: authenticated_policy_session.clone(),
6762 original_proposal_id: result.original_proposal_id.clone(),
6763 final_proposal_id: result.proposal_id.clone(),
6764 original_submission,
6765 original_proposal: submit.proposal.clone(),
6766 final_proposal,
6767 accepted_proposal_preimages,
6768 proposal_result: result.clone(),
6769 result_digest,
6770 };
6771 let run_store = state.run_store.clone();
6772 let durable_pending = pending.clone();
6773 run_lifecycle_durability_blocking(
6774 &mut run_guard,
6775 "proposal finalization outbox durability",
6776 move || {
6777 run_store
6778 .write_pending_proposal(&durable_pending)
6779 .map_err(|error| {
6780 format!("proposal finalization could not be durably prepared: {error}")
6781 })
6782 },
6783 )
6784 .await?;
6785 return finish_pending_proposal(
6786 session,
6787 state,
6788 &pending,
6789 &session.client_id,
6790 &mut run_guard,
6791 )
6792 .await;
6793 }
6794
6795 serde_json::to_value(result).map_err(|e| e.to_string())
6796}
6797
6798async fn rollback_pre_execution_guards(
6799 state: &Arc<ServerState>,
6800 run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6801 marker: &crate::run_store::ProposalExecutionMarker,
6802 original_submission: &Value,
6803) -> Result<(), String> {
6804 let run_store = state.run_store.clone();
6805 let marker = marker.clone();
6806 let submission = original_submission.clone();
6807 let rollback = crate::run_store::ProposalRetryRollback {
6808 run_id: marker.run_id.clone(),
6809 client_id: marker.client_id.clone(),
6810 requested_policy_session_id: marker.requested_policy_session_id.clone(),
6811 original_submission: submission.clone(),
6812 };
6813 run_lifecycle_durability_blocking(
6814 run_guard,
6815 "proposal pre-execution guard rollback",
6816 move || {
6817 run_store
6818 .clear_execution_marker(&marker)
6819 .map_err(|error| format!("execution marker rollback failed: {error}"))?;
6820 run_store
6821 .release_proposal_retry_owner(
6822 &marker.run_id,
6823 &marker.client_id,
6824 marker.requested_policy_session_id.as_deref(),
6825 &submission,
6826 )
6827 .map_err(|error| format!("retry owner rollback failed: {error}"))?;
6828 run_store
6829 .clear_proposal_retry_rollback(&rollback)
6830 .map_err(|error| format!("retry rollback intent cleanup failed: {error}"))
6831 },
6832 )
6833 .await
6834}
6835
6836async fn finish_pending_proposal(
6837 session: &crate::session::ClientSession,
6838 state: &Arc<ServerState>,
6839 pending: &crate::run_store::PendingProposalFinalization,
6840 durable_client_id: &str,
6841 run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6842) -> Result<Value, String> {
6843 let (started, _marker) = {
6844 let run_store = state.run_store.clone();
6845 let pending = pending.clone();
6846 run_store_blocking("proposal finalization provenance lookup", move || {
6847 run_store.pending_provenance(&pending).map_err(|error| {
6848 format!("proposal finalization provenance validation failed: {error}")
6849 })
6850 })
6851 .await?
6852 };
6853 let durable_client = started
6854 .client_id
6855 .as_deref()
6856 .ok_or_else(|| "durable RunStarted is missing its client identity".to_string())?;
6857 let active_owner = state
6858 .run_owner_binding(&pending.run_id)
6859 .await
6860 .ok_or_else(|| {
6861 format!(
6862 "active run `{}` is absent from CAR's run registry",
6863 pending.run_id
6864 )
6865 })?;
6866 if durable_client != durable_client_id
6867 || active_owner.0 != durable_client_id
6868 || active_owner.1 != session.client_id
6869 {
6870 return Err(
6871 "proposal finalization owner does not match the authenticated run binding".into(),
6872 );
6873 }
6874 session.require_run_journal_binding(&pending.run_id).await?;
6875 let event_log = session.runtime.event_log_handle();
6876 let mut log = event_log.lock().await;
6877 let active_policy = log
6878 .active_run_binding()
6879 .and_then(|(_, _, policy_session_id)| policy_session_id.map(str::to_string));
6880 match (
6881 active_policy.as_deref(),
6882 pending.policy_session_id.as_deref(),
6883 ) {
6884 (Some(active), Some(expected)) if active == expected => {}
6885 (None, Some(expected)) => log.bind_policy_session(expected)?,
6886 (None, None) => {}
6887 _ => {
6888 return Err(
6889 "proposal finalization policy identity does not match the active journal binding"
6890 .into(),
6891 );
6892 }
6893 }
6894 state.ensure_proposal_run_turns(pending).await?;
6895 match log
6896 .append_critical_async(
6897 car_eventlog::EventKind::ProposalCompleted,
6898 None,
6899 Some(&pending.final_proposal_id),
6900 pending.event_data(),
6901 PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT,
6902 )
6903 .await
6904 {
6905 Ok(_) => {}
6906 Err(car_eventlog::CriticalAppendError::DurabilityUnknown { reason }) => {
6907 return Err(format!(
6908 "proposal finalization durability is unknown; retry the exact proposal.submit safely: {reason}"
6909 ));
6910 }
6911 Err(car_eventlog::CriticalAppendError::Rejected { reason }) => {
6912 return Err(format!(
6913 "proposal finalization critical journal append rejected: {reason}"
6914 ));
6915 }
6916 }
6917 if let Some(policy_session_id) = pending.policy_session_id.as_deref() {
6918 log.clear_policy_session(policy_session_id)?;
6919 }
6920 drop(log);
6921 let run_store = state.run_store.clone();
6922 let pending = pending.clone();
6923 let pending_run_id = pending.run_id.clone();
6924 let (value, cleanup_error) = run_lifecycle_durability_blocking(
6925 run_guard,
6926 "completed proposal response durability",
6927 move || {
6928 let receipt = run_store
6929 .write_completed_proposal(&pending)
6930 .map_err(|error| {
6931 format!("completed proposal response persistence failed: {error}")
6932 })?;
6933 let cleanup_error = run_store
6934 .cleanup_completed_proposal_guards(&receipt)
6935 .err()
6936 .map(|error| error.to_string());
6937 let value = run_store
6938 .completed_proposal_response_value(&receipt)
6939 .map_err(|error| {
6940 format!("completed proposal response serialization failed: {error}")
6941 })?;
6942 Ok((value, cleanup_error))
6943 },
6944 )
6945 .await?;
6946 if let Some(error) = cleanup_error {
6947 tracing::warn!(run_id = %pending_run_id, %error, "completed proposal response is durable; guard cleanup remains pending");
6948 }
6949 Ok(value)
6950}
6951
6952fn collect_accepted_proposal_preimages(
6953 original: &car_ir::ActionProposal,
6954 result: &car_ir::ProposalResult,
6955 proposal_events: &[car_eventlog::Event],
6956) -> Result<Vec<crate::run_store::AcceptedProposalPreimage>, String> {
6957 let mut accepted = Vec::new();
6958 for lineage in &result.replan_lineage {
6959 if lineage.status != car_ir::ProposalLineageStatus::Accepted {
6960 continue;
6961 }
6962 if lineage.generation == 0 {
6963 accepted.push(crate::run_store::AcceptedProposalPreimage {
6964 generation: 0,
6965 proposal: original.clone(),
6966 });
6967 continue;
6968 }
6969 let matches: Vec<_> = proposal_events
6970 .iter()
6971 .filter(|event| {
6972 event.kind == car_eventlog::EventKind::ReplanProposalReceived
6973 && event.proposal_id.as_deref() == Some(result.original_proposal_id.as_str())
6974 && event.data.get("attempt").and_then(Value::as_u64)
6975 == Some(u64::from(lineage.generation))
6976 && event.data.get("proposal_id").and_then(Value::as_str)
6977 == Some(lineage.proposal_id.as_str())
6978 && event.data.get("proposal_digest").and_then(Value::as_str)
6979 == lineage.proposal_digest.as_deref()
6980 })
6981 .collect();
6982 if matches.len() != 1 {
6983 return Err(format!(
6984 "accepted replan generation {} has {} exact proposal preimage events",
6985 lineage.generation,
6986 matches.len()
6987 ));
6988 }
6989 let proposal = matches[0].data.get("proposal").cloned().ok_or_else(|| {
6990 format!(
6991 "accepted replan generation {} is missing its proposal preimage",
6992 lineage.generation
6993 )
6994 })?;
6995 accepted.push(crate::run_store::AcceptedProposalPreimage {
6996 generation: lineage.generation,
6997 proposal: serde_json::from_value(proposal).map_err(|error| {
6998 format!(
6999 "accepted replan generation {} has invalid proposal preimage: {error}",
7000 lineage.generation
7001 )
7002 })?,
7003 });
7004 }
7005 Ok(accepted)
7006}
7007
7008#[cfg(test)]
7009mod proposal_finalization_tests {
7010 use super::*;
7011
7012 #[test]
7013 fn accepted_replan_uses_exact_authenticated_event_preimage() {
7014 let timestamp = chrono::Utc::now();
7015 let original: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7016 "id": "original",
7017 "source": "caller",
7018 "actions": [],
7019 "timestamp": timestamp,
7020 "context": {}
7021 }))
7022 .unwrap();
7023 let final_proposal: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7024 "id": "accepted-replan",
7025 "source": "replanner",
7026 "actions": [],
7027 "timestamp": timestamp,
7028 "context": {"generation": 1}
7029 }))
7030 .unwrap();
7031 let original_digest = canonical_sha256(&original).unwrap();
7032 let final_digest = canonical_sha256(&final_proposal).unwrap();
7033 let result = car_ir::ProposalResult {
7034 proposal_id: final_proposal.id.clone(),
7035 original_proposal_id: original.id.clone(),
7036 final_proposal: Some(final_proposal.clone()),
7037 accepted_proposal_preimages: vec![],
7038 replan_lineage: vec![
7039 car_ir::ProposalLineageEntry {
7040 generation: 0,
7041 proposal_id: original.id.clone(),
7042 proposal_digest: Some(original_digest),
7043 status: car_ir::ProposalLineageStatus::Accepted,
7044 rejection_reason: None,
7045 },
7046 car_ir::ProposalLineageEntry {
7047 generation: 1,
7048 proposal_id: final_proposal.id.clone(),
7049 proposal_digest: Some(final_digest.clone()),
7050 status: car_ir::ProposalLineageStatus::Accepted,
7051 rejection_reason: None,
7052 },
7053 ],
7054 results: vec![],
7055 cost: car_ir::CostSummary::default(),
7056 };
7057 let event = car_eventlog::Event {
7058 kind: car_eventlog::EventKind::ReplanProposalReceived,
7059 run_id: Some("run".to_string()),
7060 client_id: Some("client".to_string()),
7061 policy_session_id: None,
7062 action_id: None,
7063 proposal_id: Some(original.id.clone()),
7064 data: HashMap::from([
7065 ("attempt".to_string(), Value::from(1)),
7066 (
7067 "proposal_id".to_string(),
7068 Value::from(final_proposal.id.clone()),
7069 ),
7070 ("proposal_digest".to_string(), Value::from(final_digest)),
7071 (
7072 "proposal".to_string(),
7073 serde_json::to_value(&final_proposal).unwrap(),
7074 ),
7075 ]),
7076 timestamp,
7077 prev_hash: None,
7078 hash: None,
7079 };
7080
7081 let accepted = collect_accepted_proposal_preimages(&original, &result, &[event]).unwrap();
7082 assert_eq!(accepted.len(), 2);
7083 assert_eq!(accepted[0].generation, 0);
7084 assert_eq!(accepted[0].proposal, original);
7085 assert_eq!(accepted[1].generation, 1);
7086 assert_eq!(accepted[1].proposal, final_proposal);
7087 }
7088}
7089
7090fn canonical_sha256<T: serde::Serialize + ?Sized>(value: &T) -> Result<String, String> {
7094 let canonical = car_inference::catalog_identity::canonical_json(value)?;
7095 Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
7096}
7097
7098fn proposal_durability_quarantine(
7099 run_id: &str,
7100 state_kind: &str,
7101 error: &std::io::Error,
7102) -> String {
7103 format!(
7104 "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}"
7105 )
7106}
7107
7108const PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT: std::time::Duration =
7111 std::time::Duration::from_secs(5);
7112
7113async fn handle_session_policy_open(
7114 session: &crate::session::ClientSession,
7115) -> Result<Value, String> {
7116 let id = session.runtime.open_session().await;
7117 Ok(serde_json::json!({ "session_id": id }))
7118}
7119
7120async fn handle_session_policy_close(
7121 req: &JsonRpcMessage,
7122 session: &crate::session::ClientSession,
7123) -> Result<Value, String> {
7124 let sid = req
7125 .params
7126 .get("session_id")
7127 .and_then(|v| v.as_str())
7128 .ok_or("missing 'session_id'")?;
7129 let closed = session.runtime.close_session(sid).await;
7130 Ok(serde_json::json!({ "closed": closed }))
7131}
7132
7133async fn handle_policy_register(
7139 req: &JsonRpcMessage,
7140 session: &crate::session::ClientSession,
7141) -> Result<Value, String> {
7142 let def: PolicyDefinition = serde_json::from_value(req.params.clone())
7143 .map_err(|e| format!("invalid policy params: {e}"))?;
7144 let session_id = req
7145 .params
7146 .get("session_id")
7147 .and_then(|v| v.as_str())
7148 .map(str::to_string);
7149 let check = build_policy_check(&def)
7150 .ok_or_else(|| format!("unsupported policy rule '{}'", def.rule))?;
7151 let denies_tool = blanket_denied_tool(&def);
7152 match session_id {
7153 Some(sid) => {
7154 let registered = match &denies_tool {
7155 Some(tool) => {
7156 session
7157 .runtime
7158 .register_tool_deny_in_session(&sid, &def.name, tool, check, "")
7159 .await
7160 }
7161 None => {
7162 session
7163 .runtime
7164 .register_policy_in_session(&sid, &def.name, check, "")
7165 .await
7166 }
7167 };
7168 registered.map(
7169 |_| serde_json::json!({ "registered": def.name, "scope": { "session_id": sid } }),
7170 )
7171 }
7172 None => {
7173 let mut policies = session.runtime.policies.write().await;
7174 match &denies_tool {
7175 Some(tool) => policies.register_tool_deny(&def.name, tool, check, ""),
7176 None => policies.register(&def.name, check, ""),
7177 }
7178 Ok(serde_json::json!({ "registered": def.name, "scope": "global" }))
7179 }
7180 }
7181}
7182
7183async fn handle_policy_unregister(
7196 req: &JsonRpcMessage,
7197 session: &crate::session::ClientSession,
7198) -> Result<Value, String> {
7199 let name = req
7200 .params
7201 .get("name")
7202 .and_then(|v| v.as_str())
7203 .ok_or("missing 'name'")?;
7204 let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7205 let removed = session
7206 .runtime
7207 .unregister_policy(name, session_id)
7208 .await
7209 .map_err(|e| e.to_string())?;
7210 Ok(serde_json::json!({
7211 "unregistered": name,
7212 "removed": removed,
7213 "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7214 .unwrap_or_else(|| Value::String("global".to_string())),
7215 }))
7216}
7217
7218async fn handle_policy_list(
7224 req: &JsonRpcMessage,
7225 session: &crate::session::ClientSession,
7226) -> Result<Value, String> {
7227 let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7228 let policies = session
7229 .runtime
7230 .list_policies(session_id)
7231 .await
7232 .map_err(|e| e.to_string())?;
7233 Ok(serde_json::json!({
7234 "policies": policies
7235 .into_iter()
7236 .map(|(name, description)| serde_json::json!({
7237 "name": name,
7238 "description": description,
7239 }))
7240 .collect::<Vec<_>>(),
7241 "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7242 .unwrap_or_else(|| Value::String("global".to_string())),
7243 }))
7244}
7245
7246async fn handle_verify(
7247 req: &JsonRpcMessage,
7248 session: &crate::session::ClientSession,
7249) -> Result<Value, String> {
7250 let vr: VerifyRequest =
7251 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7252 let tools_guard = session.runtime.tools.read().await;
7258 let result = car_verify::verify_with_schemas(
7259 &vr.proposal,
7260 Some(&vr.initial_state),
7261 Some(&tools_guard),
7262 30,
7263 );
7264 drop(tools_guard);
7265 let evidence = serde_json::to_value(&result.evidence).unwrap_or(Value::Null);
7266 serde_json::to_value(VerifyResponse {
7267 valid: result.valid,
7268 issues: result
7269 .issues
7270 .iter()
7271 .map(|i| VerifyIssueProto {
7272 action_id: i.action_id.clone(),
7273 severity: i.severity.clone(),
7274 message: i.message.clone(),
7275 tier: i.tier.as_str().to_string(),
7276 })
7277 .collect(),
7278 simulated_state: result.simulated_state,
7279 execution_levels: result.execution_levels,
7280 conflicts: result.conflicts,
7281 evidence,
7282 })
7283 .map_err(|e| e.to_string())
7284}
7285
7286const MONTE_CARLO_RATE_WINDOW_DAYS: u32 = 30;
7293
7294#[derive(Debug, Deserialize)]
7303struct MonteCarloParams {
7304 proposal: car_ir::ActionProposal,
7305 #[serde(default)]
7306 initial_state: HashMap<String, Value>,
7307 #[serde(default)]
7308 tool_success_rates: HashMap<String, f64>,
7309 #[serde(default)]
7310 goal: Option<car_verify::GoalCondition>,
7311 #[serde(default)]
7312 config: car_verify::MonteCarloConfig,
7313 #[serde(default)]
7317 rate_window_days: Option<u32>,
7318}
7319
7320async fn handle_verify_monte_carlo(
7321 req: &JsonRpcMessage,
7322 session: &crate::session::ClientSession,
7323) -> Result<Value, String> {
7324 let p: MonteCarloParams =
7325 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7326
7327 let window = p.rate_window_days.unwrap_or(MONTE_CARLO_RATE_WINDOW_DAYS);
7328 let derived = if window > 0 {
7329 session.runtime.tool_feedback(window)
7330 } else {
7331 None
7332 };
7333
7334 let mut rates = derived
7337 .as_ref()
7338 .map(|f| f.tool_success_rates.clone())
7339 .unwrap_or_default();
7340 let overridden: Vec<String> = p
7341 .tool_success_rates
7342 .keys()
7343 .filter(|t| rates.contains_key(*t))
7344 .cloned()
7345 .collect();
7346 rates.extend(p.tool_success_rates.iter().map(|(k, v)| (k.clone(), *v)));
7347
7348 let result = car_verify::simulate_monte_carlo(
7349 &p.proposal,
7350 Some(&p.initial_state),
7351 &rates,
7352 p.goal.as_ref(),
7353 &p.config,
7354 );
7355
7356 let counts = derived
7361 .as_ref()
7362 .map(|f| f.tool_dispatch_counts.clone())
7363 .unwrap_or_default();
7364 let mut tools: Vec<Value> = p
7365 .proposal
7366 .actions
7367 .iter()
7368 .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
7369 .filter_map(|a| a.tool.clone())
7370 .collect::<std::collections::BTreeSet<_>>()
7371 .into_iter()
7372 .map(|tool| {
7373 let caller_set = p.tool_success_rates.contains_key(&tool);
7374 let observed = counts.get(&tool).copied();
7375 let source = if caller_set {
7376 "caller"
7377 } else if observed.is_some() {
7378 "trajectories"
7379 } else {
7380 "default"
7381 };
7382 serde_json::json!({
7383 "tool": tool,
7384 "rate": rates.get(&tool).copied()
7385 .unwrap_or(p.config.default_success_rate),
7386 "source": source,
7387 "succeeded": observed.map(|(s, _)| s),
7388 "dispatched": observed.map(|(_, d)| d),
7389 })
7390 })
7391 .collect();
7392 tools.sort_by(|a, b| a["tool"].as_str().cmp(&b["tool"].as_str()));
7393
7394 let mut out = serde_json::to_value(&result).map_err(|e| e.to_string())?;
7395 if let Some(obj) = out.as_object_mut() {
7396 obj.insert(
7397 "rate_provenance".to_string(),
7398 serde_json::json!({
7399 "window_days": window,
7400 "trajectories_available": derived.is_some(),
7401 "overridden_by_caller": overridden,
7402 "tools": tools,
7403 }),
7404 );
7405 }
7406 Ok(out)
7407}
7408
7409async fn handle_sync_status(state: &ServerState) -> Result<Value, String> {
7417 let sub = state.sync_subsystem()?;
7418 let mut s = sub.lock().await;
7419 s.status()
7420}
7421
7422async fn handle_sync_knowledge(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7428 let pump = req
7429 .params
7430 .get("pump")
7431 .and_then(Value::as_bool)
7432 .unwrap_or(false);
7433 let personal = {
7435 let sub = state.sync_subsystem()?;
7436 let mut s = sub.lock().await;
7437 if pump {
7438 let _ = s.pump();
7439 }
7440 s.knowledge()
7441 };
7442 let org = match state.org_subsystem() {
7445 Some(org_sub) => {
7446 let mut s = org_sub.lock().await;
7447 if pump {
7448 let _ = s.pump();
7449 }
7450 Some(s.knowledge())
7451 }
7452 None => None,
7453 };
7454 Ok(serde_json::json!({ "facts": merge_knowledge(personal, org) }))
7455}
7456
7457fn merge_knowledge(personal: Vec<Value>, org: Option<Vec<Value>>) -> Vec<Value> {
7472 match org {
7473 Some(mut org) => {
7474 org.extend(personal);
7475 org
7476 }
7477 None => personal,
7478 }
7479}
7480
7481async fn handle_sync_append(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7482 let surface_str = req
7483 .params
7484 .get("surface")
7485 .and_then(Value::as_str)
7486 .ok_or("missing 'surface'")?;
7487 let surface = crate::sync::parse_surface(surface_str)?;
7488 let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
7489 let scope = crate::sync::parse_scope(&req.params);
7490 let sub = state.subsystem_for_scope(&scope)?;
7491 let mut s = sub.lock().await;
7492 s.append(scope, surface, payload)
7493}
7494
7495async fn handle_sync_record_turn(
7496 req: &JsonRpcMessage,
7497 state: &ServerState,
7498) -> Result<Value, String> {
7499 let conversation_id = req
7500 .params
7501 .get("conversation_id")
7502 .and_then(Value::as_str)
7503 .unwrap_or("");
7504 let role = req
7505 .params
7506 .get("role")
7507 .and_then(Value::as_str)
7508 .unwrap_or("user");
7509 let content = req
7510 .params
7511 .get("content")
7512 .and_then(Value::as_str)
7513 .unwrap_or("");
7514 let tool_calls = req
7515 .params
7516 .get("tool_calls")
7517 .and_then(Value::as_array)
7518 .cloned()
7519 .unwrap_or_default();
7520 let tool_use_id = req.params.get("tool_use_id").and_then(Value::as_str);
7521 let timestamp = req
7522 .params
7523 .get("timestamp")
7524 .and_then(Value::as_u64)
7525 .unwrap_or(0);
7526 let scope = crate::sync::parse_scope(&req.params);
7527 let sub = state.subsystem_for_scope(&scope)?;
7528 let mut s = sub.lock().await;
7529 s.record_turn(
7530 scope,
7531 conversation_id,
7532 role,
7533 content,
7534 tool_calls,
7535 tool_use_id,
7536 timestamp,
7537 )
7538}
7539
7540async fn handle_sync_record_intent(
7541 req: &JsonRpcMessage,
7542 state: &ServerState,
7543 session: &crate::session::ClientSession,
7544) -> Result<Value, String> {
7545 let agent_id = req
7546 .params
7547 .get("agent_id")
7548 .and_then(Value::as_str)
7549 .ok_or("missing 'agent_id'")?;
7550 require_sync_agent_or_host(session, state, "sync.record_intent", agent_id).await?;
7551 record_sync_intent(req, state, agent_id).await
7552}
7553
7554async fn record_sync_intent(
7555 req: &JsonRpcMessage,
7556 state: &ServerState,
7557 agent_id: &str,
7558) -> Result<Value, String> {
7559 let run_id = req
7560 .params
7561 .get("run_id")
7562 .and_then(Value::as_str)
7563 .ok_or("missing 'run_id'")?;
7564 let epoch = req
7565 .params
7566 .get("epoch")
7567 .and_then(Value::as_u64)
7568 .ok_or("missing 'epoch'")?;
7569 let status_str = req
7570 .params
7571 .get("status")
7572 .and_then(Value::as_str)
7573 .ok_or("missing 'status'")?;
7574 let status = crate::sync::parse_intent_status(status_str)?;
7575 let intent = car_sync::Intent::new(agent_id, run_id, epoch, status);
7576 let scope = crate::sync::parse_scope(&req.params);
7577 let sub = state.subsystem_for_scope(&scope)?;
7578 let mut s = sub.lock().await;
7579 s.record_intent(scope, &intent)
7580}
7581
7582async fn handle_sync_pump(state: &ServerState) -> Result<Value, String> {
7583 let user_result = {
7587 let sub = state.sync_subsystem()?;
7588 let mut s = sub.lock().await;
7589 s.pump()?
7590 };
7591 let Some(org_sub) = state.org_subsystem() else {
7595 return Ok(user_result);
7596 };
7597 let org_result = {
7598 let mut s = org_sub.lock().await;
7599 s.pump()
7600 };
7601 match org_result {
7602 Ok(org) => Ok(serde_json::json!({ "user": user_result, "org": org })),
7603 Err(e) => Ok(serde_json::json!({ "user": user_result, "org_error": e })),
7604 }
7605}
7606
7607async fn handle_sync_checkpoint(state: &ServerState) -> Result<Value, String> {
7608 let sub = state.sync_subsystem()?;
7609 let mut s = sub.lock().await;
7610 s.checkpoint()
7611}
7612
7613async fn handle_sync_rebase(state: &ServerState) -> Result<Value, String> {
7614 let sub = state.sync_subsystem()?;
7615 let mut s = sub.lock().await;
7616 s.rebase()
7617}
7618
7619async fn handle_sync_transcript(
7620 req: &JsonRpcMessage,
7621 state: &ServerState,
7622) -> Result<Value, String> {
7623 let conversation_id = req
7624 .params
7625 .get("conversation_id")
7626 .and_then(Value::as_str)
7627 .unwrap_or("");
7628 let sub = state.sync_subsystem()?;
7629 let s = sub.lock().await;
7630 Ok(s.transcript(conversation_id))
7631}
7632
7633async fn handle_sync_resume(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7634 let conversation_id = req
7635 .params
7636 .get("conversation_id")
7637 .and_then(Value::as_str)
7638 .unwrap_or("");
7639 let sub = state.sync_subsystem()?;
7640 let s = sub.lock().await;
7641 s.resume(conversation_id)
7642}
7643
7644async fn handle_sync_assistant_checkpoint_put(
7645 req: &JsonRpcMessage,
7646 state: &ServerState,
7647) -> Result<Value, String> {
7648 let raw = req
7649 .params
7650 .get("checkpoint")
7651 .cloned()
7652 .ok_or("missing 'checkpoint'")?;
7653 let checkpoint =
7654 serde_json::from_value(raw).map_err(|e| format!("invalid assistant checkpoint: {e}"))?;
7655 let sub = state.sync_subsystem()?;
7656 let mut s = sub.lock().await;
7657 s.assistant_checkpoint_put(checkpoint)
7658}
7659
7660async fn handle_sync_assistant_checkpoint_get(
7661 req: &JsonRpcMessage,
7662 state: &ServerState,
7663) -> Result<Value, String> {
7664 let session_id = req
7665 .params
7666 .get("session_id")
7667 .and_then(Value::as_str)
7668 .ok_or("missing 'session_id'")?;
7669 let sub = state.sync_subsystem()?;
7670 let s = sub.lock().await;
7671 serde_json::to_value(s.assistant_checkpoint_get(session_id)?)
7672 .map_err(|e| format!("serialize assistant checkpoint: {e}"))
7673}
7674
7675async fn handle_sync_assistant_action_put(
7676 req: &JsonRpcMessage,
7677 state: &ServerState,
7678) -> Result<Value, String> {
7679 let raw = req
7680 .params
7681 .get("record")
7682 .cloned()
7683 .ok_or("missing 'record'")?;
7684 let record = serde_json::from_value(raw)
7685 .map_err(|e| format!("invalid supervised action record: {e}"))?;
7686 let sub = state.sync_subsystem()?;
7687 let mut s = sub.lock().await;
7688 s.assistant_action_put(record)
7689}
7690
7691async fn handle_sync_assistant_action_get(
7692 req: &JsonRpcMessage,
7693 state: &ServerState,
7694) -> Result<Value, String> {
7695 let action_id = req
7696 .params
7697 .get("action_id")
7698 .and_then(Value::as_str)
7699 .ok_or("missing 'action_id'")?;
7700 let sub = state.sync_subsystem()?;
7701 let s = sub.lock().await;
7702 serde_json::to_value(s.assistant_action_get(action_id)?)
7703 .map_err(|e| format!("serialize supervised action record: {e}"))
7704}
7705
7706async fn handle_sync_fence_check(
7707 req: &JsonRpcMessage,
7708 state: &ServerState,
7709 session: &crate::session::ClientSession,
7710) -> Result<Value, String> {
7711 let agent_id = req
7712 .params
7713 .get("agent_id")
7714 .and_then(Value::as_str)
7715 .ok_or("missing 'agent_id'")?;
7716 require_sync_agent_or_host(session, state, "sync.fence_check", agent_id).await?;
7717 check_sync_fence(req, state, agent_id).await
7718}
7719
7720async fn check_sync_fence(
7721 req: &JsonRpcMessage,
7722 state: &ServerState,
7723 agent_id: &str,
7724) -> Result<Value, String> {
7725 let run_id = req
7726 .params
7727 .get("run_id")
7728 .and_then(Value::as_str)
7729 .ok_or("missing 'run_id'")?;
7730 let epoch = req
7731 .params
7732 .get("epoch")
7733 .and_then(Value::as_u64)
7734 .ok_or("missing 'epoch'")?;
7735 let sub = state.sync_subsystem()?;
7736 let mut s = sub.lock().await;
7737 s.fence_check(agent_id, run_id, epoch)
7738}
7739
7740async fn handle_lease_acquire(
7741 req: &JsonRpcMessage,
7742 state: &ServerState,
7743 session: &crate::session::ClientSession,
7744) -> Result<Value, String> {
7745 let agent_id = req
7746 .params
7747 .get("agent_id")
7748 .and_then(Value::as_str)
7749 .ok_or("missing 'agent_id'")?;
7750 require_sync_agent_or_host(session, state, "lease.acquire", agent_id).await?;
7751 acquire_lease(req, state, agent_id).await
7752}
7753
7754async fn acquire_lease(
7755 req: &JsonRpcMessage,
7756 state: &ServerState,
7757 agent_id: &str,
7758) -> Result<Value, String> {
7759 let ttl_ms = req
7760 .params
7761 .get("ttl_ms")
7762 .and_then(Value::as_u64)
7763 .unwrap_or(30_000);
7764 let sub = state.sync_subsystem()?;
7765 let mut s = sub.lock().await;
7766 s.lease_acquire(agent_id, ttl_ms)
7767}
7768
7769async fn handle_lease_renew(
7770 req: &JsonRpcMessage,
7771 state: &ServerState,
7772 session: &crate::session::ClientSession,
7773) -> Result<Value, String> {
7774 let agent_id = req
7775 .params
7776 .get("agent_id")
7777 .and_then(Value::as_str)
7778 .ok_or("missing 'agent_id'")?;
7779 require_sync_agent_or_host(session, state, "lease.renew", agent_id).await?;
7780 renew_lease(req, state, agent_id).await
7781}
7782
7783async fn renew_lease(
7784 req: &JsonRpcMessage,
7785 state: &ServerState,
7786 agent_id: &str,
7787) -> Result<Value, String> {
7788 let epoch = req
7789 .params
7790 .get("epoch")
7791 .and_then(Value::as_u64)
7792 .ok_or("missing 'epoch'")?;
7793 let ttl_ms = req
7794 .params
7795 .get("ttl_ms")
7796 .and_then(Value::as_u64)
7797 .unwrap_or(30_000);
7798 let sub = state.sync_subsystem()?;
7799 let mut s = sub.lock().await;
7800 s.lease_renew(agent_id, epoch, ttl_ms)
7801}
7802
7803async fn handle_lease_release(
7804 req: &JsonRpcMessage,
7805 state: &ServerState,
7806 session: &crate::session::ClientSession,
7807) -> Result<Value, String> {
7808 let agent_id = req
7809 .params
7810 .get("agent_id")
7811 .and_then(Value::as_str)
7812 .ok_or("missing 'agent_id'")?;
7813 require_sync_agent_or_host(session, state, "lease.release", agent_id).await?;
7814 release_lease(req, state, agent_id).await
7815}
7816
7817async fn release_lease(
7818 req: &JsonRpcMessage,
7819 state: &ServerState,
7820 agent_id: &str,
7821) -> Result<Value, String> {
7822 let epoch = req
7823 .params
7824 .get("epoch")
7825 .and_then(Value::as_u64)
7826 .ok_or("missing 'epoch'")?;
7827 let sub = state.sync_subsystem()?;
7828 let mut s = sub.lock().await;
7829 s.lease_release(agent_id, epoch)
7830}
7831
7832async fn handle_lease_status(
7833 req: &JsonRpcMessage,
7834 state: &ServerState,
7835 bound_agent: Option<&str>,
7836) -> Result<Value, String> {
7837 let agent_id = req
7838 .params
7839 .get("agent_id")
7840 .and_then(Value::as_str)
7841 .ok_or("missing 'agent_id'")?;
7842 require_own_agent(bound_agent, "lease.status", agent_id)?;
7846 let sub = state.sync_subsystem()?;
7847 let mut s = sub.lock().await;
7848 s.lease_status(agent_id)
7849}
7850
7851fn handle_secret_store_activity(
7856 session: &crate::session::ClientSession,
7857 state: &ServerState,
7858) -> Result<Value, String> {
7859 require_approval_authority(session, state)?;
7860 serde_json::to_value(car_secrets::secret_store_activity()).map_err(|error| error.to_string())
7861}
7862
7863pub use car_proto::HOST_MANAGEMENT_METHODS;
7870
7871fn require_approval_authority(
7880 session: &crate::session::ClientSession,
7881 state: &ServerState,
7882) -> Result<(), String> {
7883 if state.host_token.get().is_some()
7884 && !session.is_host.load(std::sync::atomic::Ordering::Acquire)
7885 {
7886 return Err("this operation requires the host-management role".into());
7887 }
7888 Ok(())
7889}
7890
7891fn require_own_agent(bound: Option<&str>, method: &str, target: &str) -> Result<(), String> {
7914 match bound {
7915 Some(b) if b != target => Err(format!(
7916 "{method} `{target}` does not match this session's bound agent: a supervised \
7917 agent may only act on itself"
7918 )),
7919 _ => Ok(()),
7920 }
7921}
7922
7923async fn require_own_agent_or_host(
7928 session: &crate::session::ClientSession,
7929 state: &ServerState,
7930 method: &str,
7931 target: &str,
7932) -> Result<(), String> {
7933 if session.is_host.load(Ordering::Acquire) {
7934 return Ok(());
7935 }
7936 let bound = session.agent_id.lock().await.clone();
7937 match bound.as_deref() {
7938 Some(_) => require_own_agent(bound.as_deref(), method, target),
7939 None if state.host_token.get().is_some() => {
7940 Err("this operation requires the host-management role".into())
7941 }
7942 None => Ok(()),
7943 }
7944}
7945
7946async fn require_sync_agent_or_host(
7949 session: &crate::session::ClientSession,
7950 state: &ServerState,
7951 method: &str,
7952 target: &str,
7953) -> Result<(), String> {
7954 require_own_agent_or_host(session, state, method, target).await
7955}
7956
7957pub(crate) async fn require_host_lifecycle_authority(
7961 session: &crate::session::ClientSession,
7962 state: &ServerState,
7963) -> Result<(), String> {
7964 require_approval_authority(session, state)?;
7965 if session.agent_id.lock().await.is_some() && !session.is_host.load(Ordering::Acquire) {
7966 return Err("this operation requires the host-management role".into());
7967 }
7968 Ok(())
7969}
7970
7971async fn require_agent_permissions_authority(
7991 session: &crate::session::ClientSession,
7992 state: &ServerState,
7993) -> Result<(), String> {
7994 require_approval_authority(session, state)?;
7995 if session.agent_id.lock().await.is_some() {
7996 return Err("this operation requires the host-management role".into());
7997 }
7998 Ok(())
7999}
8000
8001async fn live_agent_callback_registration(
8006 state: &ServerState,
8007 agent_id: &str,
8008 tool: &str,
8009) -> Result<
8010 (
8011 Arc<crate::session::ClientSession>,
8012 tokio::sync::OwnedMutexGuard<()>,
8013 String,
8014 ),
8015 String,
8016> {
8017 let client_id = state
8018 .attached_agents
8019 .lock()
8020 .await
8021 .get(agent_id)
8022 .cloned()
8023 .ok_or_else(|| format!("agent '{agent_id}' is not attached"))?;
8024 let target = state
8025 .sessions
8026 .lock()
8027 .await
8028 .get(&client_id)
8029 .cloned()
8030 .ok_or_else(|| format!("attached agent '{agent_id}' has no live session"))?;
8031 let guard = target.run_lifecycle_guard.clone().lock_owned().await;
8032
8033 let still_attached = state
8034 .attached_agents
8035 .lock()
8036 .await
8037 .get(agent_id)
8038 .is_some_and(|current| current == &target.client_id);
8039 let bound_agent = target.agent_id.lock().await.clone();
8040 if !still_attached
8041 || !target
8042 .authenticated
8043 .load(std::sync::atomic::Ordering::Acquire)
8044 || bound_agent.as_deref() != Some(agent_id)
8045 {
8046 return Err(format!(
8047 "agent '{agent_id}' callback session changed while authorizing tool '{tool}'"
8048 ));
8049 }
8050 if target.runtime.registry.get(tool).await.is_some() {
8051 return Err(format!(
8052 "tool '{tool}' is server-owned and is ineligible for agent standing authority"
8053 ));
8054 }
8055 let digest = target
8056 .callback_tool_schema_digests
8057 .read()
8058 .await
8059 .get(tool)
8060 .cloned()
8061 .ok_or_else(|| {
8062 format!(
8063 "tool '{tool}' has no eligible exact reverse-callback registration for agent '{agent_id}'"
8064 )
8065 })?;
8066 Ok((target, guard, digest))
8067}
8068
8069async fn handle_agent_permission_set_tool(
8070 req: &JsonRpcMessage,
8071 state: &ServerState,
8072) -> Result<Value, String> {
8073 let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8074 let mode = crate::agent_permissions::requested_tool_mode(&req.params)?;
8075 if mode == car_policy::ApprovalMode::AlwaysAllow {
8076 let (_target, _guard, digest) =
8077 live_agent_callback_registration(state, agent_id, tool).await?;
8078 crate::agent_permissions::handle_set_tool(req, Some(digest))
8079 } else {
8080 crate::agent_permissions::handle_set_tool(req, None)
8081 }
8082}
8083
8084async fn handle_agent_permission_evaluate_tool(
8085 req: &JsonRpcMessage,
8086 state: &ServerState,
8087) -> Result<Value, String> {
8088 let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8089 let mut value = crate::agent_permissions::handle_evaluate_tool(req)?;
8090 let has_override = value
8091 .get("has_tool_override")
8092 .and_then(Value::as_bool)
8093 .unwrap_or(false);
8094 let mode = value.get("mode").and_then(Value::as_str);
8095 let stored_digest = value.get("schema_digest").and_then(Value::as_str);
8096 let live_digest = if mode == Some("always_allow") && has_override {
8097 live_agent_callback_registration(state, agent_id, tool)
8098 .await
8099 .ok()
8100 .map(|(_target, _guard, digest)| digest)
8101 } else {
8102 None
8103 };
8104 let schema_bound = stored_digest.is_some();
8105 let active = if mode == Some("always_allow") {
8106 stored_digest
8107 .zip(live_digest.as_deref())
8108 .is_some_and(|(stored, live)| stored == live)
8109 } else {
8110 has_override
8111 };
8112 let object = value
8113 .as_object_mut()
8114 .ok_or_else(|| "agent permission evaluation returned a non-object".to_string())?;
8115 object.insert("active".into(), active.into());
8116 object.insert("schema_bound".into(), schema_bound.into());
8117 object.insert("schema_digest_present".into(), schema_bound.into());
8118 Ok(value)
8119}
8120
8121pub async fn session_principal_for_peers(session: &crate::session::ClientSession) -> String {
8130 session_principal(session).await
8131}
8132
8133async fn session_principal(session: &crate::session::ClientSession) -> String {
8134 if let Some(agent) = session.agent_id.lock().await.clone() {
8135 format!("agent:{agent}")
8136 } else {
8137 format!("conn:{}", session.client_id)
8138 }
8139}
8140
8141async fn handle_permission_get_tier(
8143 session: &crate::session::ClientSession,
8144) -> Result<Value, String> {
8145 let tier = session.permission_gate.read().await.granted_tier();
8146 Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8147}
8148
8149async fn handle_permission_set_tier(
8151 req: &JsonRpcMessage,
8152 session: &crate::session::ClientSession,
8153 state: &ServerState,
8154) -> Result<Value, String> {
8155 require_approval_authority(session, state)?;
8156 let tier_str = req
8157 .params
8158 .get("tier")
8159 .and_then(|v| v.as_str())
8160 .ok_or("missing 'tier'")?;
8161 let tier = car_policy::PermissionTier::from_str_opt(tier_str)
8162 .ok_or_else(|| format!("invalid tier '{tier_str}'"))?;
8163 session.permission_gate.write().await.set_granted_tier(tier);
8164 Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8165}
8166
8167fn messaging_store() -> crate::messaging_config::MessagingConfigStore {
8198 crate::messaging_config::MessagingConfigStore::from_home()
8199}
8200
8201fn messaging_channel_from_params(
8208 params: &Value,
8209) -> Result<crate::messaging_config::ChannelId, String> {
8210 match params.get("channel") {
8211 None | Some(Value::Null) => Ok(crate::messaging_config::ChannelId::IMessage),
8212 Some(Value::String(s)) => crate::messaging_config::ChannelId::from_str_opt(s)
8213 .ok_or_else(|| format!("unknown messaging channel '{s}'")),
8214 Some(other) => Err(format!("messaging channel must be a string, got {other}")),
8215 }
8216}
8217
8218fn messaging_config_view(
8224 store: &crate::messaging_config::MessagingConfigStore,
8225 channel: crate::messaging_config::ChannelId,
8226) -> Result<car_proto::MessagingConfigView, String> {
8227 let cfg = store.load()?.channel(channel);
8228 Ok(car_proto::MessagingConfigView {
8229 channel: channel.as_str().to_string(),
8230 enabled: cfg.enabled,
8231 allowlisted_handles: cfg.allowlisted_handles,
8232 pairing_active: cfg.active_pairing_code.is_some(),
8233 })
8234}
8235
8236async fn handle_messaging_config_get(
8240 req: &JsonRpcMessage,
8241 session: &crate::session::ClientSession,
8242 state: &ServerState,
8243) -> Result<Value, String> {
8244 require_approval_authority(session, state)?;
8245 let channel = messaging_channel_from_params(&req.params)?;
8246 let view = messaging_config_view(&messaging_store(), channel)?;
8247 serde_json::to_value(view).map_err(|e| e.to_string())
8248}
8249
8250async fn handle_messaging_config_set(
8255 req: &JsonRpcMessage,
8256 session: &crate::session::ClientSession,
8257 state: &ServerState,
8258) -> Result<Value, String> {
8259 require_approval_authority(session, state)?;
8260 let set: car_proto::MessagingConfigSetRequest = serde_json::from_value(req.params.clone())
8261 .map_err(|e| format!("invalid messaging.config.set params: {e}"))?;
8262 let channel = messaging_channel_from_params(&req.params)?;
8269 let store = messaging_store();
8270 if let (Some(bot), Some(app)) = (set.bot_token.as_deref(), set.app_token.as_deref()) {
8280 if channel == crate::messaging_config::ChannelId::Slack {
8281 let secrets = car_secrets::SecretStore::new();
8282 let refs = crate::slack_adapter::provision_slack_tokens(&secrets, bot, app)
8283 .map_err(|e| format!("provision slack tokens: {e}"))?;
8284 store.set_slack_token_ref_for(
8285 channel,
8286 crate::messaging_config::SlackTokenRef {
8287 bot_token_key: refs.bot_token_key,
8288 app_token_key: refs.app_token_key,
8289 },
8290 )?;
8291 }
8292 }
8293 if let Some(slack_channel) = set.slack_channel.as_deref() {
8299 if channel == crate::messaging_config::ChannelId::Slack && !slack_channel.is_empty() {
8300 store.set_slack_channel_id_for(channel, slack_channel)?;
8301 }
8302 }
8303 if let Some(enabled) = set.enabled {
8304 let was_enabled = store.is_enabled_for(channel).unwrap_or(false);
8309 store.set_enabled_for(channel, enabled)?;
8310 if enabled && !was_enabled {
8311 if let Some(sup) = state.channel_supervisor.get() {
8312 if let Err(e) = sup.ensure_spawned(channel) {
8324 tracing::warn!(
8325 channel = channel.as_str(),
8326 error = %e,
8327 "channel enabled in config but its runtime watcher could not start; \
8328 messaging.status will report watcher_running:false"
8329 );
8330 }
8331 }
8332 }
8333 }
8334 if let Some(handles) = set.allowlisted_handles {
8335 store.set_allowlist_for(channel, handles)?;
8336 }
8337 for handle in &set.add_handles {
8338 store.add_handle_for(channel, handle)?;
8339 }
8340 for handle in &set.remove_handles {
8341 store.remove_handle_for(channel, handle)?;
8342 }
8343 let view = messaging_config_view(&store, channel)?;
8344 serde_json::to_value(view).map_err(|e| e.to_string())
8345}
8346
8347async fn handle_messaging_pairing_start(
8352 req: &JsonRpcMessage,
8353 session: &crate::session::ClientSession,
8354 state: &ServerState,
8355) -> Result<Value, String> {
8356 require_approval_authority(session, state)?;
8357 let channel = messaging_channel_from_params(&req.params)?;
8358 let store = messaging_store();
8359 let code = store.mint_pairing_code_for(channel)?;
8360 let view = messaging_config_view(&store, channel)?;
8361 let resp = car_proto::MessagingPairingStartResponse {
8362 pairing_code: code,
8363 config: view,
8364 };
8365 serde_json::to_value(resp).map_err(|e| e.to_string())
8366}
8367
8368async fn handle_messaging_pairing_status(
8373 req: &JsonRpcMessage,
8374 session: &crate::session::ClientSession,
8375 state: &ServerState,
8376) -> Result<Value, String> {
8377 require_approval_authority(session, state)?;
8378 let channel = messaging_channel_from_params(&req.params)?;
8379 let code = messaging_store().active_pairing_code_for(channel)?;
8380 let resp = car_proto::MessagingPairingStatusResponse {
8381 pairing_active: code.is_some(),
8382 pairing_code: code,
8383 };
8384 serde_json::to_value(resp).map_err(|e| e.to_string())
8385}
8386
8387#[allow(unused_variables)]
8394fn messaging_fda_readable(channel: crate::messaging_config::ChannelId) -> bool {
8395 match channel {
8396 crate::messaging_config::ChannelId::IMessage => {
8397 #[cfg(target_os = "macos")]
8398 {
8399 car_ffi_common::integrations::messages_fda_readable()
8400 }
8401 #[cfg(not(target_os = "macos"))]
8402 {
8403 true
8404 }
8405 }
8406 crate::messaging_config::ChannelId::Slack => true,
8407 }
8408}
8409
8410async fn handle_messaging_status(
8422 req: &JsonRpcMessage,
8423 session: &crate::session::ClientSession,
8424 state: &ServerState,
8425) -> Result<Value, String> {
8426 require_approval_authority(session, state)?;
8427 let channel = messaging_channel_from_params(&req.params)?;
8428 let store = messaging_store();
8429 let enabled = store.is_enabled_for(channel).unwrap_or(false);
8430 let paired = !store.allowlist_for(channel).unwrap_or_default().is_empty();
8431
8432 let (watcher_running, liveness) = match state.channel_supervisor.get() {
8433 Some(sup) => (sup.is_spawned(channel), sup.liveness_snapshot(channel)),
8434 None => (false, crate::channel_supervisor::ChannelLiveness::default()),
8435 };
8436
8437 let view = car_proto::MessagingStatusView {
8438 channel: channel.as_str().to_string(),
8439 enabled,
8440 paired,
8441 watcher_running,
8442 fda_readable: messaging_fda_readable(channel),
8443 last_send_at_ms: liveness.last_send_at_ms,
8444 last_send_ok: liveness.last_send_ok,
8445 last_error: liveness.last_error,
8446 };
8447 serde_json::to_value(view).map_err(|e| e.to_string())
8448}
8449
8450async fn handle_messaging_test_send(
8463 req: &JsonRpcMessage,
8464 session: &crate::session::ClientSession,
8465 state: &ServerState,
8466) -> Result<Value, String> {
8467 require_approval_authority(session, state)?;
8468 let channel = messaging_channel_from_params(&req.params)?;
8469
8470 let result = match channel {
8471 crate::messaging_config::ChannelId::IMessage => {
8472 let liveness = state.channel_supervisor.get().map(|sup| sup.liveness());
8475 let base_dir = car_home::root_or_relative();
8478 let orch = match liveness {
8479 Some(liveness) => {
8480 crate::messaging_orchestrator::MessagingOrchestrator::with_liveness(
8481 state.host.clone(),
8482 messaging_store(),
8483 std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8484 base_dir,
8485 liveness,
8486 )
8487 }
8488 None => crate::messaging_orchestrator::MessagingOrchestrator::new(
8489 state.host.clone(),
8490 messaging_store(),
8491 std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8492 base_dir,
8493 ),
8494 };
8495 orch.send_test().await
8496 }
8497 crate::messaging_config::ChannelId::Slack => {
8498 Err("Test send is not supported on the Slack channel yet.".to_string())
8499 }
8500 };
8501
8502 let resp = match result {
8503 Ok(()) => car_proto::MessagingTestSendResponse {
8504 ok: true,
8505 error: None,
8506 },
8507 Err(e) => car_proto::MessagingTestSendResponse {
8508 ok: false,
8509 error: Some(e),
8510 },
8511 };
8512 serde_json::to_value(resp).map_err(|e| e.to_string())
8513}
8514
8515fn proposal_from_params(req: &JsonRpcMessage) -> Result<car_ir::ActionProposal, String> {
8517 let p = req.params.get("proposal").ok_or("missing 'proposal'")?;
8518 serde_json::from_value(p.clone()).map_err(|e| format!("invalid proposal: {e}"))
8519}
8520
8521async fn handle_permission_classify(
8553 req: &JsonRpcMessage,
8554 session: &crate::session::ClientSession,
8555) -> Result<Value, String> {
8556 let proposal = proposal_from_params(req)?;
8557 let gate = session.permission_gate.read().await;
8558 let rows: Vec<Value> = proposal
8559 .actions
8560 .iter()
8561 .map(|a| car_ffi_common::permgate::classification_row(gate.classifier(), a))
8565 .collect();
8566 Ok(serde_json::json!({
8567 "classifications": rows,
8568 "declared_rollback_contract": proposal.rollback_contract().as_str(),
8569 }))
8570}
8571
8572async fn handle_permission_evaluate(
8582 req: &JsonRpcMessage,
8583 session: &crate::session::ClientSession,
8584 state: &ServerState,
8585) -> Result<Value, String> {
8586 let proposal = proposal_from_params(req)?;
8587 let ceiling = resolve_skill_ceiling(req, session).await;
8588 let agent_id = authenticated_bound_agent_id(session).await;
8589 let policy = crate::agent_permissions::load_policy();
8590 let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8591 &session.callback_tool_schema_digests,
8592 &session.runtime.registry,
8593 )
8594 .await;
8595 let gate = session.permission_gate.read().await;
8596 let ledger = state.approval_ledger.read().await;
8599 let rows = evaluate_actions(
8600 &gate,
8601 &policy,
8602 agent_id.as_deref(),
8603 &callback_tools,
8604 &proposal,
8605 ceiling,
8606 &ledger,
8607 );
8608 let mut resp = serde_json::json!({ "decisions": rows });
8609 if let Some(c) = ceiling {
8610 resp.as_object_mut()
8611 .unwrap()
8612 .insert("skill_ceiling".into(), serde_json::json!(c.as_str()));
8613 }
8614 Ok(resp)
8615}
8616
8617async fn handle_permission_pending(
8623 req: &JsonRpcMessage,
8624 session: &crate::session::ClientSession,
8625 state: &ServerState,
8626) -> Result<Value, String> {
8627 let proposal = proposal_from_params(req)?;
8628 let ceiling = resolve_skill_ceiling(req, session).await;
8629 let agent_id = authenticated_bound_agent_id(session).await;
8630 let policy = crate::agent_permissions::load_policy();
8631 let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8632 &session.callback_tool_schema_digests,
8633 &session.runtime.registry,
8634 )
8635 .await;
8636 let gate = session.permission_gate.read().await;
8637 let ledger = state.approval_ledger.read().await;
8638 let pending: Vec<Value> = evaluate_actions(
8639 &gate,
8640 &policy,
8641 agent_id.as_deref(),
8642 &callback_tools,
8643 &proposal,
8644 ceiling,
8645 &ledger,
8646 )
8647 .into_iter()
8648 .filter(|r| r.get("decision").and_then(|v| v.as_str()) == Some("needs_approval"))
8649 .collect();
8650 Ok(serde_json::json!({ "pending": pending }))
8651}
8652
8653async fn resolve_skill_ceiling(
8659 req: &JsonRpcMessage,
8660 session: &crate::session::ClientSession,
8661) -> Option<car_policy::PermissionTier> {
8662 let skill = req.params.get("skill").and_then(|v| v.as_str())?;
8663 let engine = session.memgine.lock().await;
8664 engine.skill_meta(skill).and_then(|m| m.deployment_tier)
8665}
8666
8667fn evaluate_actions(
8668 gate: &car_policy::PermissionGate,
8669 policy: &car_policy::AgentPermissionPolicy,
8670 agent_id: Option<&str>,
8671 callback_tools: &HashMap<String, String>,
8672 proposal: &car_ir::ActionProposal,
8673 ceiling: Option<car_policy::PermissionTier>,
8674 ledger: &car_policy::ApprovalLedger,
8675) -> Vec<Value> {
8676 proposal
8677 .actions
8678 .iter()
8679 .map(|a| {
8680 let evaluation = crate::permission_gate::evaluate_action(
8683 gate,
8684 policy,
8685 agent_id,
8686 a,
8687 a.tool
8688 .as_deref()
8689 .and_then(|tool| callback_tools.get(tool))
8690 .map(String::as_str),
8691 ceiling,
8692 ledger,
8693 );
8694 let mut obj = serde_json::to_value(&evaluation.axes.decision).unwrap_or(Value::Null);
8695 if let Some(map) = obj.as_object_mut() {
8696 map.insert("action_id".into(), serde_json::json!(a.id));
8697 map.insert(
8698 "fingerprint".into(),
8699 serde_json::json!(car_policy::action_fingerprint(a)),
8700 );
8701 if let Some(source) = evaluation.authorization_source {
8702 map.insert("authorization_source".into(), source.into());
8703 }
8704 if let Some(digest) = evaluation.schema_digest {
8705 map.insert("authorization_schema_digest".into(), digest.into());
8706 }
8707 }
8708 car_ffi_common::permgate::stamp_reversibility(&mut obj, evaluation.axes.reversibility);
8713 obj
8714 })
8715 .collect()
8716}
8717
8718fn stamped_requester(bound: Option<String>, claimed: Option<String>) -> Option<String> {
8728 bound.or(claimed)
8729}
8730
8731async fn authenticated_bound_agent_id(session: &crate::session::ClientSession) -> Option<String> {
8732 if !session
8733 .authenticated
8734 .load(std::sync::atomic::Ordering::Acquire)
8735 {
8736 return None;
8737 }
8738 session.agent_id.lock().await.clone()
8739}
8740
8741async fn handle_permission_decision(
8746 req: &JsonRpcMessage,
8747 session: &crate::session::ClientSession,
8748 state: &ServerState,
8749 approve: bool,
8750) -> Result<Value, String> {
8751 require_approval_authority(session, state)?;
8752 let reviewer = session_principal(session).await;
8757 let reason = req
8758 .params
8759 .get("reason")
8760 .and_then(|v| v.as_str())
8761 .unwrap_or("");
8762 let evidence = req
8763 .params
8764 .get("evidence")
8765 .and_then(|v| v.as_str())
8766 .map(str::to_string);
8767
8768 let decision = if approve {
8774 car_policy::ApprovalDecision::Approved
8775 } else {
8776 car_policy::ApprovalDecision::Rejected
8777 };
8778 let record = if let Some(action_val) = req.params.get("action") {
8779 let action: car_ir::Action = serde_json::from_value(action_val.clone())
8780 .map_err(|e| format!("invalid action: {e}"))?;
8781 let rec = {
8782 let gate = session.permission_gate.read().await;
8783 gate.decision_record(&action, decision, &reviewer, reason, evidence)
8784 };
8785 state
8786 .approval_ledger
8787 .write()
8788 .await
8789 .record(rec.clone())
8790 .map_err(|e| format!("failed to persist approval decision: {e}"))?;
8793 rec
8794 } else {
8795 let fingerprint = req
8796 .params
8797 .get("fingerprint")
8798 .and_then(|v| v.as_str())
8799 .ok_or("missing 'fingerprint' (or 'action')")?;
8800 let required_tier = req
8801 .params
8802 .get("required_tier")
8803 .and_then(|v| v.as_str())
8804 .and_then(car_policy::PermissionTier::from_str_opt)
8805 .unwrap_or(car_policy::PermissionTier::FullAccess);
8806 state
8807 .approval_ledger
8808 .write()
8809 .await
8810 .record_decision(
8811 fingerprint,
8812 required_tier,
8813 decision,
8814 &reviewer,
8815 reason,
8816 evidence,
8817 )
8818 .map_err(|e| format!("failed to persist approval decision: {e}"))?
8819 };
8820
8821 let mut data = std::collections::HashMap::new();
8825 data.insert(
8826 "fingerprint".to_string(),
8827 Value::from(record.fingerprint.clone()),
8828 );
8829 data.insert(
8830 "approval".to_string(),
8831 Value::from(match record.decision {
8832 car_policy::ApprovalDecision::Approved => "approved",
8833 car_policy::ApprovalDecision::Rejected => "rejected",
8834 }),
8835 );
8836 data.insert(
8837 "required_tier".to_string(),
8838 Value::from(record.required_tier.as_str()),
8839 );
8840 data.insert("reviewer".to_string(), Value::from(record.reviewer.clone()));
8841 data.insert("reason".to_string(), Value::from(record.reason.clone()));
8842 if let Some(ev) = &record.evidence {
8843 data.insert("evidence".to_string(), Value::from(ev.clone()));
8844 }
8845 session.runtime.log.lock().await.append(
8846 car_eventlog::EventKind::ApprovalRecorded,
8847 None,
8848 None,
8849 data,
8850 );
8851
8852 serde_json::to_value(&record).map_err(|e| e.to_string())
8853}
8854
8855fn validate_tenant_id(id: &str) -> Result<(), String> {
8861 if id.contains(':') {
8862 return Err(format!(
8863 "invalid tenant_id '{id}': ':' is reserved as the namespace separator"
8864 ));
8865 }
8866 Ok(())
8867}
8868
8869async fn effective_tenant(
8878 req: &JsonRpcMessage,
8879 session: &crate::session::ClientSession,
8880) -> Result<Option<String>, String> {
8881 let param = req
8882 .params
8883 .get("tenant_id")
8884 .and_then(|v| v.as_str())
8885 .filter(|s| !s.is_empty())
8886 .map(str::to_string);
8887 if let Some(p) = ¶m {
8888 validate_tenant_id(p)?;
8889 }
8890 let bound = session.tenant.lock().await.clone();
8891 match (bound, param) {
8892 (Some(b), Some(p)) if b != p => Err(format!(
8893 "tenant_id '{p}' conflicts with this session's bound tenant '{b}'"
8894 )),
8895 (Some(b), _) => Ok(Some(b)),
8896 (None, p) => Ok(p),
8897 }
8898}
8899
8900fn reject_unscoped_tenant_key(tenant: &Option<String>, key: &str) -> Result<(), String> {
8905 if tenant.is_none() && key.starts_with("tenant:") {
8906 return Err(format!(
8907 "unscoped access to tenant-namespaced key '{key}' is not permitted; \
8908 authenticate with the owning tenant_id"
8909 ));
8910 }
8911 Ok(())
8912}
8913
8914fn handle_capabilities_list(bound_agent: Option<&str>, is_host: bool) -> Value {
8915 let caller_role = if is_host {
8916 "host"
8917 } else if bound_agent.is_some() {
8918 "agent"
8919 } else {
8920 "operator"
8921 };
8922 let methods: Vec<Value> = crate::generated_rpc_capabilities::RPC_CAPABILITIES
8923 .iter()
8924 .filter(|(_, role)| match *role {
8925 "operator" => true,
8927 "owner" => is_host || bound_agent.is_some(),
8929 "agent" => bound_agent.is_some(),
8930 "host" => is_host,
8931 _ => false,
8932 })
8933 .map(|(method, role)| serde_json::json!({"method": method, "role": role}))
8934 .collect();
8935 serde_json::json!({
8936 "caller_role": caller_role,
8937 "count": methods.len(),
8938 "methods": methods,
8939 })
8940}
8941
8942#[cfg(test)]
8943mod capability_discovery_tests {
8944 use super::handle_capabilities_list;
8945
8946 #[test]
8947 fn agent_discovery_excludes_host_methods_and_preserves_roles() {
8948 let result = handle_capabilities_list(Some("agent-a"), false);
8949 let methods = result["methods"].as_array().expect("method rows");
8950 assert_eq!(result["caller_role"], "agent");
8951 assert!(methods
8952 .iter()
8953 .any(|row| row["method"] == "capabilities.list"));
8954 assert!(methods.iter().any(|row| row["role"] == "agent"));
8955 assert!(methods.iter().any(|row| row["role"] == "owner"));
8956 assert!(methods.iter().any(|row| row["role"] == "operator"));
8957 assert!(!methods.iter().any(|row| row["role"] == "host"));
8958 }
8959
8960 #[test]
8961 fn unbound_discovery_contains_only_ungated_methods() {
8962 let result = handle_capabilities_list(None, false);
8963 let methods = result["methods"].as_array().expect("method rows");
8964 assert_eq!(result["caller_role"], "operator");
8965 assert!(methods.iter().all(|row| row["role"] == "operator"));
8966 }
8967}
8968
8969async fn handle_state_get(
8970 req: &JsonRpcMessage,
8971 session: &crate::session::ClientSession,
8972) -> Result<Value, String> {
8973 let key = require_str(&req.params, "key")?;
8974 let tenant = effective_tenant(req, session).await?;
8975 reject_unscoped_tenant_key(&tenant, key)?;
8976 Ok(session
8977 .runtime
8978 .state
8979 .scoped(tenant.as_deref())
8980 .get(key)
8981 .unwrap_or(Value::Null))
8982}
8983
8984async fn handle_state_set(
8985 req: &JsonRpcMessage,
8986 session: &crate::session::ClientSession,
8987) -> Result<Value, String> {
8988 let key = require_str(&req.params, "key")?;
8989 let value = req.params.get("value").cloned().unwrap_or(Value::Null);
8990 let tenant = effective_tenant(req, session).await?;
8991 reject_unscoped_tenant_key(&tenant, key)?;
8992 session
8993 .runtime
8994 .state
8995 .scoped(tenant.as_deref())
8996 .set(key, value, "client");
8997 Ok(Value::from("ok"))
8998}
8999
9000async fn handle_state_exists(
9004 req: &JsonRpcMessage,
9005 session: &crate::session::ClientSession,
9006) -> Result<Value, String> {
9007 let key = require_str(&req.params, "key")?;
9008 let tenant = effective_tenant(req, session).await?;
9009 reject_unscoped_tenant_key(&tenant, key)?;
9010 Ok(Value::Bool(
9011 session.runtime.state.scoped(tenant.as_deref()).exists(key),
9012 ))
9013}
9014
9015async fn handle_state_keys(
9018 req: &JsonRpcMessage,
9019 session: &crate::session::ClientSession,
9020) -> Result<Value, String> {
9021 let tenant = effective_tenant(req, session).await?;
9022 Ok(Value::Array(
9023 session
9024 .runtime
9025 .state
9026 .scoped(tenant.as_deref())
9027 .keys()
9028 .into_iter()
9029 .map(Value::String)
9030 .collect(),
9031 ))
9032}
9033
9034async fn handle_state_snapshot(
9045 req: &JsonRpcMessage,
9046 session: &crate::session::ClientSession,
9047) -> Result<Value, String> {
9048 let tenant = effective_tenant(req, session).await?;
9049 let map: serde_json::Map<String, Value> = session
9054 .runtime
9055 .state
9056 .scoped(tenant.as_deref())
9057 .snapshot_stripped()
9058 .into_iter()
9059 .collect();
9060 Ok(Value::Object(map))
9061}
9062
9063fn agent_memgine_snapshot_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
9069 let base = car_ffi_common::memory_path::ensure_base()
9070 .map_err(|e| format!("memory base unavailable: {e}"))?;
9071 let dir = base.join("agents");
9072 std::fs::create_dir_all(&dir).map_err(|e| format!("create agents dir: {e}"))?;
9073 Ok(dir.join(format!("{agent_id}.json")))
9074}
9075
9076pub fn seed_memgine_config() -> car_memgine::MemgineConfig {
9094 let anchor = std::env::var_os("CAR_PROJECT_DIR")
9095 .map(std::path::PathBuf::from)
9096 .or_else(|| std::env::current_dir().ok());
9097 match anchor {
9098 Some(dir) => {
9099 car_memgine::project::resolve_config(&dir, car_memgine::MemgineConfig::default())
9100 }
9101 None => car_memgine::MemgineConfig::default(),
9102 }
9103}
9104
9105pub fn seed_trusted_skill_signers() -> Vec<String> {
9132 let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
9133 .map(std::path::PathBuf::from)
9134 .or_else(|| std::env::current_dir().ok())
9135 else {
9136 return Vec::new();
9137 };
9138 let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
9139 return Vec::new();
9140 };
9141 car_memgine::project::load_config_overrides(&car_dir)
9142 .and_then(|o| o.trusted_skill_signers)
9143 .unwrap_or_default()
9144}
9145
9146fn namespace_memgine_snapshot_path(namespace: &str) -> Result<std::path::PathBuf, String> {
9183 if namespace.is_empty() {
9184 return Err("memory_namespace must contain at least one usable character".into());
9185 }
9186 const HEX: &[u8; 16] = b"0123456789abcdef";
9187 let mut encoded = String::with_capacity(namespace.len());
9188 for &b in namespace.as_bytes() {
9189 match b {
9190 b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => encoded.push(b as char),
9191 _ => {
9192 encoded.push('%');
9193 encoded.push(HEX[(b >> 4) as usize] as char);
9194 encoded.push(HEX[(b & 0x0f) as usize] as char);
9195 }
9196 }
9197 }
9198 let file_name = format!("{encoded}.json");
9199 if file_name.len() > 255 {
9203 return Err(format!(
9204 "memory_namespace is too long: {:?} encodes to a {}-byte filename \
9205 (limit 255)",
9206 namespace,
9207 file_name.len()
9208 ));
9209 }
9210 let base = car_ffi_common::memory_path::ensure_base()
9211 .map_err(|e| format!("memory base unavailable: {e}"))?;
9212 let dir = base.join("memory-namespaces");
9213 std::fs::create_dir_all(&dir).map_err(|e| format!("create namespaces dir: {e}"))?;
9214 Ok(dir.join(file_name))
9215}
9216
9217async fn get_or_load_namespace_memgine(
9222 state: &Arc<ServerState>,
9223 namespace: &str,
9224) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9225 {
9226 let map = state.namespace_memgines.lock().await;
9227 if let Some(eng) = map.get(namespace) {
9228 return Ok(eng.clone());
9229 }
9230 }
9231 let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9232 Some(seed_memgine_config()),
9233 )));
9234 let path = namespace_memgine_snapshot_path(namespace)?;
9235 if path.exists() {
9236 let content = std::fs::read_to_string(&path)
9238 .map_err(|e| format!("read {}: {}", path.display(), e))?;
9239 let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9240 let mut g = engine.lock().await;
9241 for (loaded, fact) in facts.iter().enumerate() {
9242 ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9243 }
9244 }
9245 let mut map = state.namespace_memgines.lock().await;
9246 Ok(map.entry(namespace.to_string()).or_insert(engine).clone())
9247}
9248
9249async fn bind_memory_namespace(
9258 state: &Arc<ServerState>,
9259 session: &crate::session::ClientSession,
9260 namespace: &str,
9261) -> Result<(), String> {
9262 let eng = get_or_load_namespace_memgine(state, namespace).await?;
9263 *session.bound_memgine.lock().await = Some(eng);
9264 *session.memory_namespace.lock().await = Some(namespace.to_string());
9265 Ok(())
9266}
9267
9268async fn persist_namespace_memgine(
9282 namespace: &str,
9283 engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9284) -> Result<(), String> {
9285 let path = namespace_memgine_snapshot_path(namespace)?;
9286 let g = engine.lock().await;
9287 let facts = memory_snapshot_facts(&g);
9288 drop(g);
9289 let path_owned = path.clone();
9290 tokio::task::spawn_blocking(move || -> Result<(), String> {
9291 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9292 atomic_write_sync(&path_owned, json.as_bytes())
9293 .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9294 })
9295 .await
9296 .map_err(|e| format!("persist_namespace_memgine join: {e}"))?
9297}
9298
9299fn memory_snapshot_facts(engine: &car_memgine::MemgineEngine) -> Vec<Value> {
9311 engine
9312 .graph
9313 .inner
9314 .node_indices()
9315 .filter_map(|nix| {
9316 let node = engine.graph.inner.node_weight(nix)?;
9317 if !node.is_valid() {
9318 return None;
9319 }
9320 if node.kind == car_memgine::MemKind::Identity
9321 || node.kind == car_memgine::MemKind::Environment
9322 {
9323 return None;
9324 }
9325 Some(serde_json::json!({
9326 "fact_id": node.fact_id,
9327 "subject": node.key,
9328 "body": node.value,
9329 "kind": match node.kind {
9330 car_memgine::MemKind::Fact if node.is_constraint => "constraint",
9331 car_memgine::MemKind::Fact => "pattern",
9332 car_memgine::MemKind::Conversation => "outcome",
9333 _ => "pattern",
9334 },
9335 "confidence": 0.5,
9336 "content_type": node.content_type.as_label(),
9337 "tags": node.metadata.tags,
9338 "source": node
9339 .metadata
9340 .provenance
9341 .first()
9342 .map(|entry| entry.source.as_str())
9343 .unwrap_or(""),
9344 }))
9345 })
9346 .collect()
9347}
9348
9349fn ingest_snapshot_fact(
9350 engine: &mut car_memgine::MemgineEngine,
9351 fact: &Value,
9352 fallback_id: String,
9353) {
9354 let subject = fact.get("subject").and_then(Value::as_str).unwrap_or("");
9355 let body = fact.get("body").and_then(Value::as_str).unwrap_or("");
9356 let kind = fact
9357 .get("kind")
9358 .and_then(Value::as_str)
9359 .unwrap_or("pattern");
9360 let fact_id = fact
9361 .get("fact_id")
9362 .and_then(Value::as_str)
9363 .unwrap_or(&fallback_id);
9364 let tags = fact
9365 .get("tags")
9366 .cloned()
9367 .and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
9368 .unwrap_or_default();
9369 let source = fact
9370 .get("source")
9371 .and_then(Value::as_str)
9372 .unwrap_or_default();
9373 let nix = engine.ingest_fact(
9374 fact_id,
9375 subject,
9376 body,
9377 "user",
9378 "peer",
9379 chrono::Utc::now(),
9380 "global",
9381 None,
9382 vec![],
9383 kind == "constraint",
9384 );
9385 if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9386 node.metadata.tags = tags;
9387 if !source.is_empty() {
9388 node.metadata.provenance = vec![car_memgine::Provenance {
9389 source: source.to_string(),
9390 ..Default::default()
9391 }];
9392 }
9393 }
9394}
9395
9396async fn get_or_load_agent_memgine(
9397 state: &Arc<ServerState>,
9398 agent_id: &str,
9399) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9400 {
9401 let map = state.agent_memgines.lock().await;
9402 if let Some(eng) = map.get(agent_id) {
9403 return Ok(eng.clone());
9404 }
9405 }
9406 let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9412 Some(seed_memgine_config()),
9413 )));
9414 let path = agent_memgine_snapshot_path(agent_id)?;
9415 if path.exists() {
9416 let content = std::fs::read_to_string(&path)
9417 .map_err(|e| format!("read {}: {}", path.display(), e))?;
9418 let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9419 let mut g = engine.lock().await;
9420 for (loaded, fact) in facts.iter().enumerate() {
9421 ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9422 }
9423 }
9424 let mut map = state.agent_memgines.lock().await;
9425 let stored = map.entry(agent_id.to_string()).or_insert(engine).clone();
9426 Ok(stored)
9427}
9428
9429fn atomic_write_sync(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
9436 use std::sync::atomic::{AtomicU64, Ordering};
9437 static SEQ: AtomicU64 = AtomicU64::new(0);
9438 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
9439 let mut tmp_os = path.as_os_str().to_owned();
9440 tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
9441 let tmp = std::path::PathBuf::from(tmp_os);
9442 std::fs::write(&tmp, bytes)?;
9443 std::fs::rename(&tmp, path)
9444}
9445
9446async fn persist_agent_memgine(
9450 agent_id: &str,
9451 engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9452) -> Result<(), String> {
9453 let path = agent_memgine_snapshot_path(agent_id)?;
9454 let g = engine.lock().await;
9455 let facts = memory_snapshot_facts(&g);
9456 drop(g);
9462 let path_owned = path.clone();
9463 tokio::task::spawn_blocking(move || -> Result<(), String> {
9464 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9465 atomic_write_sync(&path_owned, json.as_bytes())
9466 .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9467 })
9468 .await
9469 .map_err(|e| format!("persist_agent_memgine join: {e}"))?
9470}
9471
9472async fn handle_memory_fact_count(
9479 session: &crate::session::ClientSession,
9480) -> Result<Value, String> {
9481 let engine_arc = session.effective_memgine().await;
9482 let engine = engine_arc.lock().await;
9483 Ok(Value::from(engine.valid_fact_count()))
9484}
9485
9486fn parse_commit_authority(s: &str) -> Result<car_verify::CommitAuthority, String> {
9492 use car_verify::CommitAuthority as A;
9493 for candidate in [
9494 A::Execution,
9495 A::Review,
9496 A::Coordination,
9497 A::Scheduling,
9498 A::SystemConfig,
9499 ] {
9500 if candidate.as_str() == s {
9501 return Ok(candidate);
9502 }
9503 }
9504 Err(format!(
9505 "unknown commit authority `{s}` (expected one of: execution, review, coordination, scheduling, system_config)"
9506 ))
9507}
9508
9509async fn handle_memory_set_admission_table(
9518 req: &JsonRpcMessage,
9519 session: &crate::session::ClientSession,
9520) -> Result<Value, String> {
9521 let table: Option<car_verify::OwnershipTable> = match req.params.get("table") {
9522 None | Some(Value::Null) => None,
9523 Some(v) => {
9524 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid table: {e}"))?)
9525 }
9526 };
9527
9528 let ungated: Vec<&'static str> = table
9532 .as_ref()
9533 .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect())
9534 .unwrap_or_default();
9535 let enabled = table.is_some();
9536
9537 let engine_arc = session.effective_memgine().await;
9538 engine_arc.lock().await.set_admission_table(table);
9539
9540 Ok(serde_json::json!({
9541 "enabled": enabled,
9542 "ungated_surfaces": ungated,
9543 }))
9544}
9545
9546async fn handle_memory_admission_table(
9551 _req: &JsonRpcMessage,
9552 session: &crate::session::ClientSession,
9553) -> Result<Value, String> {
9554 let engine_arc = session.effective_memgine().await;
9555 let engine = engine_arc.lock().await;
9556 let table = engine.admission_table();
9557 Ok(serde_json::json!({
9558 "enabled": table.is_some(),
9559 "table": table,
9560 "ungated_surfaces": table
9561 .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect::<Vec<_>>())
9562 .unwrap_or_default(),
9563 }))
9564}
9565
9566async fn handle_memory_add_fact(
9567 req: &JsonRpcMessage,
9568 session: &crate::session::ClientSession,
9569) -> Result<Value, String> {
9570 let subject = req
9571 .params
9572 .get("subject")
9573 .and_then(|v| v.as_str())
9574 .ok_or("missing subject")?;
9575 let body = req
9576 .params
9577 .get("body")
9578 .and_then(|v| v.as_str())
9579 .ok_or("missing body")?;
9580 let kind = req
9581 .params
9582 .get("kind")
9583 .and_then(|v| v.as_str())
9584 .unwrap_or("pattern");
9585 let tags = match req.params.get("tags") {
9586 Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
9587 .map_err(|_| "tags must be an array of strings".to_string())?,
9588 None => vec![],
9589 };
9590 let source = match req.params.get("source") {
9591 Some(Value::String(value)) => value.clone(),
9592 Some(_) => return Err("source must be a string".to_string()),
9593 None => "user".to_string(),
9594 };
9595 let committed_by = req
9616 .params
9617 .get("committed_by")
9618 .and_then(|v| v.as_str())
9619 .map(parse_commit_authority)
9620 .transpose()?;
9621 let verdicts: Vec<car_verify::VerifierVerdict> = match req.params.get("verdicts") {
9622 Some(v) => {
9623 serde_json::from_value(v.clone()).map_err(|e| format!("invalid verdicts: {e}"))?
9624 }
9625 None => Vec::new(),
9626 };
9627
9628 let engine_arc = session.effective_memgine().await;
9629 let count = {
9630 let mut engine = engine_arc.lock().await;
9631 let fid = match req.params.get("fact_id") {
9638 Some(Value::String(value)) if !value.is_empty() => {
9639 let fid = value.clone();
9640 if let Some((_, existing)) = engine.graph.get_by_fact_id(&fid) {
9641 if existing.key == subject && existing.value == body {
9642 return Ok(Value::from(engine.valid_fact_count()));
9643 }
9644 return Err(format!("fact_id `{fid}` already exists"));
9645 }
9646 fid
9647 }
9648 Some(Value::String(_)) => return Err("fact_id must not be empty".to_string()),
9649 Some(_) => return Err("fact_id must be a string".to_string()),
9650 None => {
9655 let mut n = engine.valid_fact_count();
9656 loop {
9657 let candidate = format!("ws-{n}");
9658 if engine.graph.get_by_fact_id(&candidate).is_none() {
9659 break candidate;
9660 }
9661 n += 1;
9662 }
9663 }
9664 };
9665 let nix = engine
9666 .try_ingest_fact(
9667 &fid,
9668 subject,
9669 body,
9670 "user",
9671 "peer",
9672 chrono::Utc::now(),
9673 "global",
9674 None,
9675 vec![],
9676 kind == "constraint",
9677 car_verify::CommitAuthority::Execution,
9678 committed_by,
9679 verdicts,
9680 )
9681 .map_err(|decision| {
9682 format!(
9685 "memory admission refused for `{}`: {}",
9686 decision.id,
9687 serde_json::to_string(&decision.refusals)
9688 .unwrap_or_else(|_| "<unserialisable>".into())
9689 )
9690 })?;
9691 if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9692 node.metadata.tags = tags;
9693 node.metadata.provenance = vec![car_memgine::Provenance {
9694 source,
9695 ..Default::default()
9696 }];
9697 }
9698 engine.valid_fact_count()
9699 };
9700 persist_bound_agent_memory(session, &engine_arc, "memory.add_fact").await;
9704 Ok(Value::from(count))
9705}
9706
9707async fn persist_bound_agent_memory(
9719 session: &crate::session::ClientSession,
9720 engine_arc: &Arc<Mutex<car_memgine::MemgineEngine>>,
9721 op: &str,
9722) {
9723 if let Some(ns) = session.memory_namespace.lock().await.clone() {
9724 if let Err(e) = persist_namespace_memgine(&ns, engine_arc).await {
9725 tracing::warn!(memory_namespace = %ns, error = %e, op = %op,
9726 "namespace memgine persist failed; in-memory state is canonical");
9727 }
9728 return;
9729 }
9730 if let Some(id) = session.agent_id.lock().await.clone() {
9731 if let Err(e) = persist_agent_memgine(&id, engine_arc).await {
9732 tracing::warn!(agent_id = %id, error = %e, op = %op,
9733 "agent memgine persist failed; in-memory state is canonical");
9734 }
9735 }
9736}
9737
9738#[derive(Debug, Deserialize)]
9739struct MemoryUpdateStatusParams {
9740 body: String,
9741 #[serde(default)]
9742 tenant_id: Option<String>,
9743}
9744
9745async fn handle_memory_update_status(
9746 req: &JsonRpcMessage,
9747 session: &crate::session::ClientSession,
9748) -> Result<Value, String> {
9749 let params: MemoryUpdateStatusParams = typed_params(&req.params)?;
9750 let engine_arc = session.effective_memgine().await;
9751 let status = {
9752 let mut engine = engine_arc.lock().await;
9753 engine.update_proactive_status(params.body, params.tenant_id)
9754 };
9755 serde_json::to_value(status).map_err(|e| e.to_string())
9756}
9757
9758async fn handle_memory_maintain(
9759 req: &JsonRpcMessage,
9760 session: &crate::session::ClientSession,
9761) -> Result<Value, String> {
9762 let params: car_memgine::ProactiveMaintenanceRequest = typed_params(&req.params)?;
9763 let events = {
9764 let log = session.runtime.log.lock().await;
9765 log.events().to_vec()
9766 };
9767 let engine_arc = session.effective_memgine().await;
9768 let report = {
9769 let mut engine = engine_arc.lock().await;
9770 engine.maintain_proactive_memory_from_events(&events, ¶ms)
9771 };
9772 if !report.saved.is_empty() {
9773 persist_bound_agent_memory(session, &engine_arc, "memory.maintain").await;
9774 }
9775 {
9776 let mut log = session.runtime.log.lock().await;
9777 log.append(
9778 car_eventlog::EventKind::ProactiveMemoryMaintained,
9779 None,
9780 None,
9781 proactive_maintenance_event_data(&report),
9782 );
9783 }
9784 serde_json::to_value(report).map_err(|e| e.to_string())
9785}
9786
9787async fn handle_memory_save_knowledge(
9788 req: &JsonRpcMessage,
9789 session: &crate::session::ClientSession,
9790) -> Result<Value, String> {
9791 let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
9792 let engine_arc = session.effective_memgine().await;
9793 let saved = {
9794 let mut engine = engine_arc.lock().await;
9795 engine.save_proactive_knowledge(save)
9796 };
9797 persist_bound_agent_memory(session, &engine_arc, "memory.save_knowledge").await;
9798 serde_json::to_value(saved).map_err(|e| e.to_string())
9799}
9800
9801async fn handle_memory_save_procedural(
9802 req: &JsonRpcMessage,
9803 session: &crate::session::ClientSession,
9804) -> Result<Value, String> {
9805 let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
9806 let engine_arc = session.effective_memgine().await;
9807 let saved = {
9808 let mut engine = engine_arc.lock().await;
9809 engine.save_proactive_procedural(save)
9810 };
9811 persist_bound_agent_memory(session, &engine_arc, "memory.save_procedural").await;
9812 serde_json::to_value(saved).map_err(|e| e.to_string())
9813}
9814
9815#[derive(Debug, Deserialize)]
9816struct MemoryDeleteParams {
9817 id: String,
9818}
9819
9820async fn handle_memory_delete(
9821 req: &JsonRpcMessage,
9822 session: &crate::session::ClientSession,
9823) -> Result<Value, String> {
9824 let params: MemoryDeleteParams = typed_params(&req.params)?;
9825 let engine_arc = session.effective_memgine().await;
9826 let deleted = {
9827 let mut engine = engine_arc.lock().await;
9828 engine.delete_proactive_memory(¶ms.id)
9829 };
9830 if deleted.deleted
9831 && !matches!(
9832 deleted.kind,
9833 Some(car_memgine::ProactiveMemoryEntryKind::Status)
9834 )
9835 {
9836 persist_bound_agent_memory(session, &engine_arc, "memory.delete").await;
9837 }
9838 serde_json::to_value(deleted).map_err(|e| e.to_string())
9839}
9840
9841async fn handle_memory_query(
9842 req: &JsonRpcMessage,
9843 session: &crate::session::ClientSession,
9844) -> Result<Value, String> {
9845 let query = req
9846 .params
9847 .get("query")
9848 .and_then(|v| v.as_str())
9849 .ok_or("missing query")?;
9850 let k = req.params.get("k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
9851 let engine_arc = session.effective_memgine().await;
9852 let engine = engine_arc.lock().await;
9853 let seeds = engine.graph.find_seeds(query, 5);
9854 let hits = if !seeds.is_empty() {
9858 engine.graph.retrieve_ppr(&seeds, None, 0.5, k)
9859 } else {
9860 vec![]
9861 };
9862 let results: Vec<Value> = hits
9863 .iter()
9864 .filter_map(|hit| {
9865 let node = engine.graph.inner.node_weight(hit.node_ix)?;
9866 Some(serde_json::json!({
9867 "fact_id": node.fact_id,
9868 "subject": node.key,
9869 "body": node.value,
9870 "kind": format!("{:?}", node.kind).to_lowercase(),
9871 "confidence": hit.activation,
9872 "tags": node.metadata.tags,
9873 "source": node
9874 .metadata
9875 .provenance
9876 .first()
9877 .map(|entry| entry.source.as_str())
9878 .unwrap_or(""),
9879 }))
9880 })
9881 .collect();
9882 serde_json::to_value(results).map_err(|e| e.to_string())
9883}
9884
9885async fn handle_memory_intervene(
9889 req: &JsonRpcMessage,
9890 session: &crate::session::ClientSession,
9891) -> Result<Value, String> {
9892 let request: car_memgine::ProactiveMemoryRequest = typed_params(&req.params)?;
9893 let engine_arc = session.effective_memgine().await;
9894 let decision = {
9895 let mut engine = engine_arc.lock().await;
9896 engine.proactive_intervention(&request)
9897 };
9898 {
9899 let mut log = session.runtime.log.lock().await;
9900 log.append(
9901 car_eventlog::EventKind::ProactiveMemoryIntervention,
9902 None,
9903 None,
9904 proactive_intervention_event_data(&decision),
9905 );
9906 }
9907 serde_json::to_value(decision).map_err(|e| e.to_string())
9908}
9909
9910async fn handle_memory_evaluate(
9915 req: &JsonRpcMessage,
9916 session: &crate::session::ClientSession,
9917) -> Result<Value, String> {
9918 let request: car_memgine::ProactiveEvaluationRequest = typed_params(&req.params)?;
9919 let engine_arc = session.effective_memgine().await;
9920 let engine = engine_arc.lock().await;
9921 let report = engine.evaluate_proactive_memory(&request);
9922 serde_json::to_value(report).map_err(|e| e.to_string())
9923}
9924
9925async fn maybe_apply_proactive_memory(
9926 msg: &JsonRpcMessage,
9927 session: &crate::session::ClientSession,
9928 req: &mut car_inference::GenerateRequest,
9929) -> Result<(), String> {
9930 let tier = session.permission_gate.read().await.granted_tier();
9931 let mut request = match proactive_memory_activation(&msg.params, req, tier)? {
9932 ProactiveMemoryActivation::Disabled => return Ok(()),
9933 ProactiveMemoryActivation::Default => car_memgine::ProactiveMemoryRequest::default(),
9934 ProactiveMemoryActivation::Request(request) => request,
9935 };
9936
9937 if request.query.trim().is_empty() {
9938 request.query = msg
9939 .params
9940 .get("context_query")
9941 .and_then(|v| v.as_str())
9942 .filter(|s| !s.trim().is_empty())
9943 .unwrap_or(&req.prompt)
9944 .to_string();
9945 }
9946 if request.recent.is_empty() && !req.prompt.trim().is_empty() {
9947 request.recent.push(req.prompt.clone());
9948 }
9949 let events = {
9950 let log = session.runtime.log.lock().await;
9951 log.events().to_vec()
9952 };
9953 let engine_arc = session.effective_memgine().await;
9954 let maintenance = {
9955 let mut engine = engine_arc.lock().await;
9956 engine.maintain_proactive_memory_from_events(
9957 &events,
9958 &car_memgine::ProactiveMaintenanceRequest {
9959 max_recent: 32,
9960 tenant_id: request.tenant_id.clone(),
9961 },
9962 )
9963 };
9964 if !maintenance.saved.is_empty() {
9965 persist_bound_agent_memory(session, &engine_arc, "memory_intervention.maintain").await;
9966 }
9967 {
9968 let mut log = session.runtime.log.lock().await;
9969 log.append(
9970 car_eventlog::EventKind::ProactiveMemoryMaintained,
9971 None,
9972 None,
9973 proactive_maintenance_event_data(&maintenance),
9974 );
9975 }
9976 let mut derived_trigger = maintenance.trigger;
9977 if tier == car_policy::PermissionTier::FullAccess
9978 || req.intent.as_ref().is_some_and(|intent| intent.high_stakes)
9979 {
9980 derived_trigger.high_risk_action = true;
9981 }
9982 request.trigger.merge(derived_trigger);
9983
9984 let decision = {
9985 let mut engine = engine_arc.lock().await;
9986 engine.proactive_intervention(&request)
9987 };
9988 {
9989 let mut log = session.runtime.log.lock().await;
9990 log.append(
9991 car_eventlog::EventKind::ProactiveMemoryIntervention,
9992 None,
9993 None,
9994 proactive_intervention_event_data(&decision),
9995 );
9996 }
9997 if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
9998 append_context_block(req, "Proactive Memory", &reminder);
9999 }
10000 Ok(())
10001}
10002
10003enum ProactiveMemoryActivation {
10004 Disabled,
10005 Default,
10006 Request(car_memgine::ProactiveMemoryRequest),
10007}
10008
10009fn proactive_memory_activation(
10010 params: &Value,
10011 req: &car_inference::GenerateRequest,
10012 tier: car_policy::PermissionTier,
10013) -> Result<ProactiveMemoryActivation, String> {
10014 match params.get("memory_intervention") {
10015 Some(raw) if raw.as_bool() == Some(false) || raw.is_null() => {
10016 Ok(ProactiveMemoryActivation::Disabled)
10017 }
10018 Some(raw) if raw.as_bool() == Some(true) => Ok(ProactiveMemoryActivation::Default),
10019 Some(raw) => serde_json::from_value(raw.clone())
10020 .map(ProactiveMemoryActivation::Request)
10021 .map_err(|e| format!("invalid memory_intervention: {e}")),
10022 None if should_auto_apply_proactive_memory(req, tier) => {
10023 Ok(ProactiveMemoryActivation::Default)
10024 }
10025 None => Ok(ProactiveMemoryActivation::Disabled),
10026 }
10027}
10028
10029fn should_auto_apply_proactive_memory(
10030 req: &car_inference::GenerateRequest,
10031 tier: car_policy::PermissionTier,
10032) -> bool {
10033 if tier == car_policy::PermissionTier::FullAccess {
10034 return true;
10035 }
10036 if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
10037 return true;
10038 }
10039 let Some(intent) = req.intent.as_ref() else {
10040 return false;
10041 };
10042 if intent.high_stakes {
10043 return true;
10044 }
10045 matches!(
10046 intent.task,
10047 Some(car_inference::TaskHint::Code | car_inference::TaskHint::Reasoning)
10048 )
10049}
10050
10051fn proactive_maintenance_event_data(
10052 report: &car_memgine::ProactiveMaintenanceReport,
10053) -> HashMap<String, Value> {
10054 let mut data = proactive_trigger_event_data(&report.trigger);
10055 data.insert(
10056 "saved_count".to_string(),
10057 Value::from(report.saved.len() as u64),
10058 );
10059 data.insert(
10060 "skipped_existing".to_string(),
10061 Value::from(report.skipped_existing as u64),
10062 );
10063 data.insert(
10064 "status_updated".to_string(),
10065 Value::from(report.status.is_some()),
10066 );
10067 data
10068}
10069
10070fn proactive_intervention_event_data(
10071 decision: &car_memgine::ProactiveMemoryDecision,
10072) -> HashMap<String, Value> {
10073 let mut data = HashMap::new();
10074 match decision {
10075 car_memgine::ProactiveMemoryDecision::Inject {
10076 selected,
10077 candidates,
10078 bank,
10079 ..
10080 } => {
10081 data.insert("decision".to_string(), Value::from("inject"));
10082 data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
10083 data.insert(
10084 "selected_kind".to_string(),
10085 Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
10086 );
10087 data.insert(
10088 "candidate_count".to_string(),
10089 Value::from(candidates.len() as u64),
10090 );
10091 data.insert(
10092 "bank_knowledge".to_string(),
10093 Value::from(bank.knowledge as u64),
10094 );
10095 data.insert(
10096 "bank_procedural".to_string(),
10097 Value::from(bank.procedural as u64),
10098 );
10099 data.insert(
10100 "bank_open_subgoals".to_string(),
10101 Value::from(bank.open_subgoals as u64),
10102 );
10103 }
10104 car_memgine::ProactiveMemoryDecision::Silent {
10105 reason,
10106 candidates,
10107 bank,
10108 } => {
10109 data.insert("decision".to_string(), Value::from("silent"));
10110 data.insert("reason".to_string(), Value::from(reason.clone()));
10111 data.insert(
10112 "candidate_count".to_string(),
10113 Value::from(candidates.len() as u64),
10114 );
10115 data.insert(
10116 "bank_knowledge".to_string(),
10117 Value::from(bank.knowledge as u64),
10118 );
10119 data.insert(
10120 "bank_procedural".to_string(),
10121 Value::from(bank.procedural as u64),
10122 );
10123 data.insert(
10124 "bank_open_subgoals".to_string(),
10125 Value::from(bank.open_subgoals as u64),
10126 );
10127 }
10128 }
10129 data
10130}
10131
10132fn proactive_trigger_event_data(
10133 trigger: &car_memgine::ProactiveMemoryTrigger,
10134) -> HashMap<String, Value> {
10135 HashMap::from([
10136 (
10137 "repeated_failures".to_string(),
10138 Value::from(trigger.repeated_failures as u64),
10139 ),
10140 ("tool_error".to_string(), Value::from(trigger.tool_error)),
10141 (
10142 "explicit_uncertainty".to_string(),
10143 Value::from(trigger.explicit_uncertainty),
10144 ),
10145 (
10146 "high_risk_action".to_string(),
10147 Value::from(trigger.high_risk_action),
10148 ),
10149 (
10150 "context_shift".to_string(),
10151 Value::from(trigger.context_shift),
10152 ),
10153 ])
10154}
10155
10156fn append_context_block(req: &mut car_inference::GenerateRequest, title: &str, body: &str) {
10157 let block = format!("## {title}\n{body}");
10158 req.context = Some(match req.context.take() {
10159 Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
10160 _ => block,
10161 });
10162}
10163
10164#[cfg(test)]
10165mod proactive_memory_activation_tests {
10166 use super::{proactive_memory_activation, ProactiveMemoryActivation};
10167 use car_inference::{GenerateRequest, IntentHint, TaskHint};
10168 use car_policy::PermissionTier;
10169 use serde_json::json;
10170
10171 fn req() -> GenerateRequest {
10172 GenerateRequest {
10173 prompt: "finish the task".to_string(),
10174 ..Default::default()
10175 }
10176 }
10177
10178 fn is_disabled(activation: ProactiveMemoryActivation) -> bool {
10179 matches!(activation, ProactiveMemoryActivation::Disabled)
10180 }
10181
10182 fn is_default(activation: ProactiveMemoryActivation) -> bool {
10183 matches!(activation, ProactiveMemoryActivation::Default)
10184 }
10185
10186 #[test]
10187 fn explicit_false_disables_even_when_auto_eligible() {
10188 let mut req = req();
10189 req.tools = Some(vec![json!({"name": "edit_file"})]);
10190 let activation = proactive_memory_activation(
10191 &json!({"memory_intervention": false}),
10192 &req,
10193 PermissionTier::FullAccess,
10194 )
10195 .unwrap();
10196 assert!(is_disabled(activation));
10197 }
10198
10199 #[test]
10200 fn explicit_true_uses_default_request() {
10201 let activation = proactive_memory_activation(
10202 &json!({"memory_intervention": true}),
10203 &req(),
10204 PermissionTier::ReadOnly,
10205 )
10206 .unwrap();
10207 assert!(is_default(activation));
10208 }
10209
10210 #[test]
10211 fn explicit_object_is_preserved() {
10212 let activation = proactive_memory_activation(
10213 &json!({
10214 "memory_intervention": {
10215 "query": "pricing rollout",
10216 "tenant_id": "tenant-a"
10217 }
10218 }),
10219 &req(),
10220 PermissionTier::ReadOnly,
10221 )
10222 .unwrap();
10223 let ProactiveMemoryActivation::Request(request) = activation else {
10224 panic!("expected explicit request");
10225 };
10226 assert_eq!(request.query, "pricing rollout");
10227 assert_eq!(request.tenant_id.as_deref(), Some("tenant-a"));
10228 }
10229
10230 #[test]
10231 fn ordinary_chat_is_not_auto_enabled() {
10232 let mut req = req();
10233 req.intent = Some(IntentHint {
10234 task: Some(TaskHint::Chat),
10235 ..Default::default()
10236 });
10237 let activation =
10238 proactive_memory_activation(&json!({}), &req, PermissionTier::ReadOnly).unwrap();
10239 assert!(is_disabled(activation));
10240 }
10241
10242 #[test]
10243 fn tool_code_reasoning_and_high_stakes_requests_auto_enable() {
10244 let mut tool_req = req();
10245 tool_req.tools = Some(vec![json!({"name": "shell"})]);
10246 assert!(is_default(
10247 proactive_memory_activation(&json!({}), &tool_req, PermissionTier::ReadOnly).unwrap()
10248 ));
10249
10250 let mut code_req = req();
10251 code_req.intent = Some(IntentHint {
10252 task: Some(TaskHint::Code),
10253 ..Default::default()
10254 });
10255 assert!(is_default(
10256 proactive_memory_activation(&json!({}), &code_req, PermissionTier::ReadOnly).unwrap()
10257 ));
10258
10259 let mut reasoning_req = req();
10260 reasoning_req.intent = Some(IntentHint {
10261 task: Some(TaskHint::Reasoning),
10262 ..Default::default()
10263 });
10264 assert!(is_default(
10265 proactive_memory_activation(&json!({}), &reasoning_req, PermissionTier::ReadOnly)
10266 .unwrap()
10267 ));
10268
10269 assert!(is_default(
10270 proactive_memory_activation(&json!({}), &req(), PermissionTier::FullAccess).unwrap()
10271 ));
10272 }
10273}
10274
10275async fn handle_memory_build_context(
10276 req: &JsonRpcMessage,
10277 session: &crate::session::ClientSession,
10278) -> Result<Value, String> {
10279 let query = req
10280 .params
10281 .get("query")
10282 .and_then(|v| v.as_str())
10283 .unwrap_or("");
10284 let model_context_window = req
10288 .params
10289 .get("model_context_window")
10290 .and_then(|v| v.as_u64())
10291 .map(|w| w as usize);
10292 let engine_arc = session.effective_memgine().await;
10297 let mut engine = engine_arc.lock().await;
10298 Ok(Value::from(
10299 engine.build_context_for_model(query, model_context_window),
10300 ))
10301}
10302
10303async fn handle_memory_build_context_fast(
10309 req: &JsonRpcMessage,
10310 session: &crate::session::ClientSession,
10311) -> Result<Value, String> {
10312 let query = req
10313 .params
10314 .get("query")
10315 .and_then(|v| v.as_str())
10316 .unwrap_or("");
10317 let model_context_window = req
10318 .params
10319 .get("model_context_window")
10320 .and_then(|v| v.as_u64())
10321 .map(|w| w as usize);
10322 let engine_arc = session.effective_memgine().await;
10324 let mut engine = engine_arc.lock().await;
10325 Ok(Value::from(engine.build_context_with_options(
10326 query,
10327 model_context_window,
10328 car_memgine::ContextMode::Fast,
10329 None,
10330 )))
10331}
10332
10333struct ProtocolHandshakeError {
10349 code: i32,
10350 message: String,
10351}
10352
10353fn handle_server_handshake(
10362 req: &JsonRpcMessage,
10363 session: &crate::session::ClientSession,
10364) -> Result<Value, ProtocolHandshakeError> {
10365 let client_protocol = req
10366 .params
10367 .get("protocol_version")
10368 .and_then(Value::as_u64)
10369 .ok_or_else(|| ProtocolHandshakeError {
10370 code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10371 message: format!(
10372 "{} `server.handshake` requires an unsigned numeric \
10373 `protocol_version`; this daemon requires v{}",
10374 car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10375 car_proto::PROTOCOL_VERSION,
10376 ),
10377 })?;
10378
10379 if client_protocol != u64::from(car_proto::PROTOCOL_VERSION) {
10380 return Err(ProtocolHandshakeError {
10381 code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10382 message: format!(
10383 "{} client requested v{}, but this daemon requires v{}; \
10384 update/restart CarHost and car-server so their wire protocols match",
10385 car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10386 client_protocol,
10387 car_proto::PROTOCOL_VERSION,
10388 ),
10389 });
10390 }
10391
10392 fn string_array(params: &Value, field: &str) -> Result<Vec<String>, ProtocolHandshakeError> {
10393 let Some(value) = params.get(field) else {
10394 return Ok(Vec::new());
10395 };
10396 let values = value.as_array().ok_or_else(|| ProtocolHandshakeError {
10397 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10398 message: format!(
10399 "{} `{field}` must be an array of capability strings",
10400 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10401 ),
10402 })?;
10403 values
10404 .iter()
10405 .map(|value| {
10406 value
10407 .as_str()
10408 .filter(|capability| !capability.trim().is_empty())
10409 .map(str::to_string)
10410 .ok_or_else(|| ProtocolHandshakeError {
10411 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10412 message: format!(
10413 "{} `{field}` entries must be non-empty strings",
10414 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10415 ),
10416 })
10417 })
10418 .collect()
10419 }
10420
10421 let required_capabilities = string_array(&req.params, "required_capabilities")?;
10422 let optional_capabilities = string_array(&req.params, "optional_capabilities")?;
10423 let negotiated_capabilities =
10424 car_proto::negotiate_capabilities(&required_capabilities, &optional_capabilities).map_err(
10425 |missing| ProtocolHandshakeError {
10426 code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10427 message: format!(
10428 "{} unsupported mandatory capabilities: {}; update/restart CarHost and car-server",
10429 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10430 missing.join(", "),
10431 ),
10432 },
10433 )?;
10434
10435 session.negotiated_protocol_version.store(
10436 car_proto::PROTOCOL_VERSION,
10437 std::sync::atomic::Ordering::Release,
10438 );
10439 *session
10440 .negotiated_capabilities
10441 .write()
10442 .expect("negotiated capability lock poisoned") =
10443 negotiated_capabilities.iter().cloned().collect();
10444
10445 let identity = car_identity::IdentityStore::from_home().load_or_default();
10451
10452 const MAX_CLIENT_VERSION: usize = 64;
10470 let reported = req
10471 .params
10472 .get("client_version")
10473 .and_then(Value::as_str)
10474 .unwrap_or("unknown");
10475 let client_version = match reported.char_indices().nth(MAX_CLIENT_VERSION) {
10476 Some((cut, _)) => &reported[..cut],
10477 None => reported,
10478 };
10479 tracing::debug!(
10480 target: "car_server_core::handler",
10481 client_version,
10482 server_version = env!("CARGO_PKG_VERSION"),
10483 "server.handshake negotiated"
10484 );
10485
10486 Ok(serde_json::json!({
10487 "protocol_version": car_proto::PROTOCOL_VERSION,
10488 "server_version": env!("CARGO_PKG_VERSION"),
10489 "client_protocol_version": client_protocol,
10490 "client_version": client_version,
10491 "negotiated_capabilities": negotiated_capabilities,
10492 "assistant_name": identity.name,
10493 "assistant_aliases": identity.aliases(),
10494 "assistant_brand": car_identity::BRAND_NAME,
10495 }))
10496}
10497
10498async fn handle_memory_persist(
10499 req: &JsonRpcMessage,
10500 session: &crate::session::ClientSession,
10501) -> Result<Value, String> {
10502 let path = req
10503 .params
10504 .get("path")
10505 .and_then(|v| v.as_str())
10506 .ok_or("missing path")?;
10507 let resolved = car_ffi_common::memory_path::resolve(path)
10508 .map_err(|e| format!("memory.persist rejected path {path:?}: {e}"))?;
10509 let engine_arc = session.effective_memgine().await;
10515 let engine = engine_arc.lock().await;
10516 let facts = memory_snapshot_facts(&engine);
10517 let count = facts.len();
10518 drop(engine);
10522 let resolved_owned = resolved.clone();
10523 tokio::task::spawn_blocking(move || -> Result<(), String> {
10524 let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
10525 atomic_write_sync(&resolved_owned, json.as_bytes())
10526 .map_err(|e| format!("failed to write {}: {}", resolved_owned.display(), e))
10527 })
10528 .await
10529 .map_err(|e| format!("memory.persist join: {e}"))??;
10530 Ok(Value::from(count as u64))
10531}
10532
10533async fn handle_memory_load(
10539 req: &JsonRpcMessage,
10540 session: &crate::session::ClientSession,
10541) -> Result<Value, String> {
10542 let path = req
10543 .params
10544 .get("path")
10545 .and_then(|v| v.as_str())
10546 .ok_or("missing path")?;
10547 let resolved = car_ffi_common::memory_path::resolve(path)
10548 .map_err(|e| format!("memory.load rejected path {path:?}: {e}"))?;
10549 let content = std::fs::read_to_string(&resolved)
10550 .map_err(|e| format!("failed to read {}: {}", resolved.display(), e))?;
10551 let facts: Vec<Value> =
10552 serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {}", e))?;
10553 let engine_arc = session.effective_memgine().await;
10558 let mut engine = engine_arc.lock().await;
10559 engine.reset();
10560 let mut count: u32 = 0;
10561 for fact in &facts {
10562 ingest_snapshot_fact(&mut engine, fact, format!("loaded-{count}"));
10563 count += 1;
10564 }
10565 Ok(Value::from(count))
10566}
10567
10568async fn handle_skill_ingest(
10571 req: &JsonRpcMessage,
10572 session: &crate::session::ClientSession,
10573) -> Result<Value, String> {
10574 let name = req
10575 .params
10576 .get("name")
10577 .and_then(|v| v.as_str())
10578 .ok_or("missing name")?;
10579 let code = req
10580 .params
10581 .get("code")
10582 .and_then(|v| v.as_str())
10583 .ok_or("missing code")?;
10584 let platform = req
10585 .params
10586 .get("platform")
10587 .and_then(|v| v.as_str())
10588 .unwrap_or("unknown");
10589 let persona = req
10590 .params
10591 .get("persona")
10592 .and_then(|v| v.as_str())
10593 .unwrap_or("");
10594 let url_pattern = req
10595 .params
10596 .get("url_pattern")
10597 .and_then(|v| v.as_str())
10598 .unwrap_or("");
10599 let description = req
10600 .params
10601 .get("description")
10602 .and_then(|v| v.as_str())
10603 .unwrap_or("");
10604 let supersedes = opt_str(&req.params, "supersedes");
10605 let keywords: Vec<String> = req
10606 .params
10607 .get("task_keywords")
10608 .and_then(|v| v.as_array())
10609 .map(|arr| {
10610 arr.iter()
10611 .filter_map(|v| v.as_str().map(String::from))
10612 .collect()
10613 })
10614 .unwrap_or_default();
10615
10616 let trigger = car_memgine::SkillTrigger {
10617 persona: persona.into(),
10618 url_pattern: url_pattern.into(),
10619 task_keywords: keywords,
10620 structured: None,
10621 };
10622 let mut engine = session.memgine.lock().await;
10623 let node = engine.ingest_skill(
10624 name,
10625 code,
10626 platform,
10627 trigger,
10628 description,
10629 supersedes,
10630 vec![],
10631 vec![],
10632 );
10633 Ok(Value::from(node.index() as u64))
10634}
10635
10636async fn handle_skill_find(
10637 req: &JsonRpcMessage,
10638 session: &crate::session::ClientSession,
10639) -> Result<Value, String> {
10640 let persona = req
10641 .params
10642 .get("persona")
10643 .and_then(|v| v.as_str())
10644 .unwrap_or("");
10645 let url = str_or(&req.params, "url", "");
10646 let task = req
10647 .params
10648 .get("task")
10649 .and_then(|v| v.as_str())
10650 .unwrap_or("");
10651 let max = req
10652 .params
10653 .get("max_results")
10654 .and_then(|v| v.as_u64())
10655 .unwrap_or(1) as usize;
10656 let tenant = effective_tenant(req, session).await?;
10662 let engine = session.memgine.lock().await;
10663 let results = engine.find_skill_scoped(persona, url, task, max, tenant.as_deref());
10664 let json: Vec<Value> = results
10665 .iter()
10666 .map(|(m, s)| {
10667 serde_json::json!({
10668 "name": m.name, "code": m.code, "platform": m.platform,
10669 "description": m.description, "stats": m.stats, "match_score": s,
10670 })
10671 })
10672 .collect();
10673 serde_json::to_value(json).map_err(|e| e.to_string())
10674}
10675
10676async fn handle_skill_report(
10677 req: &JsonRpcMessage,
10678 session: &crate::session::ClientSession,
10679) -> Result<Value, String> {
10680 let name = req
10681 .params
10682 .get("skill_name")
10683 .and_then(|v| v.as_str())
10684 .ok_or("missing skill_name")?;
10685 let outcome_str = req
10686 .params
10687 .get("outcome")
10688 .and_then(|v| v.as_str())
10689 .ok_or("missing outcome")?;
10690 let outcome = match outcome_str {
10691 "success" => car_memgine::SkillOutcome::Success,
10692 _ => car_memgine::SkillOutcome::Fail,
10693 };
10694 let tenant = effective_tenant(req, session).await?;
10697 let mut engine = session.memgine.lock().await;
10698 let stats = engine
10699 .report_outcome_scoped(name, outcome, tenant.as_deref())
10700 .ok_or(format!("skill '{}' not found", name))?;
10701 serde_json::to_value(stats).map_err(|e| e.to_string())
10702}
10703
10704async fn handle_skill_gate_deployment(
10713 req: &JsonRpcMessage,
10714 session: &crate::session::ClientSession,
10715) -> Result<Value, String> {
10716 let name = req
10717 .params
10718 .get("skill_name")
10719 .and_then(|v| v.as_str())
10720 .ok_or("missing skill_name")?;
10721 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10722 req.params
10723 .get("provenance")
10724 .cloned()
10725 .unwrap_or_else(|| serde_json::json!({})),
10726 )
10727 .map_err(|e| format!("invalid provenance: {e}"))?;
10728 let tier_str = req
10729 .params
10730 .get("requested_tier")
10731 .and_then(|v| v.as_str())
10732 .ok_or("missing requested_tier")?;
10733 let requested =
10734 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10735 format!(
10736 "invalid requested_tier '{tier_str}' \
10737 (expected read_only|sandbox_edit|full_access)"
10738 )
10739 })?;
10740 let engine = session.memgine.lock().await;
10741 let decision = engine.gate_skill_deployment(name, provenance, requested);
10742 serde_json::to_value(decision).map_err(|e| e.to_string())
10743}
10744
10745async fn handle_skill_enforce_deployment(
10755 req: &JsonRpcMessage,
10756 session: &crate::session::ClientSession,
10757 state: &ServerState,
10758) -> Result<Value, String> {
10759 let name = req
10760 .params
10761 .get("skill_name")
10762 .and_then(|v| v.as_str())
10763 .ok_or("missing skill_name")?;
10764 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10765 req.params
10766 .get("provenance")
10767 .cloned()
10768 .unwrap_or_else(|| serde_json::json!({})),
10769 )
10770 .map_err(|e| format!("invalid provenance: {e}"))?;
10771 let tier_str = req
10772 .params
10773 .get("requested_tier")
10774 .and_then(|v| v.as_str())
10775 .ok_or("missing requested_tier")?;
10776 let requested =
10777 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10778 format!(
10779 "invalid requested_tier '{tier_str}' \
10780 (expected read_only|sandbox_edit|full_access)"
10781 )
10782 })?;
10783
10784 let decision = {
10786 let engine = session.memgine.lock().await;
10787 engine.gate_skill_deployment(name, provenance, requested)
10788 };
10789 let enforcement = {
10792 let ledger = state.approval_ledger.read().await;
10793 car_policy::skill_trust::enforce_deployment(&decision, name, requested, &ledger)
10794 };
10795
10796 let pending_json = enforcement.pending.as_ref().map(|p| {
10799 serde_json::json!({
10800 "fingerprint": p.fingerprint,
10801 "skill_name": p.skill_name,
10802 "requested_tier": requested.as_str(),
10803 })
10804 });
10805 let mut resp = serde_json::json!({ "decision": decision, "enforcement": enforcement });
10806 if let Some(pj) = pending_json {
10807 resp.as_object_mut()
10808 .unwrap()
10809 .insert("pending_approval".into(), pj);
10810 }
10811 Ok(resp)
10812}
10813
10814async fn handle_skill_ingest_governed(
10825 req: &JsonRpcMessage,
10826 session: &crate::session::ClientSession,
10827 state: &ServerState,
10828) -> Result<Value, String> {
10829 let name = req
10830 .params
10831 .get("name")
10832 .and_then(|v| v.as_str())
10833 .ok_or("missing name")?;
10834 let code = req
10835 .params
10836 .get("code")
10837 .and_then(|v| v.as_str())
10838 .ok_or("missing code")?;
10839 let platform = req
10840 .params
10841 .get("platform")
10842 .and_then(|v| v.as_str())
10843 .unwrap_or("unknown");
10844 let persona = req
10845 .params
10846 .get("persona")
10847 .and_then(|v| v.as_str())
10848 .unwrap_or("");
10849 let url_pattern = req
10850 .params
10851 .get("url_pattern")
10852 .and_then(|v| v.as_str())
10853 .unwrap_or("");
10854 let description = req
10855 .params
10856 .get("description")
10857 .and_then(|v| v.as_str())
10858 .unwrap_or("");
10859 let supersedes = opt_str(&req.params, "supersedes");
10860 let keywords: Vec<String> = req
10861 .params
10862 .get("task_keywords")
10863 .and_then(|v| v.as_array())
10864 .map(|arr| {
10865 arr.iter()
10866 .filter_map(|v| v.as_str().map(String::from))
10867 .collect()
10868 })
10869 .unwrap_or_default();
10870 let trigger = car_memgine::SkillTrigger {
10871 persona: persona.into(),
10872 url_pattern: url_pattern.into(),
10873 task_keywords: keywords,
10874 structured: None,
10875 };
10876
10877 let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10878 req.params
10879 .get("provenance")
10880 .cloned()
10881 .unwrap_or_else(|| serde_json::json!({})),
10882 )
10883 .map_err(|e| format!("invalid provenance: {e}"))?;
10884 let tier_str = req
10885 .params
10886 .get("requested_tier")
10887 .and_then(|v| v.as_str())
10888 .ok_or("missing requested_tier")?;
10889 let requested =
10890 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10891 format!(
10892 "invalid requested_tier '{tier_str}' \
10893 (expected read_only|sandbox_edit|full_access)"
10894 )
10895 })?;
10896
10897 let ledger = state.approval_ledger.read().await;
10903 let out = {
10904 let mut engine = session.memgine.lock().await;
10905 engine.ingest_skill_governed(
10906 name,
10907 code,
10908 platform,
10909 trigger,
10910 description,
10911 supersedes,
10912 vec![],
10913 vec![],
10914 provenance,
10915 requested,
10916 &ledger,
10917 )
10918 };
10919 drop(ledger);
10920
10921 let pending_json = out.enforcement.pending.as_ref().map(|p| {
10922 serde_json::json!({
10923 "fingerprint": p.fingerprint,
10924 "skill_name": p.skill_name,
10925 "requested_tier": requested.as_str(),
10926 })
10927 });
10928 let mut resp = serde_json::json!({
10929 "ingested": out.ingested.is_some(),
10930 "node": out.ingested.map(|n| n.index()),
10931 "decision": out.decision,
10932 "enforcement": out.enforcement,
10933 });
10934 if let Some(pj) = pending_json {
10935 resp.as_object_mut()
10936 .unwrap()
10937 .insert("pending_approval".into(), pj);
10938 }
10939 Ok(resp)
10940}
10941
10942fn resolve_adopt_provenance(
10962 params: &Value,
10963 trusted: &[String],
10964) -> Result<car_policy::skill_trust::SkillProvenance, String> {
10965 let scanned = params
10966 .get("scanned")
10967 .and_then(|v| v.as_bool())
10968 .unwrap_or(false);
10969 let vulnerabilities = params
10970 .get("vulnerabilities")
10971 .and_then(|v| v.as_u64())
10972 .unwrap_or(0);
10973 let source_str = params
10974 .get("source")
10975 .and_then(|v| v.as_str())
10976 .unwrap_or("unknown");
10977 let source: car_policy::skill_trust::SkillSource =
10978 serde_json::from_value(Value::String(source_str.to_string())).map_err(|_| {
10979 format!(
10980 "invalid source '{source_str}' \
10981 (expected official|first_party|community|unknown)"
10982 )
10983 })?;
10984
10985 let manifest_val = params.get("manifest");
10986 let provenance_val = params.get("provenance");
10987 if manifest_val.is_some() && provenance_val.is_some() {
10988 return Err(
10989 "pass either 'manifest' (signature derived from the bundle) or \
10990 'provenance' (caller-assembled), not both"
10991 .into(),
10992 );
10993 }
10994
10995 if let Some(mv) = manifest_val {
10996 let manifest: car_registry::manifest::AgentManifest =
10997 serde_json::from_value(mv.clone()).map_err(|e| format!("invalid manifest: {e}"))?;
10998 return Ok(car_memgine::MemgineEngine::skill_provenance_from_bundle(
10999 &manifest,
11000 trusted,
11001 scanned,
11002 vulnerabilities,
11003 source,
11004 ));
11005 }
11006
11007 if let Some(pv) = provenance_val {
11008 return serde_json::from_value(pv.clone()).map_err(|e| format!("invalid provenance: {e}"));
11009 }
11010
11011 Ok(car_policy::skill_trust::SkillProvenance {
11012 scanned,
11013 vulnerabilities,
11014 source,
11015 ..Default::default()
11016 })
11017}
11018
11019async fn handle_skill_adopt_pack(
11054 req: &JsonRpcMessage,
11055 session: &crate::session::ClientSession,
11056 state: &ServerState,
11057) -> Result<Value, String> {
11058 let pack_val = req.params.get("pack").ok_or("missing 'pack' parameter")?;
11059 let pack: car_memgine::ApprovedSkillPack =
11060 serde_json::from_value(pack_val.clone()).map_err(|e| format!("invalid pack: {e}"))?;
11061 let tier_str = req
11062 .params
11063 .get("requested_tier")
11064 .and_then(|v| v.as_str())
11065 .unwrap_or("read_only");
11066 let requested =
11067 car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11068 format!(
11069 "invalid requested_tier '{tier_str}' \
11070 (expected read_only|sandbox_edit|full_access)"
11071 )
11072 })?;
11073
11074 let trusted = seed_trusted_skill_signers();
11077 let provenance = resolve_adopt_provenance(&req.params, &trusted)?;
11078
11079 let ledger = state.approval_ledger.read().await;
11084 let out = {
11085 let mut engine = session.memgine.lock().await;
11086 pack.materialize_into_memgine_governed(&mut engine, &provenance, requested, &ledger)
11087 };
11088 drop(ledger);
11089
11090 let pending: Vec<Value> = out
11091 .pending
11092 .iter()
11093 .map(|(skill_id, fingerprint)| {
11094 serde_json::json!({ "skill_id": skill_id, "fingerprint": fingerprint })
11095 })
11096 .collect();
11097 let refused: Vec<Value> = out
11098 .refused
11099 .iter()
11100 .map(|(skill_id, reason)| serde_json::json!({ "skill_id": skill_id, "reason": reason }))
11101 .collect();
11102
11103 Ok(serde_json::json!({
11104 "loaded": out.loaded,
11105 "pending": pending,
11106 "refused": refused,
11107 "requested_tier": requested.as_str(),
11108 "provenance": provenance,
11109 "trusted_signers": trusted.len(),
11114 }))
11115}
11116
11117struct WsAgentRunner {
11126 channel: Arc<WsChannel>,
11127 host: Arc<crate::host::HostState>,
11128 client_id: String,
11129}
11130
11131#[async_trait::async_trait]
11132impl car_multi::AgentRunner for WsAgentRunner {
11133 async fn run(
11134 &self,
11135 spec: &car_multi::AgentSpec,
11136 task: &str,
11137 _runtime: &car_engine::Runtime,
11138 _mailbox: &car_multi::Mailbox,
11139 ) -> std::result::Result<car_multi::AgentOutput, car_multi::MultiError> {
11140 use futures::SinkExt;
11141
11142 let request_id = self.channel.next_request_id();
11143 let agent_id = agent_id_for_run(&self.client_id, &spec.name, &request_id);
11144 let agent = self
11145 .host
11146 .register_agent(
11147 &self.client_id,
11148 RegisterHostAgentRequest {
11149 id: Some(agent_id.clone()),
11150 name: spec.name.clone(),
11151 kind: "callback".to_string(),
11152 capabilities: spec.tools.clone(),
11153 project: spec
11154 .metadata
11155 .get("project")
11156 .and_then(|v| v.as_str())
11157 .map(str::to_string),
11158 pid: None,
11159 display: serde_json::from_value(
11160 spec.metadata
11161 .get("display")
11162 .cloned()
11163 .unwrap_or(serde_json::Value::Null),
11164 )
11165 .unwrap_or_default(),
11166 metadata: serde_json::to_value(&spec.metadata).unwrap_or(Value::Null),
11167 },
11168 )
11169 .await
11170 .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e))?;
11171 let _ = self
11172 .host
11173 .set_status(
11174 &self.client_id,
11175 SetHostAgentStatusRequest {
11176 agent_id: agent.id.clone(),
11177 status: HostAgentStatus::Running,
11178 current_task: Some(task.to_string()),
11179 message: Some(format!("{} started", spec.name)),
11180 payload: serde_json::json!({ "task": task }),
11181 },
11182 )
11183 .await;
11184
11185 let rpc_request = serde_json::json!({
11186 "jsonrpc": "2.0",
11187 "method": "multi.run_agent",
11188 "params": {
11189 "spec": spec,
11190 "task": task,
11191 },
11192 "id": request_id,
11193 });
11194
11195 let (tx, rx) = tokio::sync::oneshot::channel();
11197 self.channel
11198 .pending
11199 .lock()
11200 .await
11201 .insert(request_id.clone(), tx);
11202
11203 let msg = Message::Text(
11204 serde_json::to_string(&rpc_request)
11205 .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e.to_string()))?
11206 .into(),
11207 );
11208 if let Err(e) = self.channel.write.lock().await.send(msg).await {
11209 let _ = self
11210 .host
11211 .set_status(
11212 &self.client_id,
11213 SetHostAgentStatusRequest {
11214 agent_id: agent_id.clone(),
11215 status: HostAgentStatus::Errored,
11216 current_task: None,
11217 message: Some(format!("{} failed to start", spec.name)),
11218 payload: serde_json::json!({ "error": e.to_string() }),
11219 },
11220 )
11221 .await;
11222 return Err(car_multi::MultiError::AgentFailed(
11223 spec.name.clone(),
11224 format!("ws send error: {}", e),
11225 ));
11226 }
11227
11228 let response = match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
11230 Ok(Ok(response)) => response,
11231 Ok(Err(_)) => {
11232 let _ = self
11233 .host
11234 .set_status(
11235 &self.client_id,
11236 SetHostAgentStatusRequest {
11237 agent_id: agent_id.clone(),
11238 status: HostAgentStatus::Errored,
11239 current_task: None,
11240 message: Some(format!("{} callback channel closed", spec.name)),
11241 payload: Value::Null,
11242 },
11243 )
11244 .await;
11245 return Err(car_multi::MultiError::AgentFailed(
11246 spec.name.clone(),
11247 "agent callback channel closed".into(),
11248 ));
11249 }
11250 Err(_) => {
11251 let _ = self
11252 .host
11253 .set_status(
11254 &self.client_id,
11255 SetHostAgentStatusRequest {
11256 agent_id: agent_id.clone(),
11257 status: HostAgentStatus::Errored,
11258 current_task: None,
11259 message: Some(format!("{} timed out", spec.name)),
11260 payload: Value::Null,
11261 },
11262 )
11263 .await;
11264 return Err(car_multi::MultiError::AgentFailed(
11265 spec.name.clone(),
11266 "agent callback timed out (300s)".into(),
11267 ));
11268 }
11269 };
11270
11271 if let Some(err) = response.error {
11272 let _ = self
11273 .host
11274 .set_status(
11275 &self.client_id,
11276 SetHostAgentStatusRequest {
11277 agent_id: agent_id.clone(),
11278 status: HostAgentStatus::Errored,
11279 current_task: None,
11280 message: Some(format!("{} errored", spec.name)),
11281 payload: serde_json::json!({ "error": err }),
11282 },
11283 )
11284 .await;
11285 return Err(car_multi::MultiError::AgentFailed(spec.name.clone(), err));
11286 }
11287
11288 let output_value = response.output.unwrap_or(Value::Null);
11289 let output: car_multi::AgentOutput = serde_json::from_value(output_value).map_err(|e| {
11290 car_multi::MultiError::AgentFailed(
11291 spec.name.clone(),
11292 format!("invalid AgentOutput: {}", e),
11293 )
11294 })?;
11295 let status = if output.error.is_some() {
11296 HostAgentStatus::Errored
11297 } else {
11298 HostAgentStatus::Completed
11299 };
11300 let message = if output.error.is_some() {
11301 format!("{} errored", spec.name)
11302 } else {
11303 format!("{} completed", spec.name)
11304 };
11305 let _ = self
11306 .host
11307 .set_status(
11308 &self.client_id,
11309 SetHostAgentStatusRequest {
11310 agent_id,
11311 status,
11312 current_task: None,
11313 message: Some(message),
11314 payload: serde_json::to_value(&output).unwrap_or(Value::Null),
11315 },
11316 )
11317 .await;
11318
11319 Ok(output)
11320 }
11321}
11322
11323fn agent_id_for_run(client_id: &str, name: &str, request_id: &str) -> String {
11324 let safe_name: String = name
11325 .chars()
11326 .map(|c| {
11327 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
11328 c
11329 } else {
11330 '-'
11331 }
11332 })
11333 .collect();
11334 format!("{}:{}:{}", client_id, safe_name, request_id)
11335}
11336
11337fn multi_infra_with_budget(
11342 req: &JsonRpcMessage,
11343 session: &crate::session::ClientSession,
11344) -> Result<car_multi::SharedInfra, String> {
11345 let infra = car_multi::SharedInfra::with_shared(
11355 std::sync::Arc::clone(&session.runtime.state),
11356 std::sync::Arc::clone(&session.runtime.log),
11357 std::sync::Arc::clone(&session.runtime.policies),
11358 )
11359 .with_concurrency_gating();
11360 match req.params.get("budget") {
11361 None | Some(Value::Null) => Ok(infra),
11362 Some(v) => {
11363 let limits: car_multi::BudgetLimits =
11364 serde_json::from_value(v.clone()).map_err(|e| format!("invalid budget: {}", e))?;
11365 Ok(infra.with_budget(limits))
11366 }
11367 }
11368}
11369
11370async fn handle_multi_swarm(
11371 req: &JsonRpcMessage,
11372 session: &crate::session::ClientSession,
11373) -> Result<Value, String> {
11374 let mode_str = req
11375 .params
11376 .get("mode")
11377 .and_then(|v| v.as_str())
11378 .ok_or("missing 'mode'")?;
11379 let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11380 let task = req
11381 .params
11382 .get("task")
11383 .and_then(|v| v.as_str())
11384 .ok_or("missing 'task'")?;
11385
11386 let swarm_mode: car_multi::SwarmMode = serde_json::from_str(&format!("\"{}\"", mode_str))
11387 .map_err(|e| format!("invalid mode '{}': {}", mode_str, e))?;
11388 let agent_specs: Vec<car_multi::AgentSpec> =
11389 serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11390 let synth: Option<car_multi::AgentSpec> = req
11391 .params
11392 .get("synthesizer")
11393 .map(|v| serde_json::from_value(v.clone()))
11394 .transpose()
11395 .map_err(|e| format!("invalid synthesizer: {}", e))?;
11396
11397 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11398 channel: session.channel.clone(),
11399 host: session.host.clone(),
11400 client_id: session.client_id.clone(),
11401 });
11402 let infra = multi_infra_with_budget(req, session)?;
11403
11404 let mut swarm = car_multi::Swarm::new(agent_specs, swarm_mode);
11405 if let Some(s) = synth {
11406 swarm = swarm.with_synthesizer(s);
11407 }
11408
11409 let result = swarm
11410 .run(task, &runner, &infra)
11411 .await
11412 .map_err(|e| format!("swarm error: {}", e))?;
11413 serde_json::to_value(result).map_err(|e| e.to_string())
11414}
11415
11416async fn handle_multi_pipeline(
11417 req: &JsonRpcMessage,
11418 session: &crate::session::ClientSession,
11419) -> Result<Value, String> {
11420 let stages_val = req.params.get("stages").ok_or("missing 'stages'")?;
11421 let task = req
11422 .params
11423 .get("task")
11424 .and_then(|v| v.as_str())
11425 .ok_or("missing 'task'")?;
11426
11427 let stage_specs: Vec<car_multi::AgentSpec> =
11428 serde_json::from_value(stages_val.clone()).map_err(|e| format!("invalid stages: {}", e))?;
11429
11430 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11431 channel: session.channel.clone(),
11432 host: session.host.clone(),
11433 client_id: session.client_id.clone(),
11434 });
11435 let infra = multi_infra_with_budget(req, session)?;
11436
11437 let result = car_multi::Pipeline::new(stage_specs)
11438 .run(task, &runner, &infra)
11439 .await
11440 .map_err(|e| format!("pipeline error: {}", e))?;
11441 serde_json::to_value(result).map_err(|e| e.to_string())
11442}
11443
11444async fn handle_multi_supervisor(
11445 req: &JsonRpcMessage,
11446 session: &crate::session::ClientSession,
11447) -> Result<Value, String> {
11448 let workers_val = req.params.get("workers").ok_or("missing 'workers'")?;
11449 let supervisor_val = req.params.get("supervisor").ok_or("missing 'supervisor'")?;
11450 let task = req
11451 .params
11452 .get("task")
11453 .and_then(|v| v.as_str())
11454 .ok_or("missing 'task'")?;
11455 let max_rounds = req
11456 .params
11457 .get("max_rounds")
11458 .and_then(|v| v.as_u64())
11459 .unwrap_or(3) as u32;
11460
11461 let worker_specs: Vec<car_multi::AgentSpec> = serde_json::from_value(workers_val.clone())
11462 .map_err(|e| format!("invalid workers: {}", e))?;
11463 let supervisor_spec: car_multi::AgentSpec = serde_json::from_value(supervisor_val.clone())
11464 .map_err(|e| format!("invalid supervisor: {}", e))?;
11465
11466 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11467 channel: session.channel.clone(),
11468 host: session.host.clone(),
11469 client_id: session.client_id.clone(),
11470 });
11471 let infra = multi_infra_with_budget(req, session)?;
11472
11473 let result = car_multi::Supervisor::new(worker_specs, supervisor_spec)
11474 .with_max_rounds(max_rounds)
11475 .run(task, &runner, &infra)
11476 .await
11477 .map_err(|e| format!("supervisor error: {}", e))?;
11478 serde_json::to_value(result).map_err(|e| e.to_string())
11479}
11480
11481async fn handle_multi_map_reduce(
11482 req: &JsonRpcMessage,
11483 session: &crate::session::ClientSession,
11484) -> Result<Value, String> {
11485 let mapper_val = req.params.get("mapper").ok_or("missing 'mapper'")?;
11486 let reducer_val = req.params.get("reducer").ok_or("missing 'reducer'")?;
11487 let task = req
11488 .params
11489 .get("task")
11490 .and_then(|v| v.as_str())
11491 .ok_or("missing 'task'")?;
11492 let items_val = req.params.get("items").ok_or("missing 'items'")?;
11493
11494 let mapper_spec: car_multi::AgentSpec =
11495 serde_json::from_value(mapper_val.clone()).map_err(|e| format!("invalid mapper: {}", e))?;
11496 let reducer_spec: car_multi::AgentSpec = serde_json::from_value(reducer_val.clone())
11497 .map_err(|e| format!("invalid reducer: {}", e))?;
11498 let items: Vec<String> =
11499 serde_json::from_value(items_val.clone()).map_err(|e| format!("invalid items: {}", e))?;
11500
11501 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11502 channel: session.channel.clone(),
11503 host: session.host.clone(),
11504 client_id: session.client_id.clone(),
11505 });
11506 let infra = multi_infra_with_budget(req, session)?;
11507
11508 let result = car_multi::MapReduce::new(mapper_spec, reducer_spec)
11509 .run(task, &items, &runner, &infra)
11510 .await
11511 .map_err(|e| format!("map_reduce error: {}", e))?;
11512 serde_json::to_value(result).map_err(|e| e.to_string())
11513}
11514
11515async fn handle_multi_vote(
11516 req: &JsonRpcMessage,
11517 session: &crate::session::ClientSession,
11518) -> Result<Value, String> {
11519 let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11520 let task = req
11521 .params
11522 .get("task")
11523 .and_then(|v| v.as_str())
11524 .ok_or("missing 'task'")?;
11525
11526 let agent_specs: Vec<car_multi::AgentSpec> =
11527 serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11528 let synth: Option<car_multi::AgentSpec> = req
11529 .params
11530 .get("synthesizer")
11531 .map(|v| serde_json::from_value(v.clone()))
11532 .transpose()
11533 .map_err(|e| format!("invalid synthesizer: {}", e))?;
11534
11535 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11536 channel: session.channel.clone(),
11537 host: session.host.clone(),
11538 client_id: session.client_id.clone(),
11539 });
11540 let infra = multi_infra_with_budget(req, session)?;
11541
11542 let mut vote = car_multi::Vote::new(agent_specs);
11543 if let Some(s) = synth {
11544 vote = vote.with_synthesizer(s);
11545 }
11546
11547 let result = vote
11548 .run(task, &runner, &infra)
11549 .await
11550 .map_err(|e| format!("vote error: {}", e))?;
11551 serde_json::to_value(result).map_err(|e| e.to_string())
11552}
11553
11554async fn handle_multi_tournament(
11555 req: &JsonRpcMessage,
11556 session: &crate::session::ClientSession,
11557) -> Result<Value, String> {
11558 let competitors_val = req
11559 .params
11560 .get("competitors")
11561 .ok_or("missing 'competitors'")?;
11562 let judge_val = req.params.get("judge").ok_or("missing 'judge'")?;
11563 let task = req
11564 .params
11565 .get("task")
11566 .and_then(|v| v.as_str())
11567 .ok_or("missing 'task'")?;
11568
11569 let competitors: Vec<car_multi::AgentSpec> = serde_json::from_value(competitors_val.clone())
11570 .map_err(|e| format!("invalid competitors: {}", e))?;
11571 let judge: car_multi::AgentSpec =
11572 serde_json::from_value(judge_val.clone()).map_err(|e| format!("invalid judge: {}", e))?;
11573
11574 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11575 channel: session.channel.clone(),
11576 host: session.host.clone(),
11577 client_id: session.client_id.clone(),
11578 });
11579 let infra = multi_infra_with_budget(req, session)?;
11580
11581 let result = car_multi::Tournament::new(competitors, judge)
11582 .run(task, &runner, &infra)
11583 .await
11584 .map_err(|e| format!("tournament error: {}", e))?;
11585 serde_json::to_value(result).map_err(|e| e.to_string())
11586}
11587
11588async fn handle_multi_subtask(
11589 req: &JsonRpcMessage,
11590 session: &crate::session::ClientSession,
11591) -> Result<Value, String> {
11592 let main_val = req.params.get("main").ok_or("missing 'main'")?;
11593 let task = req
11594 .params
11595 .get("task")
11596 .and_then(|v| v.as_str())
11597 .ok_or("missing 'task'")?;
11598
11599 let main_spec: car_multi::AgentSpec =
11600 serde_json::from_value(main_val.clone()).map_err(|e| format!("invalid main: {}", e))?;
11601
11602 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11603 channel: session.channel.clone(),
11604 host: session.host.clone(),
11605 client_id: session.client_id.clone(),
11606 });
11607 let infra = multi_infra_with_budget(req, session)?;
11608
11609 let result = car_multi::SpawnSubtask::new(main_spec)
11610 .run(task, &runner, &infra)
11611 .await
11612 .map_err(|e| format!("spawn_subtask error: {}", e))?;
11613 serde_json::to_value(result).map_err(|e| e.to_string())
11614}
11615
11616fn handle_scheduler_create(req: &JsonRpcMessage) -> Result<Value, String> {
11621 let name = req
11622 .params
11623 .get("name")
11624 .and_then(|v| v.as_str())
11625 .ok_or("scheduler.create requires 'name'")?;
11626 let prompt = req
11627 .params
11628 .get("prompt")
11629 .and_then(|v| v.as_str())
11630 .ok_or("scheduler.create requires 'prompt'")?;
11631
11632 let mut task = car_scheduler::Task::new(name, prompt);
11633
11634 if let Some(t) = opt_str(&req.params, "trigger") {
11635 let trigger = match t {
11636 "once" => car_scheduler::TaskTrigger::Once,
11637 "cron" => car_scheduler::TaskTrigger::Cron,
11638 "interval" => car_scheduler::TaskTrigger::Interval,
11639 "file_watch" => car_scheduler::TaskTrigger::FileWatch,
11640 _ => car_scheduler::TaskTrigger::Manual,
11641 };
11642 let schedule = req
11643 .params
11644 .get("schedule")
11645 .and_then(|v| v.as_str())
11646 .unwrap_or("");
11647 task = task.with_trigger(trigger, schedule);
11648 }
11649
11650 if let Some(sp) = opt_str(&req.params, "system_prompt") {
11651 task = task.with_system_prompt(sp);
11652 }
11653
11654 serde_json::to_value(&task).map_err(|e| e.to_string())
11655}
11656
11657fn os_schedule_params(req: &JsonRpcMessage) -> Result<(String, String, String), String> {
11659 let task = req
11660 .params
11661 .get("task")
11662 .ok_or("requires 'task'")
11663 .and_then(|v| serde_json::to_string(v).map_err(|_| "invalid 'task'"))?;
11664 let program = req
11665 .params
11666 .get("program")
11667 .and_then(|v| v.as_str())
11668 .ok_or("requires 'program' (the binary the OS runs to execute the task)")?
11669 .to_string();
11670 let args = req
11671 .params
11672 .get("args")
11673 .cloned()
11674 .unwrap_or_else(|| Value::Array(vec![]));
11675 let args_json = serde_json::to_string(&args).map_err(|e| e.to_string())?;
11676 Ok((task, program, args_json))
11677}
11678
11679fn handle_scheduler_os_render(req: &JsonRpcMessage) -> Result<Value, String> {
11681 let (task, program, args) = os_schedule_params(req)?;
11682 let json = car_ffi_common::scheduler::render_os_schedule(&task, &program, &args)?;
11683 serde_json::from_str(&json).map_err(|e| e.to_string())
11684}
11685
11686fn handle_scheduler_os_install(req: &JsonRpcMessage) -> Result<Value, String> {
11689 let (task, program, args) = os_schedule_params(req)?;
11690 let json = car_ffi_common::scheduler::install_os_schedule(&task, &program, &args)?;
11691 serde_json::from_str(&json).map_err(|e| e.to_string())
11692}
11693
11694fn handle_tasks_schedule(
11703 req: &JsonRpcMessage,
11704 session: &crate::session::ClientSession,
11705 state: &ServerState,
11706) -> Result<Value, String> {
11707 require_approval_authority(session, state)?;
11708 let spec = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
11709 let json = car_ffi_common::scheduler::schedule_task(&spec)?;
11710 serde_json::from_str(&json).map_err(|e| e.to_string())
11711}
11712
11713fn handle_tasks_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11715 let json = car_ffi_common::scheduler::list_scheduled_tasks()?;
11716 serde_json::from_str(&json).map_err(|e| e.to_string())
11717}
11718
11719fn handle_tasks_unschedule(
11723 req: &JsonRpcMessage,
11724 session: &crate::session::ClientSession,
11725 state: &ServerState,
11726) -> Result<Value, String> {
11727 require_approval_authority(session, state)?;
11728 let id = req
11729 .params
11730 .get("id")
11731 .and_then(|v| v.as_str())
11732 .ok_or("tasks.unschedule requires 'id'")?;
11733 let json = car_ffi_common::scheduler::unschedule_task(id)?;
11734 serde_json::from_str(&json).map_err(|e| e.to_string())
11735}
11736
11737fn handle_scheduler_os_uninstall(req: &JsonRpcMessage) -> Result<Value, String> {
11739 let label = req
11740 .params
11741 .get("label")
11742 .and_then(|v| v.as_str())
11743 .ok_or("scheduler.os_uninstall requires 'label' (full label or task id)")?;
11744 let json = car_ffi_common::scheduler::uninstall_os_schedule(label)?;
11745 serde_json::from_str(&json).map_err(|e| e.to_string())
11746}
11747
11748fn handle_scheduler_os_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11750 let json = car_ffi_common::scheduler::list_os_schedules()?;
11751 serde_json::from_str(&json).map_err(|e| e.to_string())
11752}
11753
11754fn handle_scheduler_os_reconcile(_req: &JsonRpcMessage) -> Result<Value, String> {
11757 let json = car_ffi_common::scheduler::reconcile_os_schedules()?;
11758 serde_json::from_str(&json).map_err(|e| e.to_string())
11759}
11760
11761pub fn reconcile_os_schedules_at_boot() {
11765 match car_ffi_common::scheduler::reconcile_os_schedules() {
11766 Ok(json) => match serde_json::from_str::<Value>(&json) {
11767 Ok(report) => {
11768 let removed = report
11769 .get("removed")
11770 .and_then(Value::as_array)
11771 .map_or(0, Vec::len);
11772 let errors = report
11773 .get("errors")
11774 .and_then(Value::as_array)
11775 .map_or(0, Vec::len);
11776 if removed > 0 || errors > 0 {
11777 tracing::info!(removed, errors, "reaped orphaned OS schedules at boot");
11778 }
11779 }
11780 Err(e) => {
11781 tracing::warn!(error = %e, "OS-schedule reconcile returned unparseable report")
11782 }
11783 },
11784 Err(e) => tracing::warn!(error = %e, "OS-schedule reconcile failed at boot"),
11785 }
11786}
11787
11788pub async fn reap_orphaned_sandboxes_at_boot() {
11795 let reaped = car_sandbox::reap_orphaned_sandboxes().await;
11796 if reaped > 0 {
11797 tracing::info!(reaped, "reaped orphaned CAR sandbox containers at boot");
11798 }
11799}
11800
11801fn seed_task_from_store(task: &mut car_scheduler::Task, store: &car_scheduler::TaskStore) {
11812 if let Some(prior) = store.load(&task.id) {
11813 task.created_at = prior.created_at;
11816 task.executions = prior.executions;
11817 task.run_count = prior.run_count;
11818 task.last_run_at = prior.last_run_at;
11819 }
11820}
11821
11822async fn run_scheduler_task_once(
11826 task: &mut car_scheduler::Task,
11827 runner: Arc<dyn car_multi::AgentRunner>,
11828 store: &car_scheduler::TaskStore,
11829) -> car_scheduler::TaskExecution {
11830 seed_task_from_store(task, store);
11831 let executor = car_scheduler::Executor::new(runner);
11832 let execution = executor.run_once(task).await;
11833 let _ = store.save(task);
11834 execution
11835}
11836
11837async fn run_scheduler_task_loop(
11840 task: &mut car_scheduler::Task,
11841 max_iterations: Option<u32>,
11842 runner: Arc<dyn car_multi::AgentRunner>,
11843 store: &car_scheduler::TaskStore,
11844) -> Vec<car_scheduler::TaskExecution> {
11845 seed_task_from_store(task, store);
11846 let executor = car_scheduler::Executor::new(runner);
11847 let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
11848 let executions = executor.run_loop(task, max_iterations, cancel_rx).await;
11849 let _ = store.save(task);
11850 executions
11851}
11852
11853async fn handle_scheduler_run(
11854 req: &JsonRpcMessage,
11855 session: &crate::session::ClientSession,
11856) -> Result<Value, String> {
11857 let task_val = req
11858 .params
11859 .get("task")
11860 .ok_or("scheduler.run requires 'task'")?;
11861 let mut task: car_scheduler::Task =
11862 serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
11863
11864 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11865 channel: session.channel.clone(),
11866 host: session.host.clone(),
11867 client_id: session.client_id.clone(),
11868 });
11869 let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
11870 let execution = run_scheduler_task_once(&mut task, runner, &store).await;
11871
11872 serde_json::to_value(&execution).map_err(|e| e.to_string())
11873}
11874
11875async fn handle_scheduler_run_loop(
11876 req: &JsonRpcMessage,
11877 session: &crate::session::ClientSession,
11878) -> Result<Value, String> {
11879 let task_val = req
11880 .params
11881 .get("task")
11882 .ok_or("scheduler.run_loop requires 'task'")?;
11883 let mut task: car_scheduler::Task =
11884 serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
11885 let max_iterations = req
11886 .params
11887 .get("max_iterations")
11888 .and_then(|v| v.as_u64())
11889 .map(|v| v as u32);
11890
11891 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11892 channel: session.channel.clone(),
11893 host: session.host.clone(),
11894 client_id: session.client_id.clone(),
11895 });
11896 let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
11897 let executions = run_scheduler_task_loop(&mut task, max_iterations, runner, &store).await;
11898
11899 serde_json::to_value(&executions).map_err(|e| e.to_string())
11900}
11901
11902pub(crate) fn get_inference_engine(state: &ServerState) -> &Arc<car_inference::InferenceEngine> {
11907 state.inference.get_or_init(|| {
11908 let engine = Arc::new(car_inference::InferenceEngine::new(
11909 car_inference::InferenceConfig::default(),
11910 ));
11911 let worker_disabled = std::env::var_os("CAR_NO_INFERENCE_WORKER").is_some();
11919 if !car_inference::is_offload_worker() && !worker_disabled {
11920 match crate::inference_worker::WorkerOffload::new() {
11921 Ok(offload) => {
11922 car_inference::set_local_offload(Some(std::sync::Arc::new(offload)));
11923 info!("on-device inference isolated in a worker subprocess (car-releases#74)");
11924 }
11925 Err(e) => tracing::warn!(
11926 error = %e,
11927 "could not install the inference worker offloader; on-device \
11928 generation will run in-process (a native MLX abort could crash the daemon)"
11929 ),
11930 }
11931 }
11932 let discovery_enabled = std::env::var("CAR_MODEL_DISCOVERY")
11941 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
11942 .unwrap_or(false);
11943 if discovery_enabled {
11944 match tokio::runtime::Handle::try_current() {
11948 Ok(handle) => {
11949 let disco = Arc::clone(&engine);
11950 handle.spawn(async move {
11951 loop {
11952 match disco.discover_models().await {
11953 Ok(n) => info!("model discovery: {n} discovered models cached"),
11954 Err(e) => tracing::debug!("model discovery skipped: {e}"),
11955 }
11956 tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
11957 }
11958 });
11959 }
11960 Err(_) => tracing::debug!(
11961 "model discovery enabled (CAR_MODEL_DISCOVERY) but no tokio runtime at \
11962 engine init — skipping the background task"
11963 ),
11964 }
11965 }
11966 engine
11967 })
11968}
11969
11970async fn handle_foreman_plan(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
11975 let goal = msg
11976 .params
11977 .get("goal")
11978 .and_then(|v| v.as_str())
11979 .ok_or("missing 'goal'")?
11980 .to_string();
11981 let repo = msg
11982 .params
11983 .get("repo")
11984 .and_then(|v| v.as_str())
11985 .map(std::path::PathBuf::from)
11986 .or_else(|| std::env::current_dir().ok())
11987 .ok_or("no 'repo' and cwd is unavailable")?;
11988 let max_attempts = msg
11989 .params
11990 .get("max_attempts")
11991 .and_then(|v| v.as_u64())
11992 .unwrap_or(3) as u32;
11993
11994 let engine = std::sync::Arc::clone(get_inference_engine(state));
11995 let result = car_multi::decompose(&repo, &goal, max_attempts, move |prompt| {
11996 let engine = std::sync::Arc::clone(&engine);
11997 async move {
11998 engine
11999 .generate(car_inference::GenerateRequest {
12000 prompt,
12001 ..Default::default()
12002 })
12003 .await
12004 .map_err(|e| e.to_string())
12005 }
12006 })
12007 .await;
12008
12009 serde_json::to_value(car_multi::PlanReport::from(&result))
12010 .map_err(|e| format!("serialize plan: {e}"))
12011}
12012
12013async fn handle_foreman_run(
12017 msg: &JsonRpcMessage,
12018 state: &ServerState,
12019 session: &crate::session::ClientSession,
12020) -> Result<Value, String> {
12021 let goal = msg
12022 .params
12023 .get("goal")
12024 .and_then(|v| v.as_str())
12025 .ok_or("missing 'goal'")?
12026 .to_string();
12027 let repo = msg
12028 .params
12029 .get("repo")
12030 .and_then(|v| v.as_str())
12031 .map(std::path::PathBuf::from)
12032 .or_else(|| std::env::current_dir().ok())
12033 .ok_or("no 'repo' and cwd is unavailable")?;
12034 let max_attempts = msg
12035 .params
12036 .get("max_attempts")
12037 .and_then(|v| v.as_u64())
12038 .unwrap_or(3) as u32;
12039 let adapter = msg
12040 .params
12041 .get("adapter")
12042 .and_then(|v| v.as_str())
12043 .unwrap_or("claude-code")
12044 .to_string();
12045 let str_array = |key: &str| -> Option<Vec<String>> {
12046 msg.params.get(key).and_then(|v| v.as_array()).map(|a| {
12047 a.iter()
12048 .filter_map(|x| x.as_str().map(String::from))
12049 .collect()
12050 })
12051 };
12052 let verify_command = str_array("verify_command");
12056 let union_verify_command = str_array("union_verify_command");
12057
12058 let distributed = msg
12063 .params
12064 .get("distributed")
12065 .and_then(|v| v.as_bool())
12066 .unwrap_or(false);
12067 let worker_filter = str_array("workers");
12068
12069 let engine = std::sync::Arc::clone(get_inference_engine(state));
12073 let run_id = uuid::Uuid::new_v4().to_string();
12074 let agent = car_external_agents::ForemanExternalAgent::new(adapter.clone());
12075 let (pool, pool_plan) = if distributed {
12078 let (pool, plan) =
12079 crate::fleet::build_pool(state, &repo, &run_id, &adapter, worker_filter.as_deref())
12080 .await?;
12081 (Some(pool), Some(plan))
12082 } else {
12083 (None, None)
12084 };
12085 let worktree_agent: &dyn car_multi::WorktreeAgent = match &pool {
12086 Some(pool) => pool,
12087 None => &agent,
12088 };
12089 let infra = car_multi::SharedInfra::with_shared(
12094 std::sync::Arc::clone(&session.runtime.state),
12095 std::sync::Arc::clone(&session.runtime.log),
12096 std::sync::Arc::clone(&session.runtime.policies),
12097 );
12098 let config = car_multi::FarmOutConfig {
12099 verify_command,
12100 union_verify_command,
12101 recover_via_single_session: true,
12104 ..Default::default()
12105 };
12106 let outcome = car_multi::run_foreman(
12107 &repo,
12108 &goal,
12109 max_attempts,
12110 worktree_agent,
12111 &config,
12112 &infra,
12113 move |prompt| {
12114 let engine = std::sync::Arc::clone(&engine);
12115 async move {
12116 engine
12117 .generate(car_inference::GenerateRequest {
12118 prompt,
12119 ..Default::default()
12120 })
12121 .await
12122 .map_err(|e| e.to_string())
12123 }
12124 },
12125 )
12126 .await;
12127
12128 let mode = match outcome.mode {
12129 car_multi::RunMode::Parallel => "parallel",
12130 car_multi::RunMode::SingleSession => "single_session",
12131 car_multi::RunMode::ParallelThenSingleSession => "parallel_then_single_session",
12132 car_multi::RunMode::RegionalReplan => "regional_replan",
12133 };
12134 let report =
12135 car_multi::ForemanReport::from_run(&outcome.outcomes, outcome.integration.as_ref());
12136 Ok(serde_json::json!({
12137 "plan": car_multi::PlanReport::from(&outcome.plan),
12138 "mode": mode,
12139 "ran": true,
12140 "delivered": outcome.delivered(),
12141 "run": report,
12142 "run_id": run_id,
12143 "distributed": distributed,
12144 "workers": pool.as_ref().map(|p| p.worker_ids()),
12148 "placements": pool.as_ref().map(crate::fleet::placements_json),
12149 "pool": pool_plan.as_ref().map(|plan| serde_json::json!({
12153 "remote_workers": plan.remote_workers,
12154 "excluded": plan.excluded.iter().map(|(instance, reason)| serde_json::json!({
12155 "instance": instance,
12156 "reason": reason,
12157 })).collect::<Vec<_>>(),
12158 "quarantined": pool.as_ref().map(|p| p.quarantined()).unwrap_or_default(),
12166 "local_only": plan.local_only(),
12167 "degraded_reason": plan.degraded_reason(),
12168 })),
12169 }))
12170}
12171
12172struct ChatInflightGuard<'a>(&'a std::sync::atomic::AtomicUsize);
12176impl Drop for ChatInflightGuard<'_> {
12177 fn drop(&mut self) {
12178 self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
12179 }
12180}
12181
12182fn begin_chat_turn<'a>(
12196 req: &car_inference::GenerateRequest,
12197 session: &'a crate::session::ClientSession,
12198) -> (Option<String>, bool, Option<ChatInflightGuard<'a>>) {
12199 let is_chat = req
12200 .intent
12201 .as_ref()
12202 .is_some_and(|i| i.task == Some(car_inference::TaskHint::Chat));
12203 if !is_chat {
12204 return (None, false, None);
12205 }
12206 let chat_user_text = req
12207 .messages
12208 .as_ref()
12209 .and_then(|ms| {
12210 ms.iter().rev().find_map(|m| match m {
12211 car_inference::Message::User { content } => Some(content.clone()),
12212 _ => None,
12213 })
12214 })
12215 .or_else(|| {
12216 let p = req.prompt.trim();
12217 (!p.is_empty()).then(|| p.to_string())
12218 });
12219 let was_idle = session
12220 .chat_inflight
12221 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
12222 == 0;
12223 (
12224 chat_user_text,
12225 was_idle,
12226 Some(ChatInflightGuard(&session.chat_inflight)),
12227 )
12228}
12229
12230fn score_and_remember_chat_turn(
12236 slot: &mut Option<crate::session::LastChatTurn>,
12237 tracker: &mut car_inference::OutcomeTracker,
12238 new_user_text: String,
12239 result_text: &str,
12240 trace_id: &str,
12241 model_id: &str,
12242) -> car_memgine::outcome_bridge::CreditTally {
12243 use car_memgine::outcome_signal::ConversationTurn;
12244 let mut tally = car_memgine::outcome_bridge::CreditTally::default();
12245 if let Some(prev) = slot.as_ref() {
12246 let turns = vec![
12247 ConversationTurn::user(prev.user_text.clone(), "u_prev"),
12248 ConversationTurn::assistant(
12249 prev.assistant_text.clone(),
12250 "a_prev",
12251 Some(prev.model_id.clone()),
12252 Some(prev.trace_id.clone()),
12253 ),
12254 ConversationTurn::user(new_user_text.clone(), "u_new"),
12255 ];
12256 tally = car_memgine::outcome_bridge::record_conversation_outcomes(tracker, &turns);
12257 }
12258 *slot = Some(crate::session::LastChatTurn {
12259 user_text: new_user_text,
12260 assistant_text: result_text.to_string(),
12261 trace_id: trace_id.to_string(),
12262 model_id: model_id.to_string(),
12263 });
12264 tally
12265}
12266
12267#[cfg(test)]
12268mod chat_outcome_tests {
12269 use super::score_and_remember_chat_turn;
12270 use crate::session::LastChatTurn;
12271 use car_inference::{InferenceTask, OutcomeTracker};
12272
12273 #[test]
12274 fn first_turn_records_nothing_and_stashes() {
12275 let mut slot: Option<LastChatTurn> = None;
12276 let mut tracker = OutcomeTracker::new();
12277 let tally = score_and_remember_chat_turn(
12278 &mut slot,
12279 &mut tracker,
12280 "hello".into(),
12281 "hi there",
12282 "t1",
12283 "m1",
12284 );
12285 assert_eq!(tally.submitted, 0, "no prior turn → nothing to score");
12286 let stashed = slot.expect("this turn must be remembered");
12287 assert_eq!(stashed.trace_id, "t1");
12288 assert_eq!(stashed.user_text, "hello");
12289 }
12290
12291 #[test]
12292 fn second_turn_scores_prior_turn_and_reverses_mechanical_success() {
12293 let mut tracker = OutcomeTracker::new();
12294 let tid = tracker.record_start("m1", InferenceTask::Generate, "test");
12297 tracker.record_complete(&tid, 10, 1, 2);
12298 assert_eq!(tracker.profile("m1").unwrap().success_count, 1);
12299
12300 let mut slot = Some(LastChatTurn {
12301 user_text: "convert this to async".into(),
12302 assistant_text: "here's a threaded version".into(),
12303 trace_id: tid.clone(),
12304 model_id: "m1".into(),
12305 });
12306 let tally = score_and_remember_chat_turn(
12308 &mut slot,
12309 &mut tracker,
12310 "no, that's not what i asked".into(),
12311 "async version",
12312 "t2",
12313 "m1",
12314 );
12315 assert_eq!(
12316 tally.resolved, 1,
12317 "prior turn's trace was pending → resolved"
12318 );
12319 let p = tracker.profile("m1").unwrap();
12320 assert_eq!(p.success_count, 0, "mechanical success reversed");
12321 assert_eq!(p.fail_count, 1, "circle booked as a failure for m1");
12322 assert_eq!(slot.unwrap().trace_id, "t2", "this turn is now remembered");
12323 }
12324}
12325
12326fn parse_inference_request(params: &Value) -> Result<car_inference::GenerateRequest, String> {
12330 let exact_model_id = match params.get("model_id") {
12331 None | Some(Value::Null) => None,
12332 Some(Value::String(model_id)) if !model_id.trim().is_empty() => Some(model_id.clone()),
12333 Some(_) => return Err("`model_id` must be a non-empty string".to_string()),
12334 };
12335 if exact_model_id.is_some() && params.get("model").is_some_and(|value| !value.is_null()) {
12336 return Err("`model` and `model_id` are mutually exclusive".to_string());
12337 }
12338
12339 let mut normalized = params.clone();
12340 if let Some(object) = normalized.as_object_mut() {
12341 object.remove("model_id");
12342 }
12343 let mut req: car_inference::GenerateRequest = typed_params(&normalized)?;
12344 if let Some(model_id) = exact_model_id {
12345 car_inference::pin_exact_model_id(&mut req, model_id)?;
12346 }
12347 if req.model.is_some() {
12348 req.params.strict_model = true;
12349 }
12350 Ok(req)
12351}
12352
12353#[cfg(test)]
12354mod inference_request_tests {
12355 use super::parse_inference_request;
12356 use serde_json::json;
12357
12358 #[test]
12359 fn explicit_model_is_strict() {
12360 let req = parse_inference_request(&json!({
12361 "prompt": "hello",
12362 "model": "openrouter/qwen/qwen3-coder-next",
12363 }))
12364 .expect("valid explicit-model request");
12365
12366 assert!(req.params.strict_model);
12367 }
12368
12369 #[test]
12370 fn explicit_model_overrides_requested_non_strict_mode() {
12371 let req = parse_inference_request(&json!({
12372 "prompt": "hello",
12373 "model": "openrouter/qwen/qwen3-coder-next",
12374 "params": { "strict_model": false },
12375 }))
12376 .expect("valid explicit-model request");
12377
12378 assert!(req.params.strict_model);
12379 }
12380
12381 #[test]
12382 fn exact_model_id_is_strict_and_does_not_alias() {
12383 let req = parse_inference_request(&json!({
12384 "prompt": "hello",
12385 "model_id": "openai/gpt-5.4:latest",
12386 "params": { "strict_model": false },
12387 }))
12388 .expect("valid exact-id request");
12389
12390 assert_eq!(
12391 car_inference::exact_pinned_model_id(&req),
12392 Some("openai/gpt-5.4:latest")
12393 );
12394 assert!(req.params.strict_model);
12395 }
12396
12397 #[test]
12398 fn legacy_model_and_exact_model_id_are_rejected_together() {
12399 let error = parse_inference_request(&json!({
12400 "prompt": "hello",
12401 "model": "GPT 5.4",
12402 "model_id": "openai/gpt-5.4:latest",
12403 }))
12404 .expect_err("ambiguous dual pin must fail");
12405
12406 assert!(error.contains("mutually exclusive"));
12407 }
12408
12409 #[test]
12410 fn model_less_requests_preserve_requested_strict_mode() {
12411 let loose = parse_inference_request(&json!({
12412 "prompt": "hello",
12413 "params": { "strict_model": false },
12414 }))
12415 .expect("valid adaptive request");
12416 let strict = parse_inference_request(&json!({
12417 "prompt": "hello",
12418 "params": { "strict_model": true },
12419 }))
12420 .expect("valid strict adaptive request");
12421
12422 assert!(!loose.params.strict_model);
12423 assert!(strict.params.strict_model);
12424 }
12425}
12426
12427fn inference_dispatch_error(e: &car_inference::InferenceError) -> String {
12442 match e {
12443 car_inference::InferenceError::ContentRefused { .. } => {
12444 format!("{} {e}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX)
12445 }
12446 car_inference::InferenceError::CatalogPreconditionMismatch { detail } => format!(
12447 "{} {detail}",
12448 car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX
12449 ),
12450 _ => e.to_string(),
12451 }
12452}
12453
12454fn stream_dispatch_error(detail: String) -> String {
12469 match car_inference::stream::content_refusal_tags(&detail) {
12470 Some(_) => format!("{} {detail}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
12471 None => detail,
12472 }
12473}
12474
12475async fn run_owned_inference_backend<F>(
12476 registry: Arc<crate::inference_control::InferenceRegistry>,
12477 inference_id: String,
12478 result_tx: tokio::sync::oneshot::Sender<Result<Value, String>>,
12479 work: F,
12480) where
12481 F: std::future::Future<Output = Result<Value, String>>,
12482{
12483 use futures::FutureExt;
12484 use std::panic::AssertUnwindSafe;
12485
12486 let result = match AssertUnwindSafe(work).catch_unwind().await {
12487 Ok(result) => result,
12488 Err(payload) => {
12489 let detail = payload
12490 .downcast_ref::<&str>()
12491 .map(|message| (*message).to_string())
12492 .or_else(|| payload.downcast_ref::<String>().cloned())
12493 .unwrap_or_else(|| "unknown panic payload".to_string());
12494 Err(format!("inference backend panicked: {detail}"))
12495 }
12496 };
12497 registry.backend_terminated(&inference_id);
12498 let _ = result_tx.send(result);
12499}
12500
12501async fn handle_infer(
12502 msg: &JsonRpcMessage,
12503 state: Arc<ServerState>,
12504 session: Arc<crate::session::ClientSession>,
12505) -> Result<Value, String> {
12506 reject_client_inference_id(&msg.params)?;
12507 let current_run = session.current_run_id.lock().await.clone();
12508 let request_id = msg.id.as_str().map(str::to_string);
12509 let (inference_id, mut terminal) = session
12510 .inference_control
12511 .begin_for_run(current_run, request_id)
12512 .map_err(|error| format!("cannot start inference: {error:?}"))?;
12513 let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
12514 let task_msg = msg.clone();
12515 let task_state = state.clone();
12516 let task_session = session.clone();
12517 let task_registry = session.inference_control.clone();
12518 let task_id = inference_id.clone();
12519 let backend = tokio::spawn(run_owned_inference_backend(
12520 task_registry,
12521 task_id.clone(),
12522 result_tx,
12523 async move {
12524 car_inference::scope_inference_control_id(
12525 task_id.clone(),
12526 handle_infer_active(&task_msg, &task_state, &task_session, &task_id),
12527 )
12528 .await
12529 },
12530 ));
12531 let mut owner =
12532 attach_owned_inference_backend(session.inference_control.clone(), &inference_id, backend)
12533 .map_err(|error| format!("cannot own inference backend: {error:?}"))?;
12534 let exposes_control = session_has_capability(&session, car_proto::INFER_CANCEL_CAPABILITY)
12535 || session_has_capability(&session, car_proto::INFER_DEADLINE_CAPABILITY);
12536 if exposes_control {
12537 if let Err(error) = send_infer_started(&session, &msg.id, &inference_id).await {
12538 return Err(clean_up_failed_infer_started(
12539 &session.inference_control,
12540 &inference_id,
12541 error,
12542 ));
12543 }
12544 }
12545
12546 let result = tokio::select! {
12547 biased;
12548 result = &mut result_rx => match result {
12549 Ok(Ok(mut value)) => {
12550 if session.inference_control.complete(&inference_id) {
12551 if exposes_control {
12552 insert_inference_id(&mut value, &inference_id)?;
12553 }
12554 Ok(value)
12555 } else {
12556 Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12557 }
12558 }
12559 Ok(Err(error)) => {
12560 if session.inference_control.complete(&inference_id) {
12561 Err(error)
12562 } else {
12563 Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12564 }
12565 }
12566 Err(_) => Err("owned inference backend ended without a result".to_string()),
12567 },
12568 _ = terminal.changed() => {
12569 Ok(wait_terminal_control_value(terminal, &inference_id).await)
12570 }
12571 };
12572 car_inference::clear_remote_deadline(&inference_id);
12575 owner.complete_normally();
12576 result
12577}
12578
12579#[derive(Deserialize)]
12580#[serde(deny_unknown_fields)]
12581struct InferCancelParams {
12582 inference_id: String,
12583}
12584
12585#[derive(Deserialize)]
12586#[serde(deny_unknown_fields)]
12587struct InferDeadlineParams {
12588 inference_id: String,
12589 timeout_ms: u64,
12590}
12591
12592async fn exact_backend_termination_ack(inference_id: &str) -> bool {
12593 let Some(offload) = car_inference::current_local_offload() else {
12594 return false;
12595 };
12596 matches!(
12597 offload.terminate_inference(inference_id).await,
12598 car_inference::InferenceTerminationAck::Confirmed
12599 )
12600}
12601
12602async fn handle_infer_cancel(
12603 msg: &JsonRpcMessage,
12604 session: &crate::session::ClientSession,
12605) -> Result<Value, String> {
12606 if !session_has_capability(session, car_proto::INFER_CANCEL_CAPABILITY) {
12607 return Err(format!(
12608 "{} negotiate `{}` as a required or optional capability before calling `infer.cancel`",
12609 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12610 car_proto::INFER_CANCEL_CAPABILITY
12611 ));
12612 }
12613 let params: InferCancelParams = typed_params(&msg.params)?;
12614 if params.inference_id.is_empty() {
12615 return Err("`inference_id` must be a non-empty opaque string".into());
12616 }
12617 let _pending = session
12618 .inference_control
12619 .try_acquire_control()
12620 .map_err(|error| format!("infer.cancel control admission rejected: {error:?}"))?;
12621 let status = session
12622 .inference_control
12623 .control(
12624 ¶ms.inference_id,
12625 crate::inference_control::ControlCause::Cancel,
12626 exact_backend_termination_ack(¶ms.inference_id),
12627 )
12628 .await;
12629 serde_json::to_value(car_proto::InferenceControlResponse {
12630 inference_id: params.inference_id,
12631 status,
12632 })
12633 .map_err(|error| format!("serialize infer.cancel response: {error}"))
12634}
12635
12636async fn handle_infer_deadline(
12637 msg: &JsonRpcMessage,
12638 session: &crate::session::ClientSession,
12639) -> Result<Value, String> {
12640 if !session_has_capability(session, car_proto::INFER_DEADLINE_CAPABILITY) {
12641 return Err(format!(
12642 "{} negotiate `{}` as a required or optional capability before calling `infer.deadline`",
12643 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12644 car_proto::INFER_DEADLINE_CAPABILITY
12645 ));
12646 }
12647 let params: InferDeadlineParams = typed_params(&msg.params)?;
12648 if params.inference_id.is_empty() {
12649 return Err("`inference_id` must be a non-empty opaque string".into());
12650 }
12651 if params.timeout_ms == 0
12652 || params.timeout_ms > crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12653 {
12654 return Err(format!(
12655 "`timeout_ms` must be between 1 and {}",
12656 crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12657 ));
12658 }
12659
12660 let ack_id = params.inference_id.clone();
12661 let status = match session
12662 .inference_control
12663 .schedule_deadline(
12664 ¶ms.inference_id,
12665 std::time::Duration::from_millis(params.timeout_ms),
12666 || exact_backend_termination_ack(&ack_id),
12667 )
12668 .await
12669 {
12670 Ok(status) => {
12671 car_inference::set_remote_deadline(
12677 ¶ms.inference_id,
12678 std::time::Duration::from_millis(params.timeout_ms),
12679 );
12680 status
12681 }
12682 Err(crate::inference_control::DeadlineReservationError::Terminal(status)) => {
12683 return serde_json::to_value(car_proto::InferenceControlResponse {
12684 inference_id: params.inference_id,
12685 status,
12686 })
12687 .map_err(|error| format!("serialize infer.deadline response: {error}"));
12688 }
12689 Err(crate::inference_control::DeadlineReservationError::Registry(
12690 crate::inference_control::RegistryError::PendingControlLimitReached,
12691 )) => {
12692 return Err(
12693 "infer.deadline control admission rejected: PendingControlLimitReached".into(),
12694 );
12695 }
12696 Err(crate::inference_control::DeadlineReservationError::Registry(error)) => {
12697 return Err(format!("infer.deadline rejected: {error:?}"));
12698 }
12699 };
12700 serde_json::to_value(car_proto::InferenceControlResponse {
12701 inference_id: params.inference_id,
12702 status,
12703 })
12704 .map_err(|error| format!("serialize infer.deadline response: {error}"))
12705}
12706
12707fn reject_client_inference_id(params: &Value) -> Result<(), String> {
12708 if params.get("inference_id").is_some() {
12709 return Err("`inference_id` is server-assigned and cannot be supplied or reused".into());
12710 }
12711 Ok(())
12712}
12713
12714#[derive(Debug)]
12715enum InferStartedSendError {
12716 BeforeAdmission(String),
12717 AfterAdmission(String),
12718}
12719
12720impl InferStartedSendError {
12721 fn may_have_been_admitted(&self) -> bool {
12722 matches!(self, Self::AfterAdmission(_))
12723 }
12724}
12725
12726impl std::fmt::Display for InferStartedSendError {
12727 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12728 match self {
12729 Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
12730 formatter.write_str(message)
12731 }
12732 }
12733 }
12734}
12735
12736fn clean_up_failed_infer_started(
12737 registry: &crate::inference_control::InferenceRegistry,
12738 inference_id: &str,
12739 error: InferStartedSendError,
12740) -> String {
12741 if error.may_have_been_admitted() {
12742 registry.abort_after_started_admission(inference_id);
12743 } else {
12744 registry.abandon(inference_id);
12745 }
12746 error.to_string()
12747}
12748
12749struct InferenceOwnerGuard {
12750 registry: Arc<crate::inference_control::InferenceRegistry>,
12751 inference_id: String,
12752 completed_normally: bool,
12753}
12754
12755impl InferenceOwnerGuard {
12756 fn new(
12757 registry: Arc<crate::inference_control::InferenceRegistry>,
12758 inference_id: String,
12759 ) -> Self {
12760 Self {
12761 registry,
12762 inference_id,
12763 completed_normally: false,
12764 }
12765 }
12766
12767 fn complete_normally(&mut self) {
12768 self.completed_normally = true;
12769 }
12770}
12771
12772impl Drop for InferenceOwnerGuard {
12773 fn drop(&mut self) {
12774 if !self.completed_normally {
12775 self.registry.abort_owned_backend(&self.inference_id);
12776 }
12777 }
12778}
12779
12780fn attach_owned_inference_backend(
12781 registry: Arc<crate::inference_control::InferenceRegistry>,
12782 inference_id: &str,
12783 backend: tokio::task::JoinHandle<()>,
12784) -> Result<InferenceOwnerGuard, crate::inference_control::RegistryError> {
12785 registry.attach_backend(inference_id, backend)?;
12786 Ok(InferenceOwnerGuard::new(registry, inference_id.to_string()))
12787}
12788
12789async fn send_infer_started(
12790 session: &crate::session::ClientSession,
12791 request_id: &Value,
12792 inference_id: &str,
12793) -> Result<(), InferStartedSendError> {
12794 use futures::SinkExt;
12795 use tokio_tungstenite::tungstenite::Message;
12796
12797 let frame = serde_json::json!({
12798 "jsonrpc": "2.0",
12799 "method": "infer.started",
12800 "params": {
12801 "request_id": request_id,
12802 "inference_id": inference_id,
12803 },
12804 });
12805 let text = serde_json::to_string(&frame)
12806 .map_err(|error| InferStartedSendError::BeforeAdmission(error.to_string()))?;
12807 let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
12808 let mut write = tokio::time::timeout_at(deadline, session.channel.write.lock())
12809 .await
12810 .map_err(|_| {
12811 InferStartedSendError::BeforeAdmission(format!(
12812 "send infer.started timed out before admission after {}s",
12813 KEEPALIVE_WRITE_TIMEOUT.as_secs()
12814 ))
12815 })?;
12816
12817 match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
12818 Ok(Ok(())) => {}
12819 Ok(Err(error)) => {
12820 return Err(InferStartedSendError::AfterAdmission(format!(
12824 "send infer.started admission failed: {error}"
12825 )));
12826 }
12827 Err(_) => {
12828 return Err(InferStartedSendError::BeforeAdmission(format!(
12831 "send infer.started timed out before admission after {}s",
12832 KEEPALIVE_WRITE_TIMEOUT.as_secs()
12833 )));
12834 }
12835 }
12836
12837 match tokio::time::timeout_at(deadline, write.flush()).await {
12838 Ok(Ok(())) => Ok(()),
12839 Ok(Err(error)) => Err(InferStartedSendError::AfterAdmission(format!(
12840 "flush infer.started: {error}"
12841 ))),
12842 Err(_) => Err(InferStartedSendError::AfterAdmission(format!(
12843 "flush infer.started timed out after {}s; inference is terminal",
12844 KEEPALIVE_WRITE_TIMEOUT.as_secs()
12845 ))),
12846 }
12847}
12848
12849#[cfg(test)]
12850mod inference_backend_reliability_tests {
12851 use super::{
12852 attach_owned_inference_backend, clean_up_failed_infer_started, run_owned_inference_backend,
12853 send_infer_started, send_inference_notification_if_active, KEEPALIVE_WRITE_TIMEOUT,
12854 };
12855 use crate::inference_control::{InferenceRegistry, RegistryConfig};
12856 use crate::session::{ServerState, WsChannel, WsSink};
12857 use futures::{Sink, SinkExt};
12858 use serde_json::json;
12859 use std::collections::HashMap;
12860 use std::pin::Pin;
12861 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12862 use std::sync::Arc;
12863 use std::task::{Context, Poll};
12864 use std::time::Duration;
12865 use tokio::sync::Mutex;
12866 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
12867
12868 struct FlushStallingSink {
12869 pending: Arc<std::sync::Mutex<Vec<Message>>>,
12870 visible: Arc<std::sync::Mutex<Vec<Message>>>,
12871 flush_ready: Arc<AtomicBool>,
12872 }
12873
12874 impl Sink<Message> for FlushStallingSink {
12875 type Error = WsError;
12876
12877 fn poll_ready(
12878 self: Pin<&mut Self>,
12879 _context: &mut Context<'_>,
12880 ) -> Poll<Result<(), Self::Error>> {
12881 Poll::Ready(Ok(()))
12882 }
12883
12884 fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
12885 self.pending
12886 .lock()
12887 .expect("pending frames lock")
12888 .push(message);
12889 Ok(())
12890 }
12891
12892 fn poll_flush(
12893 self: Pin<&mut Self>,
12894 _context: &mut Context<'_>,
12895 ) -> Poll<Result<(), Self::Error>> {
12896 if !self.flush_ready.load(Ordering::SeqCst) {
12897 return Poll::Pending;
12898 }
12899 let mut pending = self.pending.lock().expect("pending frames lock");
12900 self.visible
12901 .lock()
12902 .expect("visible frames lock")
12903 .extend(pending.drain(..));
12904 Poll::Ready(Ok(()))
12905 }
12906
12907 fn poll_close(
12908 self: Pin<&mut Self>,
12909 context: &mut Context<'_>,
12910 ) -> Poll<Result<(), Self::Error>> {
12911 self.poll_flush(context)
12912 }
12913 }
12914
12915 fn one_slot_registry() -> Arc<InferenceRegistry> {
12916 Arc::new(InferenceRegistry::with_config(RegistryConfig {
12917 max_active: 1,
12918 max_tombstones: 1,
12919 max_pending_controls: 1,
12920 max_orphans: 1,
12921 tombstone_ttl: Duration::from_secs(300),
12922 termination_ack_timeout: Duration::from_millis(20),
12923 }))
12924 }
12925
12926 #[tokio::test]
12927 async fn panicking_backend_publishes_terminal_error_and_releases_registry_capacity() {
12928 let registry = one_slot_registry();
12929 registry
12930 .begin_with_id("panic-stream")
12931 .expect("register panicking stream");
12932 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
12933 let task = tokio::spawn(run_owned_inference_backend(
12934 registry.clone(),
12935 "panic-stream".to_string(),
12936 result_tx,
12937 async {
12938 panic!("fixture stream backend panic");
12939 #[allow(unreachable_code)]
12940 Ok(json!({}))
12941 },
12942 ));
12943 registry
12944 .attach_backend("panic-stream", task)
12945 .expect("attach panicking stream");
12946
12947 let error = result_rx
12948 .await
12949 .expect("panic must be converted into a published result")
12950 .expect_err("panic must be a terminal error");
12951 assert!(error.contains("backend panicked"), "{error}");
12952 assert!(
12953 registry.complete("panic-stream"),
12954 "client-visible failure must terminalize the active entry"
12955 );
12956 assert_eq!(registry.counts().0, 0);
12957 registry
12958 .begin_with_id("replacement-stream")
12959 .expect("panicking backend must not consume the one session slot forever");
12960 }
12961
12962 #[tokio::test]
12963 async fn panic_before_attach_and_receiver_drop_still_releases_backend_ownership() {
12964 let registry = one_slot_registry();
12965 registry
12966 .begin_with_id("panic-before-attach")
12967 .expect("register panicking backend");
12968 let (result_tx, result_rx) = tokio::sync::oneshot::channel();
12969 drop(result_rx);
12970 run_owned_inference_backend(
12971 registry.clone(),
12972 "panic-before-attach".to_string(),
12973 result_tx,
12974 async {
12975 panic!("panic before JoinHandle attachment");
12976 #[allow(unreachable_code)]
12977 Ok(json!({}))
12978 },
12979 )
12980 .await;
12981 registry
12982 .attach_backend("panic-before-attach", tokio::spawn(async {}))
12983 .expect("late attachment observes prior termination");
12984 assert!(registry.complete("panic-before-attach"));
12985 assert_eq!(registry.counts().0, 0);
12986 registry
12987 .begin_with_id("after-panic-before-attach")
12988 .expect("panic before attachment must not strand capacity");
12989 }
12990
12991 #[tokio::test(start_paused = true)]
12992 async fn infer_started_write_timeout_cleans_up_instead_of_waiting_on_the_socket_forever() {
12993 let tmp = tempfile::TempDir::new().expect("temporary server state");
12994 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
12995 let channel = Arc::new(WsChannel::test_stub());
12996 let session = state
12997 .create_session("stalled-infer-start", channel.clone())
12998 .await
12999 .expect("create test session");
13000 let registry = session.inference_control.clone();
13001 registry
13002 .begin_with_id("stalled-start")
13003 .expect("register pending inference");
13004 let backend = tokio::spawn(std::future::pending::<()>());
13005 registry
13006 .attach_backend("stalled-start", backend)
13007 .expect("attach pending backend");
13008
13009 let _held_write = channel.write.lock().await;
13010 let task_session = session.clone();
13011 let send = tokio::spawn(async move {
13012 let result = send_infer_started(&task_session, &json!(7), "stalled-start").await;
13013 if result.is_err() {
13014 task_session.inference_control.abandon("stalled-start");
13015 }
13016 result
13017 });
13018 tokio::task::yield_now().await;
13019 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13020
13021 let error = tokio::time::timeout(Duration::from_millis(1), send)
13022 .await
13023 .expect("infer.started must carry its own bounded-write policy")
13024 .expect("send task joins")
13025 .expect_err("held write lock must time out");
13026 assert!(error.to_string().contains("timed out"), "{error}");
13027 assert_eq!(registry.counts(), (0, 0), "failed start must be removed");
13028 }
13029
13030 #[tokio::test(start_paused = true)]
13031 async fn infer_started_flush_timeout_keeps_any_later_visible_id_terminally_queryable() {
13032 let tmp = tempfile::TempDir::new().expect("temporary server state");
13033 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13034 let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13035 let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13036 let flush_ready = Arc::new(AtomicBool::new(false));
13037 let sink: WsSink = Box::pin(FlushStallingSink {
13038 pending: pending.clone(),
13039 visible: visible.clone(),
13040 flush_ready: flush_ready.clone(),
13041 });
13042 let channel = Arc::new(WsChannel {
13043 write: Mutex::new(sink),
13044 pending: Mutex::new(HashMap::new()),
13045 active_actions: Mutex::new(HashMap::new()),
13046 next_id: AtomicU64::new(0),
13047 });
13048 let session = state
13049 .create_session("partial-infer-start", channel.clone())
13050 .await
13051 .expect("create test session");
13052 let registry = session.inference_control.clone();
13053 registry
13054 .begin_with_id("partially-admitted-start")
13055 .expect("register pending inference");
13056 registry
13057 .attach_backend(
13058 "partially-admitted-start",
13059 tokio::spawn(std::future::pending::<()>()),
13060 )
13061 .expect("attach pending backend");
13062
13063 let task_session = session.clone();
13064 let send = tokio::spawn(async move {
13065 let error = send_infer_started(&task_session, &json!(8), "partially-admitted-start")
13066 .await
13067 .expect_err("flush must stall");
13068 clean_up_failed_infer_started(
13069 &task_session.inference_control,
13070 "partially-admitted-start",
13071 error,
13072 )
13073 });
13074 tokio::task::yield_now().await;
13075 assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13076 assert!(visible.lock().expect("visible frames lock").is_empty());
13077
13078 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13079 let error = send.await.expect("send task joins");
13080 assert!(error.contains("terminal"), "{error}");
13081 assert_eq!(registry.counts(), (0, 1));
13082 assert!(
13083 registry
13084 .terminal_outcome("partially-admitted-start")
13085 .is_some(),
13086 "a queued infer.started ID must remain queryable"
13087 );
13088
13089 flush_ready.store(true, Ordering::SeqCst);
13090 channel
13091 .write
13092 .lock()
13093 .await
13094 .send(Message::Text(
13095 json!({"jsonrpc":"2.0","id":8,"error":{"code":-32603}})
13096 .to_string()
13097 .into(),
13098 ))
13099 .await
13100 .expect("later dispatcher response flushes queued frames");
13101 let visible = visible.lock().expect("visible frames lock");
13102 assert_eq!(visible.len(), 2);
13103 let started: serde_json::Value = match &visible[0] {
13104 Message::Text(text) => serde_json::from_str(text).expect("started JSON"),
13105 other => panic!("expected text frame, got {other:?}"),
13106 };
13107 assert_eq!(
13108 started["params"]["inference_id"],
13109 "partially-admitted-start"
13110 );
13111 assert!(
13112 registry
13113 .terminal_outcome("partially-admitted-start")
13114 .is_some(),
13115 "later visibility must not turn the admitted ID into Unknown"
13116 );
13117 }
13118
13119 #[tokio::test(start_paused = true)]
13120 async fn notification_write_budget_reports_whether_a_frame_was_admitted() {
13121 let registry = one_slot_registry();
13122 registry
13123 .begin_with_id("notification-lock-timeout")
13124 .expect("register lock-stalled notification");
13125 let channel = Arc::new(WsChannel::test_stub());
13126 let held_write = channel.write.lock().await;
13127 let task_channel = channel.clone();
13128 let task_registry = registry.clone();
13129 let lock_timeout = tokio::spawn(async move {
13130 send_inference_notification_if_active(
13131 &task_channel,
13132 &task_registry,
13133 "notification-lock-timeout",
13134 json!({"method":"infer.progress"}).to_string(),
13135 )
13136 .await
13137 .expect_err("held writer lock must time out")
13138 });
13139 tokio::task::yield_now().await;
13140 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13141 let error = lock_timeout.await.expect("lock-timeout task joins");
13142 assert!(!error.may_have_been_admitted(), "{error}");
13143 assert!(
13144 error.to_string().contains("write lock timed out"),
13145 "{error}"
13146 );
13147 drop(held_write);
13148 registry.abandon("notification-lock-timeout");
13149
13150 let registry = one_slot_registry();
13151 registry
13152 .begin_with_id("progress-flush-timeout")
13153 .expect("register flush-stalled notification");
13154 let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13155 let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13156 let flush_ready = Arc::new(AtomicBool::new(false));
13157 let sink: WsSink = Box::pin(FlushStallingSink {
13158 pending: pending.clone(),
13159 visible: visible.clone(),
13160 flush_ready,
13161 });
13162 let channel = Arc::new(WsChannel {
13163 write: Mutex::new(sink),
13164 pending: Mutex::new(HashMap::new()),
13165 active_actions: Mutex::new(HashMap::new()),
13166 next_id: AtomicU64::new(0),
13167 });
13168 let task_registry = registry.clone();
13169 let flush_timeout = tokio::spawn(async move {
13170 send_inference_notification_if_active(
13171 &channel,
13172 &task_registry,
13173 "progress-flush-timeout",
13174 json!({"method":"infer.progress"}).to_string(),
13175 )
13176 .await
13177 .expect_err("flush-stalled notification must time out")
13178 });
13179 tokio::task::yield_now().await;
13180 assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13181 assert!(visible.lock().expect("visible frames lock").is_empty());
13182 tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13183 let error = flush_timeout.await.expect("flush-timeout task joins");
13184 assert!(error.may_have_been_admitted(), "{error}");
13185 assert!(error.to_string().contains("flush timed out"), "{error}");
13186 registry.abort_owned_backend("progress-flush-timeout");
13187 assert!(registry
13188 .terminal_outcome("progress-flush-timeout")
13189 .is_some());
13190 }
13191
13192 #[tokio::test]
13193 async fn dropping_an_attached_owner_aborts_infer_backend() {
13194 for method in ["infer"] {
13195 let registry = one_slot_registry();
13196 let inference_id = format!("dropped-owner-{method}");
13197 registry
13198 .begin_with_id(&inference_id)
13199 .expect("register owned backend");
13200 let admission = Arc::new(tokio::sync::Semaphore::new(1));
13201 let task_admission = admission.clone();
13202 let backend = tokio::spawn(async move {
13203 let _permit = task_admission
13204 .acquire_owned()
13205 .await
13206 .expect("admission open");
13207 std::future::pending::<()>().await;
13208 });
13209 let owner_guard =
13210 attach_owned_inference_backend(registry.clone(), &inference_id, backend)
13211 .expect("attach through the shared handler ownership path");
13212 let owner = tokio::spawn(async move {
13213 let _owner = owner_guard;
13214 std::future::pending::<()>().await;
13215 });
13216 tokio::task::yield_now().await;
13217 assert_eq!(admission.available_permits(), 0);
13218
13219 owner.abort();
13220 assert!(owner
13221 .await
13222 .expect_err("owner future is cancelled")
13223 .is_cancelled());
13224 tokio::task::yield_now().await;
13225
13226 assert_eq!(admission.available_permits(), 1, "{method}");
13227 assert_eq!(registry.counts(), (0, 1), "{method}");
13228 assert_eq!(registry.orphan_count(), 0, "{method}");
13229 assert!(
13230 registry.terminal_outcome(&inference_id).is_some(),
13231 "{method}"
13232 );
13233 registry
13234 .begin_with_id(&format!("replacement-{method}"))
13235 .expect("owner drop releases per-session capacity");
13236 registry.abort_all();
13237 }
13238 }
13239}
13240
13241fn insert_inference_id(value: &mut Value, inference_id: &str) -> Result<(), String> {
13242 value
13243 .as_object_mut()
13244 .ok_or_else(|| "inference result must be a JSON object".to_string())?
13245 .insert(
13246 "inference_id".to_string(),
13247 Value::String(inference_id.to_string()),
13248 );
13249 Ok(())
13250}
13251
13252async fn send_inference_notification_if_active(
13258 channel: &WsChannel,
13259 registry: &crate::inference_control::InferenceRegistry,
13260 inference_id: &str,
13261 text: String,
13262) -> Result<bool, InferenceNotificationSendError> {
13263 let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13264 let mut write = tokio::time::timeout_at(deadline, channel.write.lock())
13265 .await
13266 .map_err(|_| {
13267 InferenceNotificationSendError::BeforeAdmission(format!(
13268 "notification write lock timed out after {}s",
13269 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13270 ))
13271 })?;
13272 if !registry.is_active(inference_id) {
13273 return Ok(false);
13274 }
13275
13276 match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13277 Ok(Ok(())) => {}
13278 Ok(Err(error)) => {
13279 return Err(InferenceNotificationSendError::AfterAdmission(format!(
13280 "notification admission failed: {error}"
13281 )));
13282 }
13283 Err(_) => {
13284 return Err(InferenceNotificationSendError::BeforeAdmission(format!(
13285 "notification admission timed out after {}s",
13286 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13287 )));
13288 }
13289 }
13290
13291 match tokio::time::timeout_at(deadline, write.flush()).await {
13292 Ok(Ok(())) => Ok(true),
13293 Ok(Err(error)) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13294 "notification flush failed: {error}"
13295 ))),
13296 Err(_) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13297 "notification flush timed out after {}s",
13298 KEEPALIVE_WRITE_TIMEOUT.as_secs()
13299 ))),
13300 }
13301}
13302
13303#[derive(Debug)]
13304enum InferenceNotificationSendError {
13305 BeforeAdmission(String),
13306 AfterAdmission(String),
13307}
13308
13309impl InferenceNotificationSendError {
13310 #[cfg(test)]
13311 fn may_have_been_admitted(&self) -> bool {
13312 matches!(self, Self::AfterAdmission(_))
13313 }
13314}
13315
13316impl std::fmt::Display for InferenceNotificationSendError {
13317 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13318 match self {
13319 Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
13320 formatter.write_str(message)
13321 }
13322 }
13323 }
13324}
13325
13326#[cfg(test)]
13327mod inference_notification_terminal_boundary_tests {
13328 use super::send_inference_notification_if_active;
13329 use crate::inference_control::{ControlCause, InferenceRegistry};
13330 use crate::session::WsChannel;
13331 use car_proto::InferenceControlStatus;
13332 use futures::SinkExt;
13333 use std::sync::Arc;
13334 use tokio::sync::oneshot;
13335 use tokio_tungstenite::tungstenite::Message;
13336
13337 #[tokio::test]
13338 async fn terminal_response_is_a_hard_boundary_for_progress_frames() {
13339 for (inference_id, cause, expected, wire_status) in [
13340 (
13341 "inf_terminal_boundary_cancel",
13342 ControlCause::Cancel,
13343 InferenceControlStatus::CancelledConfirmed,
13344 "cancelled_confirmed",
13345 ),
13346 (
13347 "inf_terminal_boundary_deadline",
13348 ControlCause::Deadline,
13349 InferenceControlStatus::DeadlineExceededConfirmed,
13350 "deadline_exceeded_confirmed",
13351 ),
13352 ] {
13353 let registry = Arc::new(InferenceRegistry::default());
13354 registry
13355 .begin_with_id(inference_id)
13356 .expect("register active inference");
13357 let (channel, captured) = WsChannel::test_capture();
13358 let channel = Arc::new(channel);
13359
13360 let mut boundary = channel.write.lock().await;
13364 assert!(registry.is_active(inference_id), "event precheck is active");
13365
13366 let frame = serde_json::json!({
13367 "jsonrpc": "2.0",
13368 "method": "infer.progress",
13369 "params": {"id": 1},
13370 });
13371 let channel = channel.clone();
13372 let notification_registry = registry.clone();
13373 let text = serde_json::to_string(&frame).expect("serialize notification");
13374 let (started_tx, started_rx) = oneshot::channel();
13375 let pending = tokio::spawn(async move {
13376 started_tx.send(()).expect("signal event receipt");
13377 send_inference_notification_if_active(
13378 &channel,
13379 ¬ification_registry,
13380 inference_id,
13381 text,
13382 )
13383 .await
13384 });
13385 started_rx.await.expect("notification task reached barrier");
13386
13387 let terminal = registry.control(inference_id, cause, async { true }).await;
13388 assert_eq!(terminal, expected);
13389 boundary
13390 .send(Message::Text(
13391 serde_json::json!({
13392 "jsonrpc": "2.0",
13393 "id": "terminal-1",
13394 "result": {
13395 "inference_id": inference_id,
13396 "status": wire_status,
13397 },
13398 })
13399 .to_string()
13400 .into(),
13401 ))
13402 .await
13403 .expect("write terminal response");
13404 drop(boundary);
13405
13406 assert!(
13407 !pending
13408 .await
13409 .expect("notification task")
13410 .expect("notification send"),
13411 "terminal notification must be suppressed"
13412 );
13413
13414 let frames = captured.lock().expect("capture lock");
13415 assert_eq!(frames.len(), 1, "no frame may follow terminal response");
13416 assert!(frames[0].contains(wire_status));
13417 }
13418 eprintln!(
13419 "C3_TERMINAL_BOUNDARY_SHARED=terminals=cancelled_confirmed+deadline_exceeded_confirmed,late_progress=suppressed,frames=1_each"
13420 );
13421 }
13422}
13423
13424async fn wait_terminal_control_value(
13425 mut terminal: tokio::sync::watch::Receiver<Option<crate::inference_control::TerminalOutcome>>,
13426 inference_id: &str,
13427) -> Value {
13428 if terminal.borrow().is_none() {
13429 let _ = terminal.changed().await;
13430 }
13431 let status = match *terminal.borrow() {
13432 Some(crate::inference_control::TerminalOutcome::Controlled(status)) => status,
13433 _ => car_proto::InferenceControlStatus::AlreadyTerminal,
13434 };
13435 serde_json::to_value(car_proto::InferenceControlResponse {
13436 inference_id: inference_id.to_string(),
13437 status,
13438 })
13439 .unwrap_or_else(|_| {
13440 serde_json::json!({
13441 "inference_id": inference_id,
13442 "status": "already_terminal",
13443 })
13444 })
13445}
13446
13447async fn handle_infer_active(
13448 msg: &JsonRpcMessage,
13449 state: &ServerState,
13450 session: &crate::session::ClientSession,
13451 inference_id: &str,
13452) -> Result<Value, String> {
13453 use futures::FutureExt;
13454 use std::panic::AssertUnwindSafe;
13455 use std::time::Duration;
13456
13457 let engine = get_inference_engine(state);
13458 let mut req = parse_inference_request(&msg.params)?;
13459
13460 if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13462 let memgine_arc = session.effective_memgine().await;
13465 let mut memgine = memgine_arc.lock().await;
13466 let split = memgine.build_context_split_for_model(cq, None);
13472 if !split.full.is_empty() {
13473 req.context_stable_prefix = split.stable_prefix();
13474 req.context = Some(split.full);
13475 }
13476 }
13477 req.caller = Some(session.client_id.clone());
13481 maybe_apply_proactive_memory(msg, session, &mut req).await?;
13482
13483 if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13489 {
13490 req.intent.get_or_insert_with(Default::default).high_stakes = true;
13491 }
13492
13493 let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13497
13498 const INFER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
13509 struct HeartbeatGuard(tokio::task::JoinHandle<()>);
13510 impl Drop for HeartbeatGuard {
13511 fn drop(&mut self) {
13512 self.0.abort();
13513 }
13514 }
13515 let _heartbeat = {
13516 let channel = session.channel.clone();
13517 let request_id = msg.id.clone();
13518 let registry = session.inference_control.clone();
13519 let inference_id = inference_id.to_string();
13520 HeartbeatGuard(tokio::spawn(async move {
13521 let mut ticker = tokio::time::interval(INFER_HEARTBEAT_INTERVAL);
13522 ticker.tick().await; loop {
13524 ticker.tick().await;
13525 let notif = serde_json::json!({
13526 "jsonrpc": "2.0",
13527 "method": "infer.progress",
13528 "params": { "id": request_id },
13529 });
13530 let Ok(text) = serde_json::to_string(¬if) else {
13531 break;
13532 };
13533 match send_inference_notification_if_active(
13534 &channel,
13535 ®istry,
13536 &inference_id,
13537 text,
13538 )
13539 .await
13540 {
13541 Ok(true) => {}
13542 Ok(false) | Err(_) => break,
13543 }
13544 }
13545 }))
13546 };
13547
13548 let does_local_compute =
13563 match car_inference::exact_pinned_model_id(&req).or(req.model.as_deref()) {
13564 Some(pinned) => !engine
13565 .registered_schema(pinned)
13566 .map(|s| s.is_remote())
13567 .unwrap_or(false),
13568 None => true,
13569 };
13570 let _permit = state.admission.acquire_for(does_local_compute).await;
13571
13572 let infer_timeout = inference_total_timeout();
13593 let result = match tokio::time::timeout(
13594 infer_timeout,
13595 AssertUnwindSafe(engine.generate_tracked(req)).catch_unwind(),
13596 )
13597 .await
13598 {
13599 Ok(Ok(r)) => r.map_err(|e| inference_dispatch_error(&e))?,
13600 Ok(Err(panic)) => {
13601 let detail = panic
13602 .downcast_ref::<&str>()
13603 .map(|s| (*s).to_string())
13604 .or_else(|| panic.downcast_ref::<String>().cloned())
13605 .unwrap_or_else(|| "unknown panic".to_string());
13606 return Err(format!(
13607 "inference failed (internal panic): {detail}. If this is a CUDA build, \
13608 ensure the CUDA toolkit `bin` directory is on PATH so cudarc can load \
13609 cublas/cudart."
13610 ));
13611 }
13612 Err(_) => {
13613 return Err(format!(
13614 "inference timed out after {}s with no result — the compute backend may \
13615 have failed to initialize (e.g. a CUDA/cuBLAS library load error). \
13616 Set CAR_INFER_TIMEOUT_SECS to adjust the limit.",
13617 infer_timeout.as_secs()
13618 ));
13619 }
13620 };
13621
13622 if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13630 let mut slot = session.last_chat_turn.lock().await;
13631 let tracker_arc = engine.outcome_tracker();
13632 let mut tracker = tracker_arc.write().await;
13633 let tally = score_and_remember_chat_turn(
13634 &mut slot,
13635 &mut tracker,
13636 user_text,
13637 &result.text,
13638 &result.trace_id,
13639 &result.model_used,
13640 );
13641 if tally.submitted > 0 {
13642 tracing::debug!(
13643 submitted = tally.submitted,
13644 resolved = tally.resolved,
13645 "conversation-outcome recorded for prior chat turn"
13646 );
13647 }
13648 }
13649
13650 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13651}
13652
13653async fn handle_image_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13683 let engine = get_inference_engine(state);
13684 let req: car_inference::GenerateImageRequest = typed_params(&msg.params)?;
13685 let _permit = state.admission.acquire().await;
13688 let result = engine
13689 .generate_image(req)
13690 .await
13691 .map_err(|e| e.to_string())?;
13692 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13693}
13694
13695async fn handle_video_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13696 let engine = get_inference_engine(state);
13697 let req: car_inference::GenerateVideoRequest = typed_params(&msg.params)?;
13698 let _permit = state.admission.acquire().await;
13699 let result = engine
13700 .generate_video(req)
13701 .await
13702 .map_err(|e| e.to_string())?;
13703 serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13704}
13705
13706async fn handle_infer_stream(
13707 msg: &JsonRpcMessage,
13708 session: &crate::session::ClientSession,
13709 state: &ServerState,
13710) -> Result<Value, String> {
13711 let engine = get_inference_engine(state);
13712 let mut req = parse_inference_request(&msg.params)?;
13713
13714 if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13717 let memgine_arc = session.effective_memgine().await;
13720 let mut memgine = memgine_arc.lock().await;
13721 let split = memgine.build_context_split_for_model(cq, None);
13727 if !split.full.is_empty() {
13728 req.context_stable_prefix = split.stable_prefix();
13729 req.context = Some(split.full);
13730 }
13731 }
13732 req.caller = Some(session.client_id.clone());
13736 maybe_apply_proactive_memory(msg, session, &mut req).await?;
13737
13738 if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13741 {
13742 req.intent.get_or_insert_with(Default::default).high_stakes = true;
13743 }
13744
13745 let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13749
13750 let _permit = state.admission.acquire().await;
13751 let mut handle = engine
13758 .generate_tracked_stream(req)
13759 .await
13760 .map_err(|e| inference_dispatch_error(&e))?;
13761
13762 let mut accumulator = car_inference::StreamAccumulator::default();
13763 let mut stream_error: Option<String> = None;
13764 let mut saw_done = false;
13765 let request_id = msg.id.clone();
13766
13767 while let Some(event) = handle.events.recv().await {
13768 let event_payload = match &event {
13769 car_inference::StreamEvent::TextDelta(text) => {
13770 serde_json::json!({"type": "text", "data": text})
13771 }
13772 car_inference::StreamEvent::ToolCallStart { name, index, .. } => {
13773 serde_json::json!({"type": "tool_start", "name": name, "index": index})
13774 }
13775 car_inference::StreamEvent::ToolCallDelta {
13776 index,
13777 arguments_delta,
13778 } => serde_json::json!({
13779 "type": "tool_delta",
13780 "index": index,
13781 "data": arguments_delta,
13782 }),
13783 car_inference::StreamEvent::Usage {
13784 input_tokens,
13785 output_tokens,
13786 cache_read_input_tokens,
13787 cache_creation_input_tokens,
13788 } => serde_json::json!({
13789 "type": "usage",
13790 "input_tokens": input_tokens,
13791 "output_tokens": output_tokens,
13792 "cache_read_input_tokens": cache_read_input_tokens,
13793 "cache_creation_input_tokens": cache_creation_input_tokens,
13794 }),
13795 car_inference::StreamEvent::StopReason(reason) => {
13796 serde_json::json!({"type": "stop_reason", "data": reason})
13797 }
13798 car_inference::StreamEvent::ProviderOutputItem(item) => {
13799 serde_json::json!({"type": "provider_output_item", "item": item})
13800 }
13801 car_inference::StreamEvent::Error(message) => {
13802 stream_error = Some(message.clone());
13803 serde_json::json!({"type": "error", "message": message})
13804 }
13805 car_inference::StreamEvent::Done { .. } => {
13810 saw_done = true;
13811 accumulator.push(&event);
13812 continue;
13813 }
13814 };
13815
13816 let notif = serde_json::json!({
13817 "jsonrpc": "2.0",
13818 "method": "inference.stream.event",
13819 "params": {
13820 "request_id": request_id,
13821 "event": event_payload,
13822 },
13823 });
13824 if let Ok(text) = serde_json::to_string(¬if) {
13825 let _ = session
13826 .channel
13827 .write
13828 .lock()
13829 .await
13830 .send(Message::Text(text.into()))
13831 .await;
13832 }
13833 accumulator.push(&event);
13834 }
13835
13836 let (text, tool_calls, usage, stop_reason) = accumulator.finish_with_usage();
13837
13838 if let Some(error) = stream_error {
13839 return Err(stream_dispatch_error(error));
13844 }
13845 if !saw_done {
13846 return Err("stream ended without positive completion".to_string());
13847 }
13848
13849 if let Some(u) = &usage {
13857 session.runtime.log.lock().await.append_metered(
13858 car_eventlog::EventKind::InferenceMetered,
13859 None,
13860 None,
13861 HashMap::new(),
13862 car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None),
13863 );
13864 }
13865
13866 if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13877 let mut slot = session.last_chat_turn.lock().await;
13878 let tracker_arc = engine.outcome_tracker();
13879 let mut tracker = tracker_arc.write().await;
13880 let tally = score_and_remember_chat_turn(
13881 &mut slot,
13882 &mut tracker,
13883 user_text,
13884 &text,
13885 &handle.trace_id,
13886 &handle.model_used,
13887 );
13888 if tally.submitted > 0 {
13889 tracing::debug!(
13890 submitted = tally.submitted,
13891 resolved = tally.resolved,
13892 "conversation-outcome recorded for prior chat turn (streaming)"
13893 );
13894 }
13895 }
13896
13897 Ok(serde_json::json!({
13898 "text": text,
13899 "tool_calls": tool_calls,
13900 "usage": usage,
13901 "stop_reason": stop_reason,
13902 "trace_id": handle.trace_id,
13905 "model_used": handle.model_used,
13906 }))
13907}
13908
13909async fn handle_embed(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13910 let engine = get_inference_engine(state);
13911 let req: car_inference::EmbedRequest = typed_params(&msg.params)?;
13912 let _permit = state.admission.acquire().await;
13916 let result = engine.embed(req).await.map_err(|e| e.to_string())?;
13917 Ok(serde_json::json!({"embeddings": result}))
13918}
13919
13920async fn handle_classify(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13921 let engine = get_inference_engine(state);
13922 let req: car_inference::ClassifyRequest = typed_params(&msg.params)?;
13923 let _permit = state.admission.acquire().await;
13924 let result = engine.classify(req).await.map_err(|e| e.to_string())?;
13925 Ok(serde_json::json!({"classifications": result}))
13926}
13927
13928fn handle_admission_status(state: &ServerState) -> Result<Value, String> {
13932 let total = state.admission.permits();
13933 let available = state.admission.permits_available();
13934 let in_use = total.saturating_sub(available);
13935 Ok(serde_json::json!({
13936 "permits_total": total,
13937 "permits_available": available,
13938 "permits_in_use": in_use,
13939 "env_override": crate::admission::ENV_MAX_CONCURRENT,
13940 }))
13941}
13942
13943async fn handle_tokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13944 let model = msg
13945 .params
13946 .get("model")
13947 .and_then(|v| v.as_str())
13948 .ok_or("missing 'model' parameter")?;
13949 let text = msg
13950 .params
13951 .get("text")
13952 .and_then(|v| v.as_str())
13953 .ok_or("missing 'text' parameter")?;
13954 let engine = get_inference_engine(state);
13955 let ids = engine
13956 .tokenize(model, text)
13957 .await
13958 .map_err(|e| e.to_string())?;
13959 Ok(serde_json::json!({"tokens": ids}))
13960}
13961
13962async fn handle_detokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13963 let model = msg
13964 .params
13965 .get("model")
13966 .and_then(|v| v.as_str())
13967 .ok_or("missing 'model' parameter")?;
13968 let tokens: Vec<u32> = msg
13969 .params
13970 .get("tokens")
13971 .and_then(|v| v.as_array())
13972 .ok_or("missing 'tokens' parameter")?
13973 .iter()
13974 .map(|t| {
13975 t.as_u64()
13976 .and_then(|n| u32::try_from(n).ok())
13977 .ok_or_else(|| "tokens[] must be u32 values".to_string())
13978 })
13979 .collect::<Result<Vec<_>, _>>()?;
13980 let engine = get_inference_engine(state);
13981 let text = engine
13982 .detokenize(model, &tokens)
13983 .await
13984 .map_err(|e| e.to_string())?;
13985 Ok(serde_json::json!({"text": text}))
13986}
13987
13988fn parse_user_registered_model_schema(
14008 schema_value: Value,
14009) -> Result<car_inference::ModelSchema, String> {
14010 let mut schema: car_inference::ModelSchema =
14011 serde_json::from_value(schema_value).map_err(|e| format!("invalid ModelSchema: {e}"))?;
14012 if car_inference::openrouter::is_curated_managed_gateway_alias(&schema.id) {
14013 return Err(
14014 "Parslee-managed OpenRouter aliases are reserved and cannot be registered".into(),
14015 );
14016 }
14017 schema.mark_user_registered();
14018 Ok(schema)
14019}
14020
14021async fn handle_models_register(
14022 req: &JsonRpcMessage,
14023 _state: &Arc<ServerState>,
14024) -> Result<Value, String> {
14025 let schema_value = match req.params.get("schema") {
14029 Some(v) => v.clone(),
14030 None => req.params.clone(),
14031 };
14032 let schema = parse_user_registered_model_schema(schema_value)?;
14033 let id = schema.id.clone();
14034
14035 let path = car_inference::registry::user_config_path()
14041 .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14042 if let Some(car_dir) = path.parent() {
14043 std::fs::create_dir_all(car_dir)
14044 .map_err(|e| format!("create {}: {e}", car_dir.display()))?;
14045 }
14046
14047 let mut models: Vec<car_inference::ModelSchema> = if path.exists() {
14048 let text =
14049 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14050 if text.trim().is_empty() {
14051 Vec::new()
14052 } else {
14053 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14054 }
14055 } else {
14056 Vec::new()
14057 };
14058 for model in &mut models {
14059 model.mark_user_registered();
14062 }
14063 if let Some(slot) = models.iter_mut().find(|m| m.id == id) {
14065 *slot = schema;
14066 } else {
14067 models.push(schema);
14068 }
14069 let json =
14070 serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14071 let tmp = path.with_extension("json.tmp");
14072 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14073 std::fs::rename(&tmp, &path)
14074 .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14075 Ok(serde_json::json!({
14076 "id": id,
14077 "registered": true,
14078 "path": path.to_string_lossy(),
14079 "note": "Daemon restart required for live UnifiedRegistry visibility \
14080 (Parslee-ai/car-releases#39 phase 1). The model is persisted; \
14081 next car-server boot loads it via UnifiedRegistry::load_user_config.",
14082 }))
14083}
14084
14085#[cfg(test)]
14086mod model_registration_trust_tests {
14087 use super::parse_user_registered_model_schema;
14088
14089 #[test]
14090 fn daemon_models_register_rejects_reserved_managed_aliases() {
14091 let schema = car_inference::openrouter::curated_schemas()
14092 .into_iter()
14093 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14094 .unwrap();
14095 let error =
14096 parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap_err();
14097 assert_eq!(
14098 error,
14099 "Parslee-managed OpenRouter aliases are reserved and cannot be registered"
14100 );
14101 }
14102
14103 #[test]
14104 fn daemon_models_register_forces_non_reserved_schema_to_community() {
14105 let mut schema = car_inference::openrouter::curated_schemas()
14106 .into_iter()
14107 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14108 .unwrap();
14109 schema.id = "user/nearby-model".into();
14110
14111 let registered =
14112 parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap();
14113 assert_eq!(registered.trust_tier, car_inference::TrustTier::Community);
14114 }
14115}
14116
14117async fn handle_models_unregister(
14129 req: &JsonRpcMessage,
14130 _state: &Arc<ServerState>,
14131) -> Result<Value, String> {
14132 let id = match req.params.get("id") {
14136 Some(v) => v
14137 .as_str()
14138 .ok_or_else(|| "`id` must be a string".to_string())?
14139 .to_string(),
14140 None => match req.params.as_str() {
14141 Some(s) => s.to_string(),
14142 None => return Err("missing `id` parameter".to_string()),
14143 },
14144 };
14145
14146 let path = car_inference::registry::user_config_path()
14149 .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14150
14151 if !path.exists() {
14152 return Ok(serde_json::json!({
14157 "id": id,
14158 "unregistered": false,
14159 "note": "no models.json — nothing to unregister (already absent)",
14160 }));
14161 }
14162 let text =
14163 std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14164 let mut models: Vec<car_inference::ModelSchema> = if text.trim().is_empty() {
14165 Vec::new()
14166 } else {
14167 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14168 };
14169 let before = models.len();
14170 models.retain(|m| m.id != id);
14171 if models.len() == before {
14172 return Ok(serde_json::json!({
14175 "id": id,
14176 "unregistered": false,
14177 "note": "model not in models.json — nothing to unregister",
14178 }));
14179 }
14180 let json =
14181 serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14182 let tmp = path.with_extension("json.tmp");
14183 std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14184 std::fs::rename(&tmp, &path)
14185 .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14186 Ok(serde_json::json!({
14187 "id": id,
14188 "unregistered": true,
14189 "path": path.to_string_lossy(),
14190 "note": "Daemon restart required for live UnifiedRegistry visibility \
14191 (phase 1, matching models.register).",
14192 }))
14193}
14194
14195fn handle_models_list(state: &ServerState) -> Result<Value, String> {
14196 let engine = get_inference_engine(state);
14197 let models = engine.list_models();
14198 serde_json::to_value(&models).map_err(|e| e.to_string())
14199}
14200
14201#[derive(Debug, Default, Deserialize)]
14202struct ModelsListUnifiedParams {
14203 #[serde(default)]
14204 refresh_openrouter_status: bool,
14205}
14206
14207async fn handle_models_list_unified(
14208 message: &JsonRpcMessage,
14209 state: &ServerState,
14210) -> Result<Value, String> {
14211 let params = if message.params.is_null() {
14212 ModelsListUnifiedParams::default()
14213 } else {
14214 typed_params(&message.params)?
14215 };
14216 if params.refresh_openrouter_status {
14217 let _ = car_inference::openrouter::refresh_credential_source_for_status().await;
14218 }
14219 let engine = get_inference_engine(state);
14220 let models = engine.list_models_unified();
14221 serde_json::to_value(&models).map_err(|e| e.to_string())
14222}
14223
14224#[cfg(test)]
14225mod models_list_unified_status_tests {
14226 use super::{handle_models_list_unified, JsonRpcMessage};
14227 use crate::session::{ServerState, ServerStateConfig};
14228 use serde_json::{json, Value};
14229 use std::sync::Arc;
14230
14231 #[tokio::test]
14232 async fn daemon_list_refreshes_openrouter_only_when_explicitly_requested() {
14233 const SENTINEL: &str = "CAR_SERVER_MODELS_LIST_STATUS_CHILD";
14234 if std::env::var_os(SENTINEL).is_none() {
14235 let status = std::process::Command::new(std::env::current_exe().unwrap())
14236 .arg("--exact")
14237 .arg(
14238 "handler::models_list_unified_status_tests::daemon_list_refreshes_openrouter_only_when_explicitly_requested",
14239 )
14240 .arg("--nocapture")
14241 .arg("--test-threads=1")
14242 .env(SENTINEL, "1")
14243 .env_remove(car_home::ENV_VAR)
14244 .env_remove("CAR_SECRETS_FILE_DIR")
14245 .env_remove(car_inference::openrouter::API_KEY_ENV)
14246 .status()
14247 .expect("spawn isolated daemon models-list status test");
14248 assert!(status.success(), "isolated daemon models-list test failed");
14249 return;
14250 }
14251
14252 let fixture = tempfile::TempDir::new().unwrap();
14253 unsafe {
14254 std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
14255 std::env::set_var(
14256 "CAR_SECRETS_FILE_DIR",
14257 fixture.path().join("isolated-secrets"),
14258 );
14259 std::env::remove_var(car_inference::openrouter::API_KEY_ENV);
14260 }
14261 car_secrets::SecretStore::new()
14262 .put(
14263 &car_secrets::SecretRef::with_default_service(
14264 car_inference::openrouter::API_KEY_ENV,
14265 ),
14266 "fixture-value",
14267 )
14268 .unwrap();
14269 let engine = Arc::new(car_inference::InferenceEngine::new(
14270 car_inference::InferenceConfig {
14271 state_root: fixture.path().join("state"),
14272 models_dir: fixture.path().join("models"),
14273 ..car_inference::InferenceConfig::default()
14274 },
14275 ));
14276 let state = ServerState::with_config(
14277 ServerStateConfig::new(fixture.path().join("journal")).with_inference(engine),
14278 );
14279 let request = |params| JsonRpcMessage {
14280 jsonrpc: "2.0".into(),
14281 method: Some("models.list_unified".into()),
14282 params,
14283 id: json!(1),
14284 result: None,
14285 error: None,
14286 };
14287
14288 assert_eq!(car_inference::openrouter::credential_source(), None);
14289 let before = car_secrets::secret_store_activity();
14290 let passive = handle_models_list_unified(&request(Value::Null), &state)
14291 .await
14292 .unwrap();
14293 let after_passive = car_secrets::secret_store_activity();
14294 assert_eq!(after_passive.status_attempts, before.status_attempts);
14295 assert_eq!(after_passive.get_attempts, before.get_attempts);
14296 let passive_openrouter = passive
14297 .as_array()
14298 .unwrap()
14299 .iter()
14300 .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14301 .collect::<Vec<_>>();
14302 assert!(!passive_openrouter.is_empty());
14303 assert!(passive_openrouter
14304 .iter()
14305 .all(|model| model["available"] == false));
14306
14307 let refreshed = handle_models_list_unified(
14308 &request(json!({ "refresh_openrouter_status": true })),
14309 &state,
14310 )
14311 .await
14312 .unwrap();
14313 let after_refresh = car_secrets::secret_store_activity();
14314 assert!(after_refresh.status_attempts > after_passive.status_attempts);
14315 assert_eq!(after_refresh.get_attempts, after_passive.get_attempts);
14316 let refreshed_openrouter = refreshed
14317 .as_array()
14318 .unwrap()
14319 .iter()
14320 .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14321 .collect::<Vec<_>>();
14322 assert!(!refreshed_openrouter.is_empty());
14323 assert!(refreshed_openrouter
14324 .iter()
14325 .all(|model| model["available"] == true));
14326 }
14327}
14328
14329fn handle_models_catalog_snapshot(
14330 session: &crate::session::ClientSession,
14331 state: &ServerState,
14332) -> Result<Value, String> {
14333 if !session_has_capability(session, car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY) {
14334 return Err(format!(
14335 "{} negotiate `{}` as a required or optional capability before calling `models.catalog_snapshot`",
14336 car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
14337 car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY,
14338 ));
14339 }
14340 let engine = get_inference_engine(state);
14341 let snapshot = engine.catalog_snapshot()?;
14342 serde_json::to_value(snapshot).map_err(|error| error.to_string())
14343}
14344
14345#[derive(Debug, Default, Deserialize)]
14346#[serde(deny_unknown_fields)]
14347struct EmptyModelManagementParams {}
14348
14349#[derive(Debug, Deserialize)]
14350#[serde(deny_unknown_fields)]
14351struct ModelPreflightParams {
14352 model_id: String,
14353 #[serde(default)]
14354 context_tokens: usize,
14355}
14356
14357#[derive(Debug, Deserialize)]
14358#[serde(deny_unknown_fields)]
14359struct ModelIdParams {
14360 model_id: String,
14361}
14362
14363#[derive(Debug, Deserialize)]
14364#[serde(deny_unknown_fields)]
14365struct ModelPullParams {
14366 #[serde(alias = "id", alias = "model")]
14367 name: String,
14368}
14369
14370fn strict_model_params<T: serde::de::DeserializeOwned>(
14371 message: &JsonRpcMessage,
14372) -> Result<T, String> {
14373 let params = if message.params.is_null() {
14374 serde_json::json!({})
14375 } else {
14376 message.params.clone()
14377 };
14378 serde_json::from_value(params)
14379 .map_err(|error| format!("invalid model-management params: {error}"))
14380}
14381
14382fn handle_models_resource_policy_get(
14383 message: &JsonRpcMessage,
14384 state: &ServerState,
14385) -> Result<Value, String> {
14386 let _: EmptyModelManagementParams = strict_model_params(message)?;
14387 let engine = get_inference_engine(state);
14388 let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14389 engine.config.state_root.clone(),
14390 );
14391 let evidence = repository
14392 .load_with_evidence()
14393 .map_err(|error| error.to_string())?;
14394 let hardware = car_inference::HardwareInfo::detect();
14395 let evaluated_budget = evidence.policy.effective_budget(hardware.total_ram_mb);
14396 Ok(serde_json::json!({
14397 "policy": evidence.policy,
14398 "evaluated_budget": evaluated_budget,
14399 "hardware_total_mb": hardware.total_ram_mb,
14400 "source": evidence.source,
14401 "warning": evidence.warning,
14402 }))
14403}
14404
14405fn handle_models_resource_policy_set(
14406 message: &JsonRpcMessage,
14407 session: &crate::session::ClientSession,
14408 state: &ServerState,
14409) -> Result<Value, String> {
14410 require_approval_authority(session, state)?;
14411 let policy: car_inference::resource_policy::ResourcePolicy = strict_model_params(message)?;
14412 policy.validate().map_err(|error| error.to_string())?;
14413 let engine = get_inference_engine(state);
14414 let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14415 engine.config.state_root.clone(),
14416 );
14417 car_inference::resource_policy::ResourcePolicyRepository::save(&repository, &policy)
14418 .map_err(|error| error.to_string())?;
14419 engine.apply_local_resource_policy(policy.clone());
14420 let hardware = car_inference::HardwareInfo::detect();
14421 let evaluated_budget = policy.effective_budget(hardware.total_ram_mb);
14422 Ok(serde_json::json!({
14423 "policy": policy,
14424 "evaluated_budget": evaluated_budget,
14425 "hardware_total_mb": hardware.total_ram_mb,
14426 "notice": evaluated_budget.normalization_notice,
14427 }))
14428}
14429
14430fn handle_models_preflight(message: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14431 let params: ModelPreflightParams = strict_model_params(message)?;
14432 let preflight = get_inference_engine(state)
14433 .local_model_preflight(¶ms.model_id, params.context_tokens)
14434 .map_err(|error| error.to_string())?;
14435 serde_json::to_value(preflight).map_err(|error| error.to_string())
14436}
14437
14438fn handle_models_storage_roots(
14439 message: &JsonRpcMessage,
14440 session: &crate::session::ClientSession,
14441 state: &ServerState,
14442) -> Result<Value, String> {
14443 require_approval_authority(session, state)?;
14444 let _: EmptyModelManagementParams = strict_model_params(message)?;
14445 let engine = get_inference_engine(state);
14446 let store = engine.model_management_store();
14447 let hf_home = std::env::var_os("HF_HOME")
14448 .map(std::path::PathBuf::from)
14449 .or_else(|| {
14450 std::env::var_os("HOME")
14451 .or_else(|| std::env::var_os("USERPROFILE"))
14452 .map(|home| std::path::PathBuf::from(home).join(".cache/huggingface"))
14453 })
14454 .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface"));
14455 let normalize = car_inference::resource_policy::normalized_state_root_key;
14456 Ok(serde_json::json!({
14457 "state_root": normalize(&engine.config.state_root),
14458 "models_dir": normalize(&engine.config.models_dir),
14459 "hf_home": normalize(&hf_home),
14460 "hf_hub": normalize(&hf_home.join("hub")),
14461 "install_receipts_dir": normalize(store.receipts_root()),
14462 "management_state_dir": normalize(store.management_state_dir()),
14463 }))
14464}
14465
14466async fn handle_models_remove(
14467 message: &JsonRpcMessage,
14468 session: &crate::session::ClientSession,
14469 state: &ServerState,
14470) -> Result<Value, String> {
14471 require_approval_authority(session, state)?;
14472 let params: ModelIdParams = strict_model_params(message)?;
14473 let engine = Arc::clone(get_inference_engine(state));
14474 let operation = state
14475 .spawn_durable_operation("models.remove", async move {
14476 let result = engine
14477 .remove_model_from_car(¶ms.model_id)
14478 .await
14479 .map_err(|error| error.to_string())?;
14480 Ok(serde_json::json!({
14481 "model_id": result.model_id,
14482 "removed_from_car": true,
14483 "artifact_kind": result.artifact_kind,
14484 "shared_cache_preserved": true,
14485 }))
14486 })
14487 .await;
14488 operation.await.map_err(|_| {
14489 "daemon-owned models.remove operation stopped before publishing a result".to_string()
14490 })?
14491}
14492
14493async fn handle_models_adopt(
14494 message: &JsonRpcMessage,
14495 session: &crate::session::ClientSession,
14496 state: &ServerState,
14497) -> Result<Value, String> {
14498 require_approval_authority(session, state)?;
14499 let params: ModelIdParams = strict_model_params(message)?;
14500 let engine = Arc::clone(get_inference_engine(state));
14501 let operation = state
14502 .spawn_durable_operation("models.adopt", async move {
14503 let receipt = engine
14504 .adopt_model_into_car(¶ms.model_id)
14505 .await
14506 .map_err(|error| error.to_string())?;
14507 Ok(serde_json::json!({
14508 "model_id": receipt.model_id,
14509 "adopted": true,
14510 "can_remove": true,
14511 }))
14512 })
14513 .await;
14514 operation.await.map_err(|_| {
14515 "daemon-owned models.adopt operation stopped before publishing a result".to_string()
14516 })?
14517}
14518
14519async fn handle_models_route_provenance(
14525 req: &JsonRpcMessage,
14526 state: &ServerState,
14527) -> Result<Value, String> {
14528 let id = require_str(&req.params, "id")?;
14529 let engine = state.inference.get().ok_or_else(|| {
14530 "models.route_provenance requires an initialized model registry; \
14531 call `models.list_unified` to initialize it, then retry"
14532 .to_string()
14533 })?;
14534 let schema = engine
14535 .registered_schema(id)
14536 .ok_or_else(|| format!("unknown model id `{id}`"))?;
14537 let canonical_id = canonical_managed_route_provenance_id(&schema)?;
14538
14539 let (endpoint, authority) = resolved_managed_parslee_base().await?;
14540 serde_json::to_value(ManagedRouteProvenance {
14541 id: canonical_id,
14542 provider: "parslee",
14543 source: "proprietary_oauth_pkce",
14544 is_local: false,
14545 endpoint,
14546 authority,
14547 })
14548 .map_err(|error| error.to_string())
14549}
14550
14551fn canonical_managed_route_provenance_id(
14555 schema: &car_inference::ModelSchema,
14556) -> Result<&str, String> {
14557 let Some(id) = car_inference::openrouter::canonical_managed_gateway_selector(schema) else {
14558 return Err(
14559 "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14560 .into(),
14561 );
14562 };
14563 Ok(id)
14564}
14565
14566#[derive(Debug, Serialize)]
14567struct ManagedRouteProvenance<'a> {
14568 id: &'a str,
14569 provider: &'static str,
14570 source: &'static str,
14571 is_local: bool,
14572 endpoint: String,
14573 authority: String,
14574}
14575
14576async fn resolved_managed_parslee_base() -> Result<(String, String), String> {
14583 let api_base = tokio::task::spawn_blocking(|| car_auth::api_base(None))
14584 .await
14585 .map_err(|error| format!("managed inference API base worker failed: {error}"))?;
14586 redact_resolved_managed_api_base(&api_base)
14587}
14588
14589fn redact_resolved_managed_api_base(api_base: &str) -> Result<(String, String), String> {
14590 let url = reqwest::Url::parse(api_base)
14591 .map_err(|_| "invalid managed inference API base".to_string())?;
14592 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
14593 return Err("invalid managed inference API base".into());
14594 }
14595
14596 let authority = url.origin().ascii_serialization();
14597 Ok((authority.clone(), authority))
14598}
14599
14600#[cfg(test)]
14601mod managed_route_provenance_tests {
14602 use super::{canonical_managed_route_provenance_id, redact_resolved_managed_api_base};
14603 use car_inference::openrouter::{
14604 canonical_managed_gateway_selector, curated_schemas, is_curated_managed_gateway_alias,
14605 };
14606
14607 #[test]
14608 fn managed_route_provenance_redacts_the_runtime_resolved_base_without_refresh() {
14609 assert_eq!(
14610 redact_resolved_managed_api_base("https://persisted.example/").unwrap(),
14611 (
14612 "https://persisted.example".into(),
14613 "https://persisted.example".into()
14614 )
14615 );
14616 }
14617
14618 #[test]
14619 fn managed_route_provenance_normalizes_and_redacts_url_components() {
14620 assert_eq!(
14621 redact_resolved_managed_api_base("HTTPS://Staging-Api.Example:443/").unwrap(),
14622 (
14623 "https://staging-api.example".into(),
14624 "https://staging-api.example".into()
14625 )
14626 );
14627 for (base, expected) in [
14628 ("https://gateway.example/prefix", "https://gateway.example"),
14629 (
14630 "https://user:sensitive@gateway.example/private?token=value#fragment",
14631 "https://gateway.example",
14632 ),
14633 ] {
14634 assert_eq!(
14635 redact_resolved_managed_api_base(base).unwrap(),
14636 (expected.into(), expected.into())
14637 );
14638 }
14639 for invalid in [
14640 "ftp://gateway.example",
14641 "mailto:ops@example.com",
14642 "https://",
14643 ] {
14644 let error = redact_resolved_managed_api_base(invalid).unwrap_err();
14645 assert_eq!(error, "invalid managed inference API base");
14646 }
14647 }
14648
14649 #[test]
14650 fn managed_route_provenance_accepts_only_canonical_managed_aliases() {
14651 assert!(is_curated_managed_gateway_alias(
14652 "parslee/openrouter/frontier-general"
14653 ));
14654 assert!(!is_curated_managed_gateway_alias(
14655 "openrouter/openai/gpt-5.4"
14656 ));
14657 assert!(!is_curated_managed_gateway_alias("parslee/not-managed"));
14658
14659 let schemas = curated_schemas();
14660 let managed = schemas
14661 .iter()
14662 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14663 .unwrap();
14664 assert_eq!(
14665 canonical_managed_gateway_selector(managed),
14666 Some("parslee/openrouter/frontier-general")
14667 );
14668
14669 let personal = schemas
14670 .iter()
14671 .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
14672 .unwrap();
14673 assert!(canonical_managed_gateway_selector(personal).is_none());
14674
14675 let mut non_parslee = managed.clone();
14676 non_parslee.provider = "other".into();
14677 assert!(canonical_managed_gateway_selector(&non_parslee).is_none());
14678
14679 let mut noncanonical_id = managed.clone();
14680 noncanonical_id.id = "parslee/not-managed".into();
14681 assert!(canonical_managed_gateway_selector(&noncanonical_id).is_none());
14682 }
14683
14684 #[test]
14685 fn managed_route_provenance_uses_live_signed_transport_contract() {
14686 let mut signed_override = curated_schemas()
14687 .into_iter()
14688 .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14689 .unwrap();
14690 signed_override.name = "signed-catalog-display-metadata-drift".into();
14691 assert_eq!(
14692 canonical_managed_route_provenance_id(&signed_override),
14693 Ok("parslee/openrouter/frontier-general")
14694 );
14695
14696 signed_override.provider = "not-parslee".into();
14697 assert_eq!(
14698 canonical_managed_route_provenance_id(&signed_override).unwrap_err(),
14699 "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14700 );
14701 }
14702}
14703
14704#[derive(Debug, Deserialize)]
14705#[serde(rename_all = "camelCase")]
14706struct ModelSearchParams {
14707 #[serde(default)]
14708 query: Option<String>,
14709 #[serde(default)]
14710 capability: Option<car_inference::ModelCapability>,
14711 #[serde(default)]
14712 provider: Option<String>,
14713 #[serde(default)]
14714 local_only: bool,
14715 #[serde(default)]
14716 available_only: bool,
14717 #[serde(default)]
14718 limit: Option<usize>,
14719}
14720
14721#[derive(Debug, Serialize)]
14722#[serde(rename_all = "camelCase")]
14723struct ModelSearchEntry {
14724 #[serde(flatten)]
14729 info: car_inference::ModelInfo,
14730 tags: Vec<String>,
14731 pullable: bool,
14732 upgrade: Option<car_inference::ModelUpgrade>,
14733}
14734
14735#[derive(Debug, Serialize)]
14736#[serde(rename_all = "camelCase")]
14737struct ModelSearchResponse {
14738 models: Vec<ModelSearchEntry>,
14739 upgrades: Vec<car_inference::ModelUpgrade>,
14740 total: usize,
14741 available: usize,
14742 local: usize,
14743 remote: usize,
14744}
14745
14746async fn handle_models_search(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14747 let params: ModelSearchParams =
14748 serde_json::from_value(req.params.clone()).unwrap_or(ModelSearchParams {
14749 query: None,
14750 capability: None,
14751 provider: None,
14752 local_only: false,
14753 available_only: false,
14754 limit: None,
14755 });
14756 let engine = get_inference_engine(state);
14757 let upgrades = engine.available_model_upgrades();
14758 let upgrades_by_from: HashMap<String, car_inference::ModelUpgrade> = upgrades
14759 .iter()
14760 .cloned()
14761 .map(|upgrade| (upgrade.from_id.clone(), upgrade))
14762 .collect();
14763 let query = params
14764 .query
14765 .as_deref()
14766 .map(str::trim)
14767 .filter(|q| !q.is_empty())
14768 .map(|q| q.to_ascii_lowercase());
14769 let provider = params
14770 .provider
14771 .as_deref()
14772 .map(str::trim)
14773 .filter(|p| !p.is_empty())
14774 .map(|p| p.to_ascii_lowercase());
14775
14776 let mut entries: Vec<ModelSearchEntry> = engine
14777 .list_schemas()
14778 .into_iter()
14779 .filter(|schema| {
14780 if let Some(capability) = params.capability {
14781 if !schema.has_capability(capability) {
14782 return false;
14783 }
14784 }
14785 if let Some(provider) = provider.as_deref() {
14786 if schema.provider.to_ascii_lowercase() != provider {
14787 return false;
14788 }
14789 }
14790 if params.local_only && !schema.is_local() {
14791 return false;
14792 }
14793 if params.available_only && !schema.available {
14794 return false;
14795 }
14796 if let Some(query) = query.as_deref() {
14797 let capability_text = schema
14798 .capabilities
14799 .iter()
14800 .map(|cap| format!("{cap:?}").to_ascii_lowercase())
14801 .collect::<Vec<_>>()
14802 .join(" ");
14803 let haystack = format!(
14804 "{} {} {} {} {} {}",
14805 schema.id,
14806 schema.name,
14807 schema.provider,
14808 schema.family,
14809 schema.tags.join(" "),
14810 capability_text
14811 )
14812 .to_ascii_lowercase();
14813 if !haystack.contains(query) {
14814 return false;
14815 }
14816 }
14817 true
14818 })
14819 .map(|schema| {
14820 let pullable = model_is_pullable(&schema);
14821 let mut info =
14822 car_inference::ModelInfo::from(&schema).with_fit(engine.model_fit(&schema));
14823 info.family = Some(schema.family);
14824 info.version = Some(schema.version);
14825 let upgrade = upgrades_by_from.get(&schema.id).cloned();
14826 ModelSearchEntry {
14827 info,
14828 tags: schema.tags,
14829 pullable,
14830 upgrade,
14831 }
14832 })
14833 .collect();
14834 entries.sort_by(|a, b| {
14835 b.info
14836 .available
14837 .cmp(&a.info.available)
14838 .then(b.info.is_local.cmp(&a.info.is_local))
14839 .then(a.info.name.cmp(&b.info.name))
14840 });
14841 if let Some(limit) = params.limit {
14842 entries.truncate(limit);
14843 }
14844
14845 let total = entries.len();
14846 let available = entries.iter().filter(|entry| entry.info.available).count();
14847 let local = entries.iter().filter(|entry| entry.info.is_local).count();
14848 let response = ModelSearchResponse {
14849 models: entries,
14850 upgrades,
14851 total,
14852 available,
14853 local,
14854 remote: total.saturating_sub(local),
14855 };
14856 serde_json::to_value(response).map_err(|e| e.to_string())
14857}
14858
14859fn model_is_pullable(schema: &car_inference::ModelSchema) -> bool {
14860 schema.downloads_weights() && !schema.weights_ready
14861}
14862
14863fn optional_enum_param<T: serde::de::DeserializeOwned>(
14867 req: &JsonRpcMessage,
14868 key: &str,
14869) -> Result<Option<T>, String> {
14870 match req.params.get(key) {
14871 None | Some(Value::Null) => Ok(None),
14872 Some(v) => serde_json::from_value(v.clone())
14873 .map(Some)
14874 .map_err(|_| format!("invalid '{key}': {v}")),
14875 }
14876}
14877
14878fn recommend_from_params(
14883 req: &JsonRpcMessage,
14884 engine: &car_inference::InferenceEngine,
14885) -> Result<car_inference::RecommendationSet, String> {
14886 let hw = car_inference::HardwareInfo::detect();
14887 recommend_from_params_with_hardware(req, engine, &hw)
14888}
14889
14890fn recommend_from_params_with_hardware(
14891 req: &JsonRpcMessage,
14892 engine: &car_inference::InferenceEngine,
14893 hw: &car_inference::HardwareInfo,
14894) -> Result<car_inference::RecommendationSet, String> {
14895 let schemas = engine.list_schemas();
14896 let policy = engine.active_local_resource_policy();
14897 recommend_from_params_with_policy(req, &schemas, hw, &policy.policy, policy.warning)
14898}
14899
14900#[cfg(test)]
14904fn recommend_from_params_with_inputs(
14905 req: &JsonRpcMessage,
14906 schemas: &[car_inference::ModelSchema],
14907 hw: &car_inference::HardwareInfo,
14908 policy_repository: &car_inference::FileResourcePolicyRepository,
14909) -> Result<car_inference::RecommendationSet, String> {
14910 let (policy, _, warning) = load_recommendation_policy(policy_repository);
14911 recommend_from_params_with_policy(req, schemas, hw, &policy, warning)
14912}
14913
14914#[cfg(test)]
14915fn load_recommendation_policy(
14916 policy_repository: &car_inference::FileResourcePolicyRepository,
14917) -> (
14918 car_inference::ResourcePolicy,
14919 car_inference::ResourcePolicyLoadSource,
14920 Option<String>,
14921) {
14922 match policy_repository.load_with_evidence() {
14923 Ok(evidence) => (evidence.policy, evidence.source, evidence.warning),
14924 Err(error) => (
14925 car_inference::ResourcePolicy::everyday(),
14926 car_inference::ResourcePolicyLoadSource::CorruptDefault,
14927 Some(format!(
14928 "The local-model resource policy could not be read ({error}); CAR used Everyday."
14929 )),
14930 ),
14931 }
14932}
14933
14934fn recommend_from_params_with_policy(
14935 req: &JsonRpcMessage,
14936 schemas: &[car_inference::ModelSchema],
14937 hw: &car_inference::HardwareInfo,
14938 policy: &car_inference::ResourcePolicy,
14939 warning: Option<String>,
14940) -> Result<car_inference::RecommendationSet, String> {
14941 let use_case =
14942 optional_enum_param::<car_inference::UseCase>(req, "use_case")?.unwrap_or_default();
14943 let tier = optional_enum_param::<car_inference::QualityTier>(req, "tier")?.unwrap_or_default();
14944 let privacy = if req
14945 .params
14946 .get("cloud_ok")
14947 .and_then(|v| v.as_bool())
14948 .unwrap_or(false)
14949 {
14950 car_inference::Privacy::CloudOk
14951 } else {
14952 car_inference::Privacy::OnDevice
14953 };
14954 let refs: Vec<&car_inference::ModelSchema> = schemas.iter().collect();
14955 let mut set = car_inference::recommend_with_policy(&refs, hw, policy, use_case, tier, privacy);
14956 if let Some(warning) = warning {
14957 set.note = Some(match set.note.take() {
14958 Some(note) => format!("Resource policy warning: {warning} {note}"),
14959 None => format!("Resource policy warning: {warning}"),
14960 });
14961 }
14962 Ok(set)
14963}
14964
14965fn handle_models_recommend(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14967 let engine = get_inference_engine(state);
14968 let set = recommend_from_params(req, engine)?;
14969 serde_json::to_value(set).map_err(|e| e.to_string())
14970}
14971
14972fn handle_models_setup_plan(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14975 let engine = get_inference_engine(state);
14976 let hw = car_inference::HardwareInfo::detect();
14977 let schemas = engine.list_schemas();
14978 let policy = engine.active_local_resource_policy();
14979 setup_plan_from_params_with_policy_inputs(
14980 req,
14981 &schemas,
14982 &hw,
14983 &policy.policy,
14984 policy.source,
14985 policy.warning,
14986 )
14987}
14988
14989#[cfg(test)]
14990fn setup_plan_from_params_with_inputs(
14991 req: &JsonRpcMessage,
14992 schemas: &[car_inference::ModelSchema],
14993 hw: &car_inference::HardwareInfo,
14994 policy_repository: &car_inference::FileResourcePolicyRepository,
14995) -> Result<Value, String> {
14996 let (policy, source, warning) = load_recommendation_policy(policy_repository);
14997 setup_plan_from_params_with_policy_inputs(req, schemas, hw, &policy, source, warning)
14998}
14999
15000fn setup_plan_from_params_with_policy_inputs(
15001 req: &JsonRpcMessage,
15002 schemas: &[car_inference::ModelSchema],
15003 hw: &car_inference::HardwareInfo,
15004 active_policy: &car_inference::ResourcePolicy,
15005 active_policy_source: car_inference::ResourcePolicyLoadSource,
15006 active_policy_warning: Option<String>,
15007) -> Result<Value, String> {
15008 let preview_policy = match req.params.get("resource_policy") {
15009 None => None,
15010 Some(value) => {
15011 let policy: car_inference::ResourcePolicy = serde_json::from_value(value.clone())
15012 .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15013 policy
15014 .validate()
15015 .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15016 Some(policy)
15017 }
15018 };
15019 let (policy, policy_source, warning) = match preview_policy {
15020 Some(policy) => (policy, serde_json::json!("preview"), None),
15021 None => (
15022 active_policy.clone(),
15023 serde_json::json!(active_policy_source),
15024 active_policy_warning,
15025 ),
15026 };
15027 let evaluated_budget = policy.effective_budget(hw.total_ram_mb);
15028 let mut set = recommend_from_params_with_policy(req, schemas, hw, &policy, warning)?;
15029 let (recommended, alternatives) = split_setup_recommendations(set.picks);
15030 if recommended.is_none() {
15031 let notice = "No model is within CAR's automatic recommendation target; heavier or unknown-memory alternatives require an explicit choice.";
15032 set.note = Some(match set.note.take() {
15033 Some(note) => format!("{note} {notice}"),
15034 None => notice.into(),
15035 });
15036 }
15037 serde_json::to_value(serde_json::json!({
15038 "machine": describe_machine_for_plan(hw),
15039 "recommended": recommended,
15040 "alternatives": alternatives,
15041 "needs_more_memory": set.not_enough_memory,
15042 "note": set.note,
15043 "resource_policy": policy,
15044 "evaluated_budget": evaluated_budget,
15045 "policy_source": policy_source,
15046 }))
15047 .map_err(|e| e.to_string())
15048}
15049
15050fn split_setup_recommendations(
15051 mut picks: Vec<car_inference::Recommendation>,
15052) -> (
15053 Option<car_inference::Recommendation>,
15054 Vec<car_inference::Recommendation>,
15055) {
15056 let recommended_index = picks
15057 .iter()
15058 .position(|pick| pick.within_recommendation_target);
15059 let recommended = recommended_index.map(|index| picks.remove(index));
15060 (recommended, picks)
15061}
15062
15063fn describe_machine_for_plan(hw: &car_inference::HardwareInfo) -> String {
15065 use car_inference::hardware::SupportedAcceleration::*;
15066 match hw.supported_acceleration() {
15067 Apple { unified_memory_mb } => format!(
15068 "Apple Silicon, {} GB unified memory (Metal)",
15069 unified_memory_mb / 1024
15070 ),
15071 Cuda { device_memory_mb } => match device_memory_mb {
15072 Some(mb) => format!("NVIDIA GPU, {} GB VRAM (CUDA)", mb / 1024),
15073 None => "NVIDIA GPU (CUDA)".to_string(),
15074 },
15075 UnsupportedDiscreteGpu { name, .. } => format!(
15076 "{} GB RAM, CPU inference ({name} not yet supported)",
15077 hw.total_ram_mb / 1024
15078 ),
15079 Cpu => format!("{} GB RAM, CPU inference", hw.total_ram_mb / 1024),
15080 }
15081}
15082
15083#[cfg(test)]
15084mod model_resource_policy_recommendation_tests {
15085 use super::{
15086 model_is_pullable, recommend_from_params_with_inputs, setup_plan_from_params_with_inputs,
15087 split_setup_recommendations, JsonRpcMessage,
15088 };
15089 use car_inference::hardware::GpuBackend;
15090 use car_inference::{
15091 FileResourcePolicyRepository, HardwareInfo, ModelSchema, ResourcePolicy,
15092 ResourcePolicyRepository,
15093 };
15094 use serde_json::json;
15095
15096 fn request() -> JsonRpcMessage {
15097 JsonRpcMessage {
15098 jsonrpc: "2.0".into(),
15099 method: Some("models.recommend".into()),
15100 params: json!({"use_case": "assistant", "tier": "balanced"}),
15101 id: json!(1),
15102 result: None,
15103 error: None,
15104 }
15105 }
15106
15107 fn mac_32gb() -> HardwareInfo {
15108 HardwareInfo {
15109 os: "macos".into(),
15110 arch: "aarch64".into(),
15111 cpu_cores: 10,
15112 total_ram_mb: 32 * 1024,
15113 gpu_backend: GpuBackend::Metal,
15114 gpu_memory_mb: None,
15115 gpu_devices: vec![],
15116 recommended_model: String::new(),
15117 recommended_context: 8_192,
15118 max_model_mb: 0,
15119 }
15120 }
15121
15122 fn setup_schemas(home: &tempfile::TempDir) -> Vec<ModelSchema> {
15123 let models_dir = home.path().join("weights");
15124 let huggingface_hub_root = home.path().join("huggingface-hub");
15125 std::fs::create_dir_all(&huggingface_hub_root).unwrap();
15126 car_inference::registry::builtin_catalog_with_huggingface_hub_for_testing(
15127 &models_dir,
15128 &huggingface_hub_root,
15129 )
15130 .into_iter()
15131 .filter(|model| matches!(model.id.as_str(), "mlx/qwen3-4b:4bit" | "mlx/qwen3-8b:4bit"))
15132 .collect()
15133 }
15134
15135 fn setup_request(params: serde_json::Value) -> JsonRpcMessage {
15136 JsonRpcMessage {
15137 jsonrpc: "2.0".into(),
15138 method: Some("models.setup_plan".into()),
15139 params,
15140 id: json!(1),
15141 result: None,
15142 error: None,
15143 }
15144 }
15145
15146 #[test]
15147 fn setup_plan_preview_custom_zero_changes_eligibility_without_persisting() {
15148 let home = tempfile::tempdir().unwrap();
15149 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15150 repository.save(&ResourcePolicy::everyday()).unwrap();
15151 let policy_path = repository.path();
15152 let saved_bytes = std::fs::read(&policy_path).unwrap();
15153 let schemas = setup_schemas(&home);
15154
15155 let preview = setup_plan_from_params_with_inputs(
15157 &setup_request(json!({
15158 "use_case": "assistant",
15159 "tier": "balanced",
15160 "resource_policy": {
15161 "profile": "custom",
15162 "custom_max_model_mb": 0
15163 }
15164 })),
15165 &schemas,
15166 &mac_32gb(),
15167 &repository,
15168 )
15169 .unwrap();
15170
15171 assert_eq!(preview["resource_policy"]["profile"], "custom");
15172 assert_eq!(preview["resource_policy"]["custom_max_model_mb"], 0);
15173 assert_eq!(
15174 preview["evaluated_budget"]["configured_model_ceiling_mb"],
15175 0
15176 );
15177 assert_eq!(
15178 preview["evaluated_budget"]["effective_new_load_ceiling_mb"],
15179 0
15180 );
15181 assert_eq!(preview["policy_source"], "preview");
15182 assert!(preview["recommended"].is_null());
15183 assert_eq!(preview["alternatives"].as_array().unwrap().len(), 0);
15184 assert_eq!(preview["needs_more_memory"].as_array().unwrap().len(), 2);
15185
15186 assert_eq!(std::fs::read(&policy_path).unwrap(), saved_bytes);
15187 let fresh_repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15188 assert_eq!(fresh_repository.load().unwrap(), ResourcePolicy::everyday());
15189
15190 let persisted = setup_plan_from_params_with_inputs(
15191 &setup_request(json!({"use_case":"assistant","tier":"balanced"})),
15192 &schemas,
15193 &mac_32gb(),
15194 &fresh_repository,
15195 )
15196 .unwrap();
15197 assert_eq!(persisted["policy_source"], "loaded");
15198 assert_eq!(persisted["resource_policy"]["profile"], "everyday");
15199 assert_eq!(persisted["recommended"]["model_id"], "mlx/qwen3-4b:4bit");
15200 assert!(persisted["alternatives"]
15201 .as_array()
15202 .unwrap()
15203 .iter()
15204 .any(|pick| pick["model_id"] == "mlx/qwen3-8b:4bit"));
15205 }
15206
15207 #[test]
15208 fn setup_plan_preview_preserves_exact_custom_ten_and_a_half_gb() {
15209 let home = tempfile::tempdir().unwrap();
15210 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15211 repository.save(&ResourcePolicy::everyday()).unwrap();
15212
15213 let plan = setup_plan_from_params_with_inputs(
15214 &setup_request(json!({
15215 "resource_policy": {
15216 "profile": "custom",
15217 "custom_max_model_mb": 10_752
15218 }
15219 })),
15220 &setup_schemas(&home),
15221 &mac_32gb(),
15222 &repository,
15223 )
15224 .unwrap();
15225
15226 assert_eq!(plan["resource_policy"]["custom_max_model_mb"], 10_752);
15227 assert_eq!(
15228 plan["evaluated_budget"]["configured_model_ceiling_mb"],
15229 10_752
15230 );
15231 assert_eq!(
15232 plan["evaluated_budget"]["effective_new_load_ceiling_mb"],
15233 10_752
15234 );
15235 assert_eq!(repository.load().unwrap(), ResourcePolicy::everyday());
15236 }
15237
15238 #[test]
15239 fn setup_plan_preview_rejects_invalid_policy_shapes() {
15240 let home = tempfile::tempdir().unwrap();
15241 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15242 let schemas = setup_schemas(&home);
15243 let invalid = [
15244 json!({"profile":"custom","custom_max_model_mb":10_752,"unknown":true}),
15245 json!({"profile":"custom"}),
15246 json!({"profile":"everyday","custom_max_model_mb":512}),
15247 json!({"profile":"custom","custom_max_model_mb":10_547}),
15248 json!({"profile":"custom","custom_max_model_mb":-512}),
15249 json!({"profile":"custom","custom_max_model_mb":10_752.0}),
15250 serde_json::Value::Null,
15251 json!("custom"),
15252 json!([]),
15253 json!(true),
15254 ];
15255
15256 for resource_policy in invalid {
15257 let error = setup_plan_from_params_with_inputs(
15258 &setup_request(json!({"resource_policy": resource_policy.clone()})),
15259 &schemas,
15260 &mac_32gb(),
15261 &repository,
15262 )
15263 .expect_err(&format!(
15264 "invalid preview must fail closed: {resource_policy}"
15265 ));
15266 assert!(
15267 error.contains("invalid 'resource_policy'"),
15268 "unexpected error for {resource_policy}: {error}"
15269 );
15270 }
15271 }
15272
15273 #[test]
15274 fn setup_plan_without_preview_keeps_corrupt_policy_warning_and_everyday_fallback() {
15275 let home = tempfile::tempdir().unwrap();
15276 std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15277 let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15278
15279 let plan = setup_plan_from_params_with_inputs(
15280 &setup_request(json!({})),
15281 &setup_schemas(&home),
15282 &mac_32gb(),
15283 &repository,
15284 )
15285 .unwrap();
15286
15287 assert_eq!(plan["policy_source"], "corrupt_default");
15288 assert_eq!(
15289 plan["resource_policy"],
15290 json!({
15291 "profile": "everyday",
15292 "custom_max_model_mb": null
15293 })
15294 );
15295 assert_eq!(
15296 plan["evaluated_budget"]["configured_model_ceiling_mb"],
15297 13_107
15298 );
15299 assert!(plan["note"]
15300 .as_str()
15301 .is_some_and(|note| note.contains("saved resource policy could not be loaded")));
15302 }
15303
15304 #[test]
15305 fn isolated_car_home_everyday_policy_recommends_real_4b_and_keeps_8b_heavier() {
15306 let home = tempfile::tempdir().unwrap();
15307 let schemas = setup_schemas(&home);
15308 assert_eq!(schemas.len(), 2, "real built-in 4B/8B rows must exist");
15309
15310 let set = recommend_from_params_with_inputs(
15311 &request(),
15312 &schemas,
15313 &mac_32gb(),
15314 &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15315 )
15316 .unwrap();
15317
15318 assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
15319 assert!(set.picks[0].within_recommendation_target);
15320 let eight = set
15321 .picks
15322 .iter()
15323 .find(|pick| pick.model_id == "mlx/qwen3-8b:4bit")
15324 .expect("8B remains visible as a heavier alternative");
15325 assert_eq!(eight.display_name, "Qwen3-8B-MLX");
15326 let mut over_target = eight.clone();
15327 over_target.within_recommendation_target = false;
15328 let (automatic, visible) = split_setup_recommendations(vec![over_target]);
15329 assert!(automatic.is_none());
15330 assert_eq!(visible.len(), 1);
15331
15332 let (recommended, alternatives) = split_setup_recommendations(set.picks);
15333 assert_eq!(recommended.unwrap().model_id, "mlx/qwen3-4b:4bit");
15334 assert!(alternatives
15335 .iter()
15336 .any(|pick| pick.model_id == "mlx/qwen3-8b:4bit"));
15337 }
15338
15339 #[test]
15340 fn corrupt_policy_is_visible_in_recommendation_notice() {
15341 let home = tempfile::tempdir().unwrap();
15342 std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15343 let schemas: Vec<ModelSchema> = setup_schemas(&home)
15344 .into_iter()
15345 .filter(|model| model.id == "mlx/qwen3-4b:4bit")
15346 .collect();
15347
15348 let set = recommend_from_params_with_inputs(
15349 &request(),
15350 &schemas,
15351 &mac_32gb(),
15352 &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15353 )
15354 .unwrap();
15355 assert!(
15356 set.note
15357 .as_deref()
15358 .is_some_and(|note| note.contains("saved resource policy could not be loaded")),
15359 "corrupt policy recovery must remain visible: {:?}",
15360 set.note
15361 );
15362 }
15363
15364 #[test]
15365 fn routable_mlx_model_without_weights_remains_pullable() {
15366 let home = tempfile::tempdir().unwrap();
15367 let mut schema = setup_schemas(&home)
15368 .into_iter()
15369 .find(|model| model.id == "mlx/qwen3-4b:4bit")
15370 .expect("built-in 4B MLX row from an explicit empty test hub");
15371 schema.available = true;
15375 schema.weights_ready = false;
15376
15377 assert!(schema.available, "the MLX runtime is routable on this host");
15378 assert!(
15379 !schema.weights_ready,
15380 "fresh test root has no model weights"
15381 );
15382 assert!(model_is_pullable(&schema));
15383 }
15384}
15385
15386fn handle_models_upgrades(state: &ServerState) -> Result<Value, String> {
15387 let engine = get_inference_engine(state);
15388 serde_json::to_value(serde_json::json!({
15389 "upgrades": engine.available_model_upgrades()
15390 }))
15391 .map_err(|e| e.to_string())
15392}
15393
15394async fn handle_models_detect_upgrades(state: &ServerState) -> Result<Value, String> {
15397 let engine = get_inference_engine(state);
15398 let findings = engine.detect_upgrades().await;
15399 serde_json::to_value(serde_json::json!({ "upgrades": findings })).map_err(|e| e.to_string())
15400}
15401
15402async fn handle_models_check_upgrade_nudge(
15406 req: &JsonRpcMessage,
15407 state: &ServerState,
15408) -> Result<Value, String> {
15409 let engine = get_inference_engine(state);
15410 let inference_active = req
15411 .params
15412 .get("inference_active")
15413 .and_then(|v| v.as_bool())
15414 .unwrap_or(false);
15415 let (decision, _state) = engine.check_upgrade_nudge(inference_active).await;
15416 serde_json::to_value(decision).map_err(|e| e.to_string())
15417}
15418
15419fn handle_models_dismiss_upgrade(
15421 req: &JsonRpcMessage,
15422 state: &ServerState,
15423) -> Result<Value, String> {
15424 let key = req
15425 .params
15426 .get("dismiss_key")
15427 .and_then(|v| v.as_str())
15428 .map(str::trim)
15429 .filter(|s| !s.is_empty())
15430 .ok_or("missing or empty 'dismiss_key' parameter")?;
15431 let engine = get_inference_engine(state);
15432 engine
15433 .dismiss_upgrade_nudge(key)
15434 .map_err(|e| e.to_string())?;
15435 Ok(serde_json::json!({ "dismissed": key }))
15436}
15437
15438async fn handle_models_check_concierge(
15442 req: &JsonRpcMessage,
15443 state: &ServerState,
15444) -> Result<Value, String> {
15445 let engine = get_inference_engine(state);
15446 let inference_active = req
15447 .params
15448 .get("inference_active")
15449 .and_then(|v| v.as_bool())
15450 .unwrap_or(false);
15451 let (suggestions, _state) = engine.check_concierge(inference_active).await;
15452 serde_json::to_value(serde_json::json!({ "suggestions": suggestions }))
15453 .map_err(|e| e.to_string())
15454}
15455
15456fn handle_models_dismiss_suggestion(
15459 req: &JsonRpcMessage,
15460 state: &ServerState,
15461) -> Result<Value, String> {
15462 let key = req
15463 .params
15464 .get("dismiss_key")
15465 .and_then(|v| v.as_str())
15466 .map(str::trim)
15467 .filter(|s| !s.is_empty())
15468 .ok_or("missing or empty 'dismiss_key' parameter")?;
15469 let engine = get_inference_engine(state);
15470 engine
15471 .dismiss_concierge_suggestion(key)
15472 .map_err(|e| e.to_string())?;
15473 Ok(serde_json::json!({ "dismissed": key }))
15474}
15475
15476async fn handle_concierge_status(
15480 req: &JsonRpcMessage,
15481 state: &ServerState,
15482) -> Result<Value, String> {
15483 let engine = get_inference_engine(state);
15484 let inference_active = req
15487 .params
15488 .get("inference_active")
15489 .and_then(|v| v.as_bool())
15490 .unwrap_or_else(|| state.admission.in_flight() > 0);
15491 let status = engine.concierge_status(inference_active).await;
15492 serde_json::to_value(status).map_err(|e| e.to_string())
15493}
15494
15495fn handle_concierge_dismiss(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15500 let key = req
15501 .params
15502 .get("dismiss_key")
15503 .and_then(|v| v.as_str())
15504 .map(str::trim)
15505 .filter(|s| !s.is_empty())
15506 .ok_or("missing or empty 'dismiss_key' parameter")?;
15507 let reason: car_inference::DismissReason = req
15508 .params
15509 .get("reason")
15510 .cloned()
15511 .map(serde_json::from_value)
15512 .transpose()
15513 .map_err(|e| format!("invalid 'reason': {e}"))?
15514 .unwrap_or(car_inference::DismissReason::NotNow);
15515 let engine = get_inference_engine(state);
15516 engine.dismiss_concierge_labeled(key, reason)?;
15517 Ok(serde_json::json!({ "dismissed": key, "reason": reason }))
15518}
15519
15520fn handle_concierge_defaults(state: &ServerState) -> Result<Value, String> {
15522 let engine = get_inference_engine(state);
15523 serde_json::to_value(engine.lane_defaults()).map_err(|e| e.to_string())
15524}
15525
15526async fn handle_concierge_set_default(
15530 req: &JsonRpcMessage,
15531 state: &ServerState,
15532) -> Result<Value, String> {
15533 let use_case: car_inference::UseCase = req
15534 .params
15535 .get("use_case")
15536 .cloned()
15537 .map(serde_json::from_value)
15538 .transpose()
15539 .map_err(|e| format!("invalid 'use_case': {e}"))?
15540 .ok_or("missing 'use_case'")?;
15541 let model_id = req
15542 .params
15543 .get("model_id")
15544 .and_then(|v| v.as_str())
15545 .map(str::trim)
15546 .filter(|s| !s.is_empty())
15547 .ok_or("missing or empty 'model_id'")?;
15548 let project = req
15549 .params
15550 .get("project")
15551 .and_then(|v| v.as_str())
15552 .map(str::to_string);
15553 let engine = get_inference_engine(state);
15554 engine
15555 .user_set_lane_default(use_case, model_id, project)
15556 .await?;
15557 Ok(serde_json::json!({ "set": model_id, "use_case": use_case }))
15558}
15559
15560async fn handle_concierge_clear_default(
15563 req: &JsonRpcMessage,
15564 state: &ServerState,
15565) -> Result<Value, String> {
15566 let use_case: car_inference::UseCase = req
15567 .params
15568 .get("use_case")
15569 .cloned()
15570 .map(serde_json::from_value)
15571 .transpose()
15572 .map_err(|e| format!("invalid 'use_case': {e}"))?
15573 .ok_or("missing 'use_case'")?;
15574 let project = req
15575 .params
15576 .get("project")
15577 .and_then(|v| v.as_str())
15578 .map(str::to_string);
15579 let engine = get_inference_engine(state);
15580 let removed = engine.user_clear_lane_default(use_case, project).await?;
15581 Ok(serde_json::json!({ "cleared": removed }))
15582}
15583
15584async fn handle_concierge_apply(
15589 req: &JsonRpcMessage,
15590 state: &ServerState,
15591) -> Result<Value, String> {
15592 let use_case: car_inference::UseCase = req
15593 .params
15594 .get("use_case")
15595 .cloned()
15596 .map(serde_json::from_value)
15597 .transpose()
15598 .map_err(|e| format!("invalid 'use_case': {e}"))?
15599 .ok_or("missing 'use_case'")?;
15600 let model_id = req
15601 .params
15602 .get("model_id")
15603 .and_then(|v| v.as_str())
15604 .map(str::trim)
15605 .filter(|s| !s.is_empty())
15606 .ok_or("missing or empty 'model_id'")?;
15607 let project = req
15608 .params
15609 .get("project")
15610 .and_then(|v| v.as_str())
15611 .map(str::to_string);
15612 let engine = get_inference_engine(state);
15613 let result = engine.apply_concierge(use_case, model_id, project).await?;
15614 serde_json::to_value(result).map_err(|e| e.to_string())
15615}
15616
15617async fn handle_concierge_rollback(
15620 req: &JsonRpcMessage,
15621 state: &ServerState,
15622) -> Result<Value, String> {
15623 let use_case: car_inference::UseCase = req
15624 .params
15625 .get("use_case")
15626 .cloned()
15627 .map(serde_json::from_value)
15628 .transpose()
15629 .map_err(|e| format!("invalid 'use_case': {e}"))?
15630 .ok_or("missing 'use_case'")?;
15631 let project = req
15632 .params
15633 .get("project")
15634 .and_then(|v| v.as_str())
15635 .map(str::to_string);
15636 let engine = get_inference_engine(state);
15637 let restored = engine.rollback_lane(use_case, project).await?;
15638 Ok(serde_json::json!({ "restored": restored }))
15639}
15640
15641fn handle_concierge_actions(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15644 let limit = req
15645 .params
15646 .get("limit")
15647 .and_then(|v| v.as_u64())
15648 .unwrap_or(50) as usize;
15649 let engine = get_inference_engine(state);
15650 serde_json::to_value(serde_json::json!({ "actions": engine.concierge_actions(limit) }))
15651 .map_err(|e| e.to_string())
15652}
15653
15654async fn handle_concierge_refresh_catalog(state: &ServerState) -> Result<Value, String> {
15658 let engine = get_inference_engine(state);
15659 let count = engine.refresh_catalog().await?;
15660 Ok(serde_json::json!({
15661 "refreshed": count,
15662 "note": "verified catalog cached; new models apply on next daemon restart"
15663 }))
15664}
15665
15666async fn handle_concierge_ask(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15671 let question = req
15672 .params
15673 .get("question")
15674 .and_then(|v| v.as_str())
15675 .map(str::trim)
15676 .filter(|s| !s.is_empty())
15677 .ok_or("missing or empty 'question'")?;
15678 let engine = get_inference_engine(state);
15679 let answer = engine.concierge_ask(question).await?;
15680 Ok(serde_json::json!({ "answer": answer }))
15681}
15682
15683fn handle_models_update_prefs_get(state: &ServerState) -> Result<Value, String> {
15685 let engine = get_inference_engine(state);
15686 serde_json::to_value(engine.update_prefs()).map_err(|e| e.to_string())
15687}
15688
15689fn handle_models_update_prefs_set(
15692 req: &JsonRpcMessage,
15693 state: &ServerState,
15694) -> Result<Value, String> {
15695 let prefs: car_inference::UpdatePreferences = serde_json::from_value(req.params.clone())
15696 .map_err(|e| format!("invalid preferences: {e}"))?;
15697 let engine = get_inference_engine(state);
15698 engine.set_update_prefs(&prefs).map_err(|e| e.to_string())?;
15699 serde_json::to_value(prefs).map_err(|e| e.to_string())
15700}
15701
15702pub async fn run_upgrade_nudge_check(state: &Arc<ServerState>) {
15708 let engine = get_inference_engine(state);
15709 let inference_active = state.admission.in_flight() > 0;
15712 let (decision, mut nstate) = engine.check_upgrade_nudge(inference_active).await;
15713 if let Some(nudge) = decision.nudge {
15714 let delivered = broadcast_upgrade_nudge(state, &nudge).await;
15715 if delivered > 0 {
15719 let now = std::time::SystemTime::now()
15720 .duration_since(std::time::UNIX_EPOCH)
15721 .map(|d| d.as_secs())
15722 .unwrap_or(0);
15723 nstate.last_nudge_secs = now;
15724 let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15725 }
15726 }
15727}
15728
15729pub async fn broadcast_upgrade_nudge(
15735 state: &Arc<ServerState>,
15736 nudge: &car_inference::UpgradeNudge,
15737) -> usize {
15738 use futures::SinkExt;
15739 use tokio_tungstenite::tungstenite::Message;
15740 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15741 .a2ui_subscribers
15742 .lock()
15743 .await
15744 .values()
15745 .cloned()
15746 .collect();
15747 if subscribers.is_empty() {
15748 return 0;
15749 }
15750 let Ok(json) = serde_json::to_string(&serde_json::json!({
15751 "jsonrpc": "2.0",
15752 "method": "models.upgrade_available",
15753 "params": nudge,
15754 })) else {
15755 return 0;
15756 };
15757 let mut delivered = 0;
15758 for channel in subscribers {
15759 if channel
15760 .write
15761 .lock()
15762 .await
15763 .send(Message::Text(json.clone().into()))
15764 .await
15765 .is_ok()
15766 {
15767 delivered += 1;
15768 }
15769 }
15770 delivered
15771}
15772
15773pub async fn run_concierge_check(state: &Arc<ServerState>) {
15779 let engine = get_inference_engine(state);
15780 let inference_active = state.admission.in_flight() > 0;
15781 let (suggestions, mut nstate) = engine.check_concierge(inference_active).await;
15782 if suggestions.is_empty() {
15783 return;
15784 }
15785 let mut any_delivered = false;
15786 for suggestion in &suggestions {
15787 if broadcast_concierge_suggestion(state, suggestion).await > 0 {
15788 any_delivered = true;
15789 }
15790 }
15791 if any_delivered {
15795 let now = std::time::SystemTime::now()
15796 .duration_since(std::time::UNIX_EPOCH)
15797 .map(|d| d.as_secs())
15798 .unwrap_or(0);
15799 nstate.last_concierge_secs = now;
15800 let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15801 }
15802}
15803
15804pub async fn run_idle_backend_eviction(state: &Arc<ServerState>) {
15816 let Some(engine) = state.inference.get() else {
15817 return;
15818 };
15819 let (entries, bytes) = engine.evict_idle_backends();
15820 if entries > 0 {
15821 tracing::info!(
15822 entries,
15823 mb = bytes / (1024 * 1024),
15824 "evicted idle model backends; resident model memory released"
15825 );
15826 }
15827 let stopped = engine.evict_idle_vllm_servers().await;
15831 if stopped > 0 {
15832 tracing::info!(stopped, "stopped idle vllm-mlx servers");
15833 }
15834}
15835
15836pub async fn broadcast_concierge_suggestion(
15840 state: &Arc<ServerState>,
15841 suggestion: &car_inference::ConciergeSuggestion,
15842) -> usize {
15843 use futures::SinkExt;
15844 use tokio_tungstenite::tungstenite::Message;
15845 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15846 .a2ui_subscribers
15847 .lock()
15848 .await
15849 .values()
15850 .cloned()
15851 .collect();
15852 if subscribers.is_empty() {
15853 return 0;
15854 }
15855 let Ok(json) = serde_json::to_string(&serde_json::json!({
15856 "jsonrpc": "2.0",
15857 "method": "models.suggestion_available",
15858 "params": suggestion,
15859 })) else {
15860 return 0;
15861 };
15862 let mut delivered = 0;
15863 for channel in subscribers {
15864 if channel
15865 .write
15866 .lock()
15867 .await
15868 .send(Message::Text(json.clone().into()))
15869 .await
15870 .is_ok()
15871 {
15872 delivered += 1;
15873 }
15874 }
15875 delivered
15876}
15877
15878struct PullProgressSink {
15882 tx: tokio::sync::mpsc::UnboundedSender<car_inference::DownloadEvent>,
15883}
15884
15885impl car_inference::DownloadProgress for PullProgressSink {
15886 fn on_event(&self, event: &car_inference::DownloadEvent) {
15887 let _ = self.tx.send(event.clone());
15889 }
15890}
15891
15892async fn handle_models_pull(
15893 msg: &JsonRpcMessage,
15894 session: &crate::session::ClientSession,
15895 state: &Arc<ServerState>,
15896) -> Result<Value, String> {
15897 require_approval_authority(session, state)?;
15898 let params: ModelPullParams = strict_model_params(msg)?;
15899 let name = params.name;
15900 if name.is_empty() || name.trim() != name {
15901 return Err(
15902 "invalid model-management params: model id must be non-empty exact UTF-8 without surrounding whitespace"
15903 .into(),
15904 );
15905 }
15906 let engine = Arc::clone(get_inference_engine(state));
15907 let operation_state = Arc::clone(state);
15908 let operation = state
15909 .spawn_durable_operation("models.pull", async move {
15910 run_models_pull(name, engine, operation_state).await
15911 })
15912 .await;
15913 operation.await.map_err(|_| {
15914 "daemon-owned models.pull operation stopped before publishing a result".to_string()
15915 })?
15916}
15917
15918async fn run_models_pull(
15919 name: String,
15920 engine: Arc<car_inference::InferenceEngine>,
15921 state: Arc<ServerState>,
15922) -> Result<Value, String> {
15923 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<car_inference::DownloadEvent>();
15929 let sink = car_inference::ProgressSink::new(Arc::new(PullProgressSink { tx }));
15930 let broadcaster_state = state.clone();
15931 let model_label = name.clone();
15932 let broadcaster = tokio::spawn(async move {
15933 while let Some(event) = rx.recv().await {
15934 broadcast_pull_progress(&broadcaster_state, &model_label, &event).await;
15935 }
15936 });
15937
15938 let result = engine.pull_model_with_progress(&name, &sink).await;
15939 drop(sink);
15941 if let Err(e) = broadcaster.await {
15943 tracing::warn!(error = %e, "pull-progress broadcaster task failed");
15944 }
15945
15946 let path = result.map_err(|e| e.to_string())?;
15947 Ok(serde_json::json!({"path": path.display().to_string()}))
15948}
15949
15950async fn broadcast_pull_progress(
15953 state: &Arc<ServerState>,
15954 model: &str,
15955 event: &car_inference::DownloadEvent,
15956) {
15957 use futures::SinkExt;
15958 use tokio_tungstenite::tungstenite::Message;
15959 let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15960 .a2ui_subscribers
15961 .lock()
15962 .await
15963 .values()
15964 .cloned()
15965 .collect();
15966 if subscribers.is_empty() {
15967 return;
15968 }
15969 let Ok(json) = serde_json::to_string(&serde_json::json!({
15970 "jsonrpc": "2.0",
15971 "method": "models.pull_progress",
15972 "params": { "model": model, "event": event },
15973 })) else {
15974 return;
15975 };
15976 for channel in subscribers {
15977 let _ = channel
15978 .write
15979 .lock()
15980 .await
15981 .send(Message::Text(json.clone().into()))
15982 .await;
15983 }
15984}
15985
15986async fn handle_skills_distill(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15987 let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
15988 msg.params
15989 .get("events")
15990 .cloned()
15991 .unwrap_or(msg.params.clone()),
15992 )
15993 .map_err(|e| format!("invalid events: {}", e))?;
15994
15995 let inference = get_inference_engine(state).clone();
15996 let engine = car_memgine::MemgineEngine::new(None).with_inference(inference);
15997
15998 let skills = engine.distill_skills(&events).await;
15999 serde_json::to_value(&skills).map_err(|e| e.to_string())
16000}
16001
16002async fn handle_memory_consolidate(
16006 session: &crate::session::ClientSession,
16007) -> Result<Value, String> {
16008 let engine_arc = session.effective_memgine().await;
16009 let report = {
16010 let mut engine = engine_arc.lock().await;
16011 engine.consolidate().await
16012 };
16013 if let Some(id) = session.agent_id.lock().await.clone() {
16014 if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
16015 tracing::warn!(agent_id = %id, error = %e,
16016 "agent memgine persist after consolidate failed");
16017 }
16018 }
16019 serde_json::to_value(&report).map_err(|e| e.to_string())
16020}
16021
16022async fn handle_memory_utility_get(
16037 session: &crate::session::ClientSession,
16038) -> Result<Value, String> {
16039 let engine = session.memgine.lock().await;
16040 let (weight, exploration) = engine.utility_retrieval();
16041 Ok(serde_json::json!({
16042 "utility_weight": weight,
16043 "utility_exploration": exploration,
16044 }))
16045}
16046
16047async fn handle_memory_utility_set(
16064 msg: &JsonRpcMessage,
16065 session: &crate::session::ClientSession,
16066) -> Result<Value, String> {
16067 let (weight, exploration) = {
16068 let mut engine = session.memgine.lock().await;
16069 let (cur_weight, cur_exploration) = engine.utility_retrieval();
16073 let weight = msg
16074 .params
16075 .get("utility_weight")
16076 .and_then(|v| v.as_f64())
16077 .unwrap_or(cur_weight);
16078 let exploration = msg
16079 .params
16080 .get("utility_exploration")
16081 .and_then(|v| v.as_f64())
16082 .unwrap_or(cur_exploration);
16083 engine.set_utility_retrieval(weight, exploration);
16084 engine.utility_retrieval()
16085 };
16086 Ok(serde_json::json!({
16087 "utility_weight": weight,
16088 "utility_exploration": exploration,
16089 }))
16090}
16091
16092async fn handle_cascade_run(
16123 req: &JsonRpcMessage,
16124 session: &crate::session::ClientSession,
16125 state: &ServerState,
16126) -> Result<Value, String> {
16127 use car_memgine::cascade::{run_cascade_async, CascadePolicy, CascadeTier, TierResult};
16128
16129 #[derive(serde::Deserialize)]
16130 struct ObservedTier {
16131 confidence: f64,
16132 #[serde(default)]
16133 knowledge: Option<String>,
16134 }
16135
16136 let current_confidence = req
16137 .params
16138 .get("current_confidence")
16139 .and_then(|v| v.as_f64())
16140 .unwrap_or(0.0);
16141 let policy_val = req.params.get("policy").ok_or("missing 'policy'")?;
16142 let policy: CascadePolicy =
16143 serde_json::from_value(policy_val.clone()).map_err(|e| format!("invalid 'policy': {e}"))?;
16144 let observed: std::collections::HashMap<CascadeTier, ObservedTier> = req
16145 .params
16146 .get("observed")
16147 .map(|v| serde_json::from_value(v.clone()))
16148 .transpose()
16149 .map_err(|e| format!("invalid 'observed': {e}"))?
16150 .unwrap_or_default();
16151 let claim = req
16152 .params
16153 .get("claim")
16154 .and_then(|v| v.as_str())
16155 .unwrap_or("")
16156 .to_string();
16157 let dry_run = req
16162 .params
16163 .get("dry_run")
16164 .and_then(|v| v.as_bool())
16165 .unwrap_or(false);
16166 let fingerprint = format!("cascade:{}", claim.trim());
16167
16168 let has_human_tier = policy
16173 .tiers
16174 .iter()
16175 .any(|t| t.tier == CascadeTier::HumanExpert);
16176 if has_human_tier && claim.trim().is_empty() {
16177 return Err(
16178 "a 'human_expert' tier requires a non-empty 'claim' (it keys the durable approval)"
16179 .into(),
16180 );
16181 }
16182
16183 let engine_arc = session.effective_memgine().await;
16187
16188 let observed_ref = &observed;
16189 let fp_ref = fingerprint.as_str();
16190 let run = move |tier: CascadeTier| {
16191 let engine_arc = engine_arc.clone();
16192 async move {
16193 let obs = observed_ref.get(&tier);
16194 let caller_confidence = obs.map(|o| o.confidence).unwrap_or(0.0);
16195 let caller_knowledge = obs.and_then(|o| o.knowledge.clone());
16196 let (knowledge, confidence) = match tier {
16202 CascadeTier::SelfReflect => {
16203 let knowledge = if dry_run {
16204 caller_knowledge
16205 .unwrap_or_else(|| "self_reflect: dry_run (no reflect)".to_string())
16206 } else {
16207 let report = {
16209 let mut engine = engine_arc.lock().await;
16210 engine.reflect().await
16211 };
16212 caller_knowledge.unwrap_or_else(|| {
16213 format!(
16214 "self_reflect: {} insight(s) ingested ({} corrections, {} preferences, {} friction)",
16215 report.insights_ingested,
16216 report.corrections_found,
16217 report.preferences_found,
16218 report.friction_points_found,
16219 )
16220 })
16221 };
16222 (knowledge, caller_confidence)
16223 }
16224 CascadeTier::ToolVerify => {
16225 let knowledge = caller_knowledge
16226 .unwrap_or_else(|| "tool_verify: caller-supplied".to_string());
16227 (knowledge, caller_confidence)
16228 }
16229 CascadeTier::HumanExpert => {
16230 let decision = {
16233 let ledger = state.approval_ledger.read().await;
16234 ledger.lookup(fp_ref).map(|r| r.decision)
16235 };
16236 match decision {
16237 Some(car_policy::ApprovalDecision::Rejected) => {
16239 return Err(format!("human_expert rejected: {fp_ref}"));
16240 }
16241 Some(car_policy::ApprovalDecision::Approved) => {
16245 let knowledge = caller_knowledge
16246 .unwrap_or_else(|| format!("human_expert: approved ({fp_ref})"));
16247 (knowledge, caller_confidence)
16248 }
16249 None => {
16254 let knowledge = caller_knowledge.unwrap_or_else(|| {
16255 format!("human_expert: awaiting approval ({fp_ref})")
16256 });
16257 (knowledge, 0.0)
16258 }
16259 }
16260 }
16261 };
16262 Ok::<TierResult, String>(TierResult {
16263 knowledge,
16264 confidence,
16265 })
16266 }
16267 };
16268
16269 let run_result = run_cascade_async(current_confidence, &policy, run).await?;
16270
16271 let human_ran = run_result
16275 .steps
16276 .iter()
16277 .any(|s| s.tier == CascadeTier::HumanExpert);
16278 let pending = if human_ran {
16279 state
16280 .approval_ledger
16281 .read()
16282 .await
16283 .lookup(&fingerprint)
16284 .is_none()
16285 } else {
16286 false
16287 };
16288
16289 let mut resp = serde_json::json!({ "run": run_result });
16290 if pending {
16291 resp.as_object_mut().unwrap().insert(
16292 "pending_approval".into(),
16293 serde_json::json!({ "fingerprint": fingerprint, "claim": claim }),
16294 );
16295 }
16296 Ok(resp)
16297}
16298
16299async fn assemble_evolution_components(
16314 session: &crate::session::ClientSession,
16315 state: &Arc<ServerState>,
16316) -> Vec<car_memgine::self_evolution::ComponentState> {
16317 let mut components = {
16321 let engine_arc = session.effective_memgine().await;
16322 let engine = engine_arc.lock().await;
16323 engine.evolution_component_states()
16324 };
16325 {
16326 let log = session.runtime.log.lock().await;
16327 if let Some(h) = crate::evolution::harness_component_from_events(log.events()) {
16328 components.push(h);
16329 }
16330 }
16331 state.ensure_connectors_loaded().await;
16332 let connectors = state.connectors().list().await;
16333 if let Some(t) = crate::evolution::tools_component_from_connectors(&connectors) {
16334 components.push(t);
16335 }
16336 components
16337}
16338
16339async fn handle_selfheal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16343 serde_json::to_value(state.selfheal.status().await).map_err(|error| error.to_string())
16344}
16345
16346async fn handle_selfheal_detections(
16350 req: &JsonRpcMessage,
16351 state: &Arc<ServerState>,
16352) -> Result<Value, String> {
16353 let query = serde_json::from_value::<crate::selfheal::DetectionQuery>(req.params.clone())
16354 .map_err(|error| format!("invalid selfheal.detections params: {error}"))?;
16355 serde_json::to_value(state.selfheal.detections(query).await).map_err(|error| error.to_string())
16356}
16357
16358async fn handle_selfheal_dismiss(
16362 req: &JsonRpcMessage,
16363 state: &Arc<ServerState>,
16364) -> Result<Value, String> {
16365 let key = req
16366 .params
16367 .get("dedup_key")
16368 .and_then(Value::as_str)
16369 .ok_or_else(|| "selfheal.dismiss requires string param 'dedup_key'".to_string())?;
16370 serde_json::to_value(state.selfheal.dismiss(key).await?).map_err(|error| error.to_string())
16371}
16372
16373async fn handle_selfheal_fix(
16375 req: &JsonRpcMessage,
16376 state: &Arc<ServerState>,
16377) -> Result<Value, String> {
16378 let key = req
16379 .params
16380 .get("dedup_key")
16381 .and_then(Value::as_str)
16382 .ok_or_else(|| "selfheal.fix requires string param 'dedup_key'".to_string())?;
16383 serde_json::to_value(state.selfheal.fix(key).await?).map_err(|error| error.to_string())
16384}
16385
16386async fn handle_heal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16392 serde_json::to_value(state.heal.status(get_inference_engine(state)))
16393 .map_err(|error| error.to_string())
16394}
16395
16396async fn handle_heal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16402 let owned = state.clone();
16410 let handle = tokio::spawn(async move { owned.heal.run_tick(&owned).await });
16411 match handle.await {
16412 Ok(result) => serde_json::to_value(result?).map_err(|error| error.to_string()),
16413 Err(e) => Err(format!("heal sweep task failed: {e}")),
16414 }
16415}
16416
16417async fn handle_selfheal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16422 serde_json::to_value(state.selfheal.run_tick(state).await?).map_err(|error| error.to_string())
16423}
16424
16425async fn handle_evolution_plan(
16433 req: &JsonRpcMessage,
16434 session: &crate::session::ClientSession,
16435 state: &Arc<ServerState>,
16436) -> Result<Value, String> {
16437 let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16438 req.params
16439 .get("policy")
16440 .cloned()
16441 .unwrap_or_else(|| serde_json::json!({})),
16442 )
16443 .map_err(|e| format!("invalid policy: {e}"))?;
16444 let components = assemble_evolution_components(session, state).await;
16445 let plan = car_memgine::self_evolution::plan_evolution(&components, &policy);
16446 serde_json::to_value(plan).map_err(|e| e.to_string())
16447}
16448
16449enum CandidateSource {
16569 Supplied(car_eventlog::harness_metrics::HarnessMetrics),
16571 Measured(car_eventlog::harness_metrics::HarnessMetrics),
16573 Failed(String),
16576 Unavailable(String),
16579}
16580
16581async fn handle_evolution_run(
16582 req: &JsonRpcMessage,
16583 session: &crate::session::ClientSession,
16584 state: &Arc<ServerState>,
16585) -> Result<Value, String> {
16586 use car_memgine::harness_evolution::{
16587 mutation_fingerprint, EvolutionAgent, Governance, PromotionDecision,
16588 };
16589 use car_memgine::self_evolution::{EvolutionOutcome, EvolvableComponent};
16590
16591 let _cycle_token = session
16596 .evolution_guard
16597 .try_begin()
16598 .ok_or("evolution.run already in flight on this session")?;
16599
16600 let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16601 req.params
16602 .get("policy")
16603 .cloned()
16604 .unwrap_or_else(|| serde_json::json!({})),
16605 )
16606 .map_err(|e| format!("invalid policy: {e}"))?;
16607 let dry_run = req
16608 .params
16609 .get("dry_run")
16610 .and_then(|v| v.as_bool())
16611 .unwrap_or(false);
16612 let baseline_override: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16613 match req.params.get("harness_baseline_metrics") {
16614 Some(v) if !v.is_null() => Some(
16615 serde_json::from_value(v.clone())
16616 .map_err(|e| format!("invalid harness_baseline_metrics: {e}"))?,
16617 ),
16618 _ => None,
16619 };
16620 let candidate_metrics: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16621 match req.params.get("harness_candidate_metrics") {
16622 Some(v) if !v.is_null() => Some(
16623 serde_json::from_value(v.clone())
16624 .map_err(|e| format!("invalid harness_candidate_metrics: {e}"))?,
16625 ),
16626 _ => None,
16627 };
16628
16629 let measure_request = crate::evolution::parse_harness_measure_request(&req.params)?;
16632
16633 if measure_request.is_some() && (baseline_override.is_some() || candidate_metrics.is_some()) {
16637 let supplied = match (baseline_override.is_some(), candidate_metrics.is_some()) {
16638 (true, true) => "harness_baseline_metrics and harness_candidate_metrics",
16639 (true, false) => "harness_baseline_metrics",
16640 _ => "harness_candidate_metrics",
16641 };
16642 return Err(format!(
16643 "harness_measure cannot be combined with {supplied}: harness_measure makes the \
16644 daemon measure the baseline and every candidate itself, so supplied metrics would \
16645 have to be either ignored or silently preferred. Send one or the other."
16646 ));
16647 }
16648
16649 let measuring = measure_request.is_some() && !dry_run;
16652
16653 let measurer = if measuring {
16657 Some(state.harness_measurer().ok_or(
16658 "harness_measure requested but this daemon build has no in-process harness \
16659 evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16660 startup; embedders must call ServerState::set_harness_measurer)",
16661 )?)
16662 } else {
16663 None
16664 };
16665
16666 let context_measure_request = crate::evolution::parse_context_measure_request(&req.params)?;
16686 let context_measurer = match context_measure_request.as_ref() {
16687 Some(_) => Some(state.harness_measurer().ok_or(
16688 "context_measure requested but this daemon build has no in-process harness \
16689 evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16690 startup; embedders must call ServerState::set_harness_measurer)",
16691 )?),
16692 None => None,
16693 };
16694
16695 let live_harness_config = session.runtime.harness_config().await;
16701
16702 let mut baseline_measurement_error: Option<String> = None;
16706 let mut measured_baseline: Option<car_eventlog::harness_metrics::HarnessMetrics> = None;
16707 if let (Some(m), Some(rq)) = (measurer.as_ref(), measure_request.as_ref()) {
16708 match crate::evolution::measure_baseline(m.as_ref(), rq, live_harness_config.as_ref()).await
16709 {
16710 Ok(metrics) => measured_baseline = Some(metrics),
16711 Err(e) => baseline_measurement_error = Some(e),
16712 }
16713 }
16714
16715 let mut components = assemble_evolution_components(session, state).await;
16716 let engine_arc = session.effective_memgine().await;
16719
16720 let (failed_events, live_metrics) = {
16722 let log = session.runtime.log.lock().await;
16723 (
16724 crate::evolution::failed_trace_events(log.events()),
16725 car_eventlog::harness_metrics::compute_harness_metrics(log.events()),
16726 )
16727 };
16728 let has_baseline_override = baseline_override.is_some() || measured_baseline.is_some();
16732 let baseline_metrics = measured_baseline
16733 .clone()
16734 .or(baseline_override)
16735 .unwrap_or(live_metrics);
16736 if has_baseline_override {
16741 components
16742 .retain(|c| c.component != car_memgine::self_evolution::EvolvableComponent::Harness);
16743 if let Some(h) = crate::evolution::harness_component_from_metrics(&baseline_metrics) {
16744 components.push(h);
16745 }
16746 }
16747 let harness_mutations = EvolutionAgent::new().diagnose(&baseline_metrics);
16748
16749 let measurement_summary: Option<Value> = measure_request.as_ref().map(|rq| {
16759 let mut o = serde_json::json!({
16760 "split": rq.split,
16761 "model": rq.model,
16762 "split_seed": rq.split_seed,
16763 });
16764 let obj = o.as_object_mut().expect("object literal");
16765 if dry_run {
16766 obj.insert("status".into(), Value::from("skipped_dry_run"));
16767 } else if let Some(b) = &measured_baseline {
16768 obj.insert("status".into(), Value::from("measured"));
16769 obj.insert(
16770 "baseline_metrics".into(),
16771 serde_json::to_value(b).unwrap_or(Value::Null),
16772 );
16773 } else {
16774 obj.insert("status".into(), Value::from("measurement_failed"));
16775 obj.insert(
16776 "error".into(),
16777 baseline_measurement_error
16778 .clone()
16779 .map(Value::from)
16780 .unwrap_or(Value::Null),
16781 );
16782 }
16783 o
16784 });
16785
16786 let pending_approvals: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
16787 let failed_events = &failed_events;
16788 let harness_mutations = &harness_mutations;
16789 let baseline_metrics = &baseline_metrics;
16790 let candidate_metrics = &candidate_metrics;
16791 let pending_ref = &pending_approvals;
16792 let engine_ref = &engine_arc;
16793 let measurer_ref = &measurer;
16794 let measure_request_ref = &measure_request;
16795 let context_measurer_ref = &context_measurer;
16796 let context_measure_request_ref = &context_measure_request;
16797 let base_harness_config = live_harness_config.clone().unwrap_or_default();
16801 let base_harness_config = &base_harness_config;
16802 let baseline_measurement_error = &baseline_measurement_error;
16803 let measurement_summary = &measurement_summary;
16804
16805 let run = |component: EvolvableComponent| {
16806 let engine = engine_ref.clone();
16807 async move {
16808 match component {
16809 EvolvableComponent::Memory => {
16810 crate::evolution::run_memory_evolution(&engine, dry_run).await
16811 }
16812 EvolvableComponent::Skills => {
16813 crate::evolution::run_skills_evolution(&engine, failed_events, dry_run).await
16814 }
16815 EvolvableComponent::Harness => {
16816 if let Some(e) = baseline_measurement_error {
16820 return Err(e.clone());
16821 }
16822 if harness_mutations.is_empty() {
16823 return Ok(EvolutionOutcome::no_op(
16824 "no harness mutations diagnosed from session telemetry",
16825 ));
16826 }
16827 let agent = EvolutionAgent::new();
16828 let mut details: Vec<Value> = Vec::new();
16829 let mut applied = 0usize;
16830 let mut pending = 0usize;
16831 for m in harness_mutations {
16832 let fingerprint = mutation_fingerprint(m);
16837 let prior = {
16841 let ledger = state.approval_ledger.read().await;
16842 ledger.lookup(&fingerprint).map(|r| r.decision)
16843 };
16844 let status: Value = match prior {
16851 Some(car_policy::ApprovalDecision::Rejected) => {
16852 serde_json::json!({ "status": "rejected_by_operator" })
16853 }
16854 Some(car_policy::ApprovalDecision::Approved) => {
16855 if m.patch.is_none() {
16856 serde_json::json!({
16857 "status": "approved_no_patch",
16858 "note": "approved but carries no concrete config patch — a human designs this change",
16859 })
16860 } else if dry_run {
16861 serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
16862 } else {
16863 match session
16866 .runtime
16867 .update_harness_config(|cfg| {
16868 cfg.apply(m, Governance::HumanApproved)
16869 })
16870 .await
16871 {
16872 Ok(inverse) => {
16873 applied += 1;
16874 serde_json::json!({
16875 "status": "applied",
16876 "governance": "human_approved",
16877 "rollback_patch": inverse,
16878 })
16879 }
16880 Err(e) => serde_json::json!({
16881 "status": "apply_failed", "error": e,
16882 }),
16883 }
16884 }
16885 }
16886 None => {
16887 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";
16892 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";
16893 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";
16894 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";
16895
16896 let source = if let Some(cand) = candidate_metrics {
16897 CandidateSource::Supplied(cand.clone())
16898 } else if let (Some(measurer), Some(rq)) =
16899 (measurer_ref.as_ref(), measure_request_ref.as_ref())
16900 {
16901 match m.patch.as_ref().filter(|p| !p.is_empty()) {
16902 None => CandidateSource::Unavailable(
16903 NO_PATCH_REASON.to_string(),
16904 ),
16905 Some(_) if m.requires_human_approval() => {
16906 CandidateSource::Unavailable(
16907 SAFETY_SKIP_REASON.to_string(),
16908 )
16909 }
16910 Some(patch) => {
16911 match crate::evolution::measure_candidate(
16912 measurer.as_ref(),
16913 rq,
16914 base_harness_config,
16915 patch,
16916 )
16917 .await
16918 {
16919 Ok(metrics) => CandidateSource::Measured(metrics),
16920 Err(e) => CandidateSource::Failed(e),
16921 }
16922 }
16923 }
16924 } else if measure_request_ref.is_some() {
16925 CandidateSource::Unavailable(DRY_RUN_REASON.to_string())
16926 } else {
16927 CandidateSource::Unavailable(NO_CANDIDATE_REASON.to_string())
16928 };
16929
16930 let measured_here = matches!(source, CandidateSource::Measured(_));
16934
16935 match source {
16936 CandidateSource::Supplied(ref cand)
16937 | CandidateSource::Measured(ref cand) => {
16938 let decision = agent.evaluate(m, baseline_metrics, cand);
16939 let mut status = match &decision {
16940 PromotionDecision::Promote { reason } => {
16941 let reason = reason.clone();
16942 if dry_run {
16943 serde_json::json!({ "status": "would_apply", "governance": "promoted", "reason": reason })
16944 } else {
16945 match session
16946 .runtime
16947 .update_harness_config(|cfg| {
16948 cfg.apply(
16949 m,
16950 Governance::Promoted(
16951 decision.clone(),
16952 ),
16953 )
16954 })
16955 .await
16956 {
16957 Ok(inverse) => {
16958 applied += 1;
16959 serde_json::json!({
16960 "status": "applied",
16961 "governance": "promoted",
16962 "reason": reason,
16963 "rollback_patch": inverse,
16964 })
16965 }
16966 Err(e) => serde_json::json!({
16967 "status": "apply_failed", "error": e,
16968 }),
16969 }
16970 }
16971 }
16972 PromotionDecision::NeedsApproval { reason } => {
16973 pending += 1;
16974 pending_ref.lock().unwrap().push(
16975 serde_json::json!({
16976 "fingerprint": fingerprint,
16977 "mutation": m.id,
16978 "component": m.contract.component,
16979 "safety_affecting": true,
16980 "rationale": m.rationale,
16981 "reason": reason,
16982 }),
16983 );
16984 serde_json::json!({ "status": "pending_approval", "reason": reason })
16985 }
16986 PromotionDecision::Reject { reason } => {
16987 serde_json::json!({ "status": "rejected_by_gate", "reason": reason })
16988 }
16989 PromotionDecision::Incomparable { reason } => {
17000 serde_json::json!({ "status": "incomparable", "reason": reason })
17001 }
17002 };
17003 if measured_here {
17004 if let Some(obj) = status.as_object_mut() {
17005 obj.insert(
17006 "candidate_metrics".into(),
17007 serde_json::to_value(cand)
17008 .unwrap_or(Value::Null),
17009 );
17010 }
17011 }
17012 status
17013 }
17014 CandidateSource::Failed(error) => serde_json::json!({
17019 "status": "measurement_failed",
17020 "error": error,
17021 }),
17022 CandidateSource::Unavailable(reason) => {
17028 pending += 1;
17029 pending_ref.lock().unwrap().push(serde_json::json!({
17030 "fingerprint": fingerprint,
17031 "mutation": m.id,
17032 "component": m.contract.component,
17033 "safety_affecting": m.requires_human_approval(),
17034 "rationale": m.rationale,
17035 "reason": reason,
17036 }));
17037 serde_json::json!({
17038 "status": "pending_approval",
17039 "reason": reason,
17040 })
17041 }
17042 }
17043 }
17044 };
17045 let mut d = serde_json::json!({
17046 "mutation": m.id,
17047 "component": m.contract.component,
17048 "fingerprint": fingerprint,
17049 "rationale": m.rationale,
17050 });
17051 if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
17052 for (k, v) in s {
17053 obj.insert(k.clone(), v.clone());
17054 }
17055 }
17056 details.push(d);
17057 }
17058 let mut summary_obj = serde_json::json!({
17059 "mechanism": "harness_evolution",
17060 "mutations": harness_mutations.len(),
17061 "applied": applied,
17062 "pending": pending,
17063 "details": details,
17064 });
17065 if let (Some(obj), Some(measurement)) =
17066 (summary_obj.as_object_mut(), measurement_summary.as_ref())
17067 {
17068 obj.insert("measurement".into(), measurement.clone());
17069 }
17070 let summary = serde_json::to_string(&summary_obj).map_err(|e| e.to_string())?;
17071 Ok(if applied > 0 {
17074 EvolutionOutcome::applied(summary)
17075 } else {
17076 EvolutionOutcome::no_op(summary)
17077 })
17078 }
17079 EvolvableComponent::Context => {
17080 let context_measure = match (
17091 context_measurer_ref.as_ref(),
17092 context_measure_request_ref.as_ref(),
17093 ) {
17094 (Some(m), Some(rq)) => {
17095 Some((m.as_ref() as &dyn crate::evolution::HarnessMeasurer, rq))
17096 }
17097 _ => None,
17098 };
17099 crate::evolution::run_context_evolution(
17100 &engine,
17101 state,
17102 dry_run,
17103 pending_ref,
17104 None,
17105 context_measure,
17106 )
17107 .await
17108 }
17109 EvolvableComponent::Tools => Ok(EvolutionOutcome::out_of_scope(
17114 crate::evolution::TOOLS_OUT_OF_SCOPE_REASON,
17115 )),
17116 }
17117 }
17118 };
17119
17120 let report = car_memgine::self_evolution::run_evolution_cycle(&components, &policy, run).await;
17121
17122 if !dry_run {
17123 let mut data: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
17124 data.insert("source".into(), Value::from("evolution.run"));
17125 data.insert(
17126 "evolve_now".into(),
17127 serde_json::to_value(&report.plan.evolve_now).unwrap_or(Value::Null),
17128 );
17129 data.insert(
17130 "evolved".into(),
17131 serde_json::to_value(&report.evolved).unwrap_or(Value::Null),
17132 );
17133 session.runtime.log.lock().await.append(
17134 car_eventlog::EventKind::EvolutionTriggered,
17135 None,
17136 None,
17137 data,
17138 );
17139 if !report.evolved.is_empty() {
17142 if let Some(id) = session.agent_id.lock().await.clone() {
17143 if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
17144 tracing::warn!(agent_id = %id, error = %e,
17145 "agent memgine persist after evolution.run failed; in-memory state is canonical");
17146 }
17147 }
17148 }
17149 }
17150
17151 let pending = pending_approvals.into_inner().unwrap();
17152 let mut resp = serde_json::json!({
17153 "plan": report.plan,
17154 "steps": report.steps,
17155 "evolved": report.evolved,
17156 "out_of_scope": report.out_of_scope,
17160 });
17161 if !pending.is_empty() {
17162 resp.as_object_mut()
17163 .unwrap()
17164 .insert("pending_approvals".into(), Value::Array(pending));
17165 }
17166 if let Some(measurement) = measurement_summary.as_ref() {
17187 resp.as_object_mut()
17188 .unwrap()
17189 .insert("measurement".into(), measurement.clone());
17190 }
17191 Ok(resp)
17192}
17193
17194async fn handle_skill_repair(
17198 msg: &JsonRpcMessage,
17199 session: &crate::session::ClientSession,
17200) -> Result<Value, String> {
17201 let name = msg
17202 .params
17203 .get("skill_name")
17204 .and_then(|v| v.as_str())
17205 .ok_or("missing 'skill_name' parameter")?;
17206 let mut engine = session.memgine.lock().await;
17207 let code = engine.repair_skill(name).await;
17208 Ok(match code {
17209 Some(c) => serde_json::json!({ "code": c }),
17210 None => Value::Null,
17211 })
17212}
17213
17214async fn handle_skills_ingest_distilled(
17217 msg: &JsonRpcMessage,
17218 session: &crate::session::ClientSession,
17219) -> Result<Value, String> {
17220 let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17221 msg.params
17222 .get("skills")
17223 .cloned()
17224 .unwrap_or(msg.params.clone()),
17225 )
17226 .map_err(|e| format!("invalid skills: {}", e))?;
17227 let mut engine = session.memgine.lock().await;
17228 let nodes = engine.ingest_distilled_skills(&skills);
17229 Ok(serde_json::json!({ "ingested": nodes.len() }))
17230}
17231
17232async fn handle_skills_evolve(
17235 msg: &JsonRpcMessage,
17236 session: &crate::session::ClientSession,
17237) -> Result<Value, String> {
17238 let domain = msg
17239 .params
17240 .get("domain")
17241 .and_then(|v| v.as_str())
17242 .ok_or("missing 'domain' parameter")?
17243 .to_string();
17244 let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
17245 msg.params
17246 .get("events")
17247 .cloned()
17248 .unwrap_or(Value::Array(vec![])),
17249 )
17250 .map_err(|e| format!("invalid events: {}", e))?;
17251 let mut engine = session.memgine.lock().await;
17252 let skills = engine.evolve_skills(&events, &domain).await;
17253 serde_json::to_value(&skills).map_err(|e| e.to_string())
17254}
17255
17256async fn handle_skills_domains_needing_evolution(
17258 msg: &JsonRpcMessage,
17259 session: &crate::session::ClientSession,
17260) -> Result<Value, String> {
17261 let threshold = msg
17262 .params
17263 .get("threshold")
17264 .and_then(|v| v.as_f64())
17265 .unwrap_or(0.6);
17266 let engine = session.memgine.lock().await;
17267 let domains = engine.domains_needing_evolution(threshold);
17268 serde_json::to_value(&domains).map_err(|e| e.to_string())
17269}
17270
17271async fn handle_skills_ingest_provisional(
17277 msg: &JsonRpcMessage,
17278 session: &crate::session::ClientSession,
17279) -> Result<Value, String> {
17280 let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17281 msg.params
17282 .get("skills")
17283 .cloned()
17284 .unwrap_or(msg.params.clone()),
17285 )
17286 .map_err(|e| format!("invalid skills: {}", e))?;
17287 let tenant = msg.params.get("tenant").and_then(|v| v.as_str());
17288 let mut engine = session.memgine.lock().await;
17289 let ingested = engine.ingest_provisional_candidates(&skills, tenant);
17290 Ok(serde_json::json!({ "ingested": ingested }))
17291}
17292
17293async fn handle_skills_gate(
17298 _msg: &JsonRpcMessage,
17299 session: &crate::session::ClientSession,
17300) -> Result<Value, String> {
17301 let mut engine = session.memgine.lock().await;
17302 let (promoted, rejected) = engine.gate_skill_candidates();
17303 Ok(serde_json::json!({ "promoted": promoted, "rejected": rejected }))
17304}
17305
17306async fn handle_skill_meta(
17310 msg: &JsonRpcMessage,
17311 session: &crate::session::ClientSession,
17312) -> Result<Value, String> {
17313 let key = msg
17314 .params
17315 .get("key")
17316 .and_then(|v| v.as_str())
17317 .ok_or("missing 'key' parameter")?;
17318 let engine = session.memgine.lock().await;
17319 match engine.skill_meta(key) {
17320 Some(meta) => serde_json::to_value(&meta).map_err(|e| e.to_string()),
17321 None => Ok(Value::Null),
17322 }
17323}
17324
17325async fn handle_skill_export(
17329 msg: &JsonRpcMessage,
17330 session: &crate::session::ClientSession,
17331) -> Result<Value, String> {
17332 let key = msg
17333 .params
17334 .get("key")
17335 .and_then(|v| v.as_str())
17336 .ok_or("missing 'key' parameter")?;
17337 let engine = session.memgine.lock().await;
17338 Ok(engine
17339 .export_skill(key)
17340 .map(Value::String)
17341 .unwrap_or(Value::Null))
17342}
17343
17344async fn handle_skill_import(
17348 msg: &JsonRpcMessage,
17349 session: &crate::session::ClientSession,
17350) -> Result<Value, String> {
17351 let md = msg
17352 .params
17353 .get("markdown")
17354 .and_then(|v| v.as_str())
17355 .ok_or("missing 'markdown' parameter")?;
17356 let mut engine = session.memgine.lock().await;
17357 engine.import_skill_markdown(md)?;
17358 Ok(serde_json::json!({ "imported": true }))
17359}
17360
17361async fn handle_rerank(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17363 let engine = get_inference_engine(state);
17364 let req: car_inference::RerankRequest = typed_params(&msg.params)?;
17365 let _permit = state.admission.acquire().await;
17366 let result = engine.rerank(req).await.map_err(|e| e.to_string())?;
17367 serde_json::to_value(&result).map_err(|e| e.to_string())
17368}
17369
17370async fn handle_search(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17379 let req: car_inference::search::SearchRequest =
17380 serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17381 let _permit = state.admission.acquire().await;
17382 let result = car_inference::search::web_search(&req.query, req.max_results)
17383 .await
17384 .map_err(|e| e.to_string())?;
17385 serde_json::to_value(&result).map_err(|e| e.to_string())
17386}
17387
17388async fn handle_web_fetch(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17391 let req: car_inference::search::FetchRequest =
17392 serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17393 let _permit = state.admission.acquire().await;
17394 let result = car_inference::search::web_fetch(&req.url)
17395 .await
17396 .map_err(|e| e.to_string())?;
17397 serde_json::to_value(&result).map_err(|e| e.to_string())
17398}
17399
17400async fn handle_transcribe(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17401 use base64::Engine as _;
17402 let engine = get_inference_engine(state);
17403
17404 let mut params = msg.params.clone();
17411 let audio_b64 = params
17412 .as_object_mut()
17413 .and_then(|m| m.remove("audio_b64"))
17414 .and_then(|v| v.as_str().map(str::to_string));
17415 let _tmp_audio = if let Some(b64) = audio_b64 {
17416 let bytes = base64::engine::general_purpose::STANDARD
17417 .decode(b64.as_bytes())
17418 .map_err(|e| format!("audio_b64 decode failed: {e}"))?;
17419 let tmp = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
17420 std::fs::write(tmp.path(), &bytes).map_err(|e| e.to_string())?;
17421 let path = tmp.path().to_string_lossy().into_owned();
17422 if let Some(obj) = params.as_object_mut() {
17423 obj.insert("audio_path".to_string(), Value::String(path));
17424 }
17425 Some(tmp)
17426 } else {
17427 None
17428 };
17429
17430 let req: car_inference::TranscribeRequest =
17431 serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17432 let _permit = state.admission.acquire().await;
17433 let result = engine.transcribe(req).await.map_err(|e| e.to_string())?;
17434 serde_json::to_value(&result).map_err(|e| e.to_string())
17435}
17436
17437async fn handle_synthesize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17443 use base64::Engine as _;
17444 let engine = get_inference_engine(state);
17445
17446 let mut params = msg.params.clone();
17447 let return_b64 = params
17448 .as_object_mut()
17449 .and_then(|m| m.remove("return_b64"))
17450 .and_then(|v| v.as_bool())
17451 .unwrap_or(false);
17452 let no_output_path = params
17453 .as_object()
17454 .map(|m| !m.contains_key("output_path"))
17455 .unwrap_or(true);
17456
17457 let req: car_inference::SynthesizeRequest =
17458 serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17459 let _permit = state.admission.acquire().await;
17460 let result = engine.synthesize(req).await.map_err(|e| e.to_string())?;
17461 let mut value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
17462
17463 if return_b64 || no_output_path {
17467 let bytes = std::fs::read(&result.audio_path).map_err(|e| {
17468 format!(
17469 "synthesize: failed to read rendered audio at {}: {e}",
17470 result.audio_path
17471 )
17472 })?;
17473 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
17474 if let Some(obj) = value.as_object_mut() {
17475 obj.insert("audio_b64".to_string(), Value::String(encoded));
17476 }
17477 }
17478 Ok(value)
17479}
17480
17481async fn handle_speech_prepare(state: &ServerState) -> Result<Value, String> {
17488 let engine = get_inference_engine(state);
17489 let status = engine
17490 .prepare_speech_runtime()
17491 .await
17492 .map_err(|e| e.to_string())?;
17493 serde_json::to_value(&status).map_err(|e| e.to_string())
17494}
17495
17496async fn handle_models_route(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17499 let prompt = msg
17500 .params
17501 .get("prompt")
17502 .and_then(|v| v.as_str())
17503 .ok_or("missing 'prompt' parameter")?;
17504 let intent: Option<car_inference::IntentHint> = match msg.params.get("intent") {
17507 Some(v) if !v.is_null() => {
17508 Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid 'intent': {e}"))?)
17509 }
17510 _ => None,
17511 };
17512 let engine = get_inference_engine(state);
17513 let decision = engine.route_adaptive_with_intent(prompt, intent).await;
17514 serde_json::to_value(&decision).map_err(|e| e.to_string())
17515}
17516
17517async fn handle_models_stats(state: &ServerState) -> Result<Value, String> {
17519 let engine = get_inference_engine(state);
17520 let profiles = engine.export_profiles().await;
17521 Ok(serde_json::json!({ "profiles": models_stats_view(&profiles) }))
17522}
17523
17524async fn handle_outcomes_scoreboard(state: &ServerState) -> Result<Value, String> {
17532 let engine = get_inference_engine(state);
17533 let scoreboard = engine.outcome_scoreboard();
17534 serde_json::to_value(&scoreboard).map_err(|e| e.to_string())
17535}
17536
17537fn models_stats_view(profiles: &[car_inference::ModelProfile]) -> Vec<Value> {
17546 profiles
17547 .iter()
17548 .map(|p| {
17549 let mut v = serde_json::to_value(p).expect("ModelProfile is infallibly serializable");
17550 if let Some(obj) = v.as_object_mut() {
17551 obj.insert(
17552 "success_rate".into(),
17553 serde_json::json!(p.success_rate_resolved()),
17554 );
17555 obj.insert(
17556 "avg_latency_ms".into(),
17557 serde_json::json!(p.avg_latency_ms()),
17558 );
17559 }
17560 v
17561 })
17562 .collect()
17563}
17564
17565#[cfg(test)]
17566mod models_stats_view_tests {
17567 use super::models_stats_view;
17568
17569 #[test]
17570 fn success_rate_is_null_until_resolved_then_the_real_ratio() {
17571 let mut unmeasured = car_inference::ModelProfile::new("m-new".into());
17572 unmeasured.total_calls = 5; let mut measured = car_inference::ModelProfile::new("m-seen".into());
17575 measured.total_calls = 4;
17576 measured.success_count = 3;
17577 measured.fail_count = 1;
17578
17579 let view = models_stats_view(&[unmeasured, measured]);
17580
17581 assert!(view[0]["success_rate"].is_null());
17583 assert_eq!(view[0]["success_count"], 0);
17584 assert_eq!(view[0]["total_calls"], 5);
17585
17586 assert_eq!(view[1]["success_rate"], 0.75);
17588 assert_eq!(view[1]["fail_count"], 1);
17589 assert_eq!(view[1]["model_id"], "m-seen");
17590 }
17591
17592 #[test]
17593 fn view_is_non_lossy_and_round_trips_into_model_profile() {
17594 let mut p = car_inference::ModelProfile::new("m".into());
17600 p.total_calls = 2;
17601 p.success_count = 1;
17602 p.total_output_tokens = 4096;
17603
17604 let view = models_stats_view(&[p]);
17605 assert_eq!(view[0]["avg_latency_ms"], 0.0); let round: Vec<car_inference::ModelProfile> =
17607 serde_json::from_value(serde_json::Value::Array(view)).expect("round-trips");
17608 assert_eq!(round[0].total_output_tokens, 4096, "field not dropped");
17609 assert_eq!(round[0].model_id, "m");
17610 }
17611}
17612
17613#[derive(Deserialize)]
17614#[serde(rename_all = "camelCase")]
17615struct OutcomesResolvePendingParams {
17616 action_results: Vec<(String, bool, f64, String)>,
17621}
17622
17623async fn handle_outcomes_resolve_pending(
17643 req: &JsonRpcMessage,
17644 state: &ServerState,
17645) -> Result<Value, String> {
17646 let params: OutcomesResolvePendingParams =
17647 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17648 let engine = get_inference_engine(state);
17649 let mut tracker = engine.outcome_tracker.write().await;
17650 let inferred = tracker.infer_outcomes_from_action_sequence(¶ms.action_results);
17651 tracker.resolve_pending_from_signals(inferred);
17652 Ok(serde_json::json!({ "recorded": params.action_results.len() }))
17653}
17654
17655async fn handle_events_count(session: &crate::session::ClientSession) -> Result<Value, String> {
17657 let n = session.runtime.log.lock().await.len();
17658 Ok(Value::from(n as u64))
17659}
17660
17661async fn handle_events_query(
17671 msg: &JsonRpcMessage,
17672 session: &crate::session::ClientSession,
17673) -> Result<Value, String> {
17674 let query: car_eventlog::EventQuery = if msg.params.is_null() {
17675 Default::default()
17676 } else {
17677 serde_json::from_value(msg.params.clone())
17678 .map_err(|e| format!("invalid events.query params: {e}"))?
17679 };
17680 let log = session.runtime.log.lock().await;
17681 let events = log.query(&query);
17682 Ok(serde_json::json!({
17683 "count": events.len(),
17684 "events": events,
17685 }))
17686}
17687
17688enum NlpOp {
17690 IdentifyLanguage,
17691 Tokenize,
17692 ExtractEntities,
17693}
17694
17695fn handle_nlp(req: &JsonRpcMessage, op: NlpOp) -> Result<Value, String> {
17701 let text = req
17702 .params
17703 .get("text")
17704 .and_then(|v| v.as_str())
17705 .ok_or("missing 'text'")?;
17706 let json = match op {
17707 NlpOp::IdentifyLanguage => car_ffi_common::nlp::identify_language(text),
17708 NlpOp::Tokenize => car_ffi_common::nlp::tokenize_words(text),
17709 NlpOp::ExtractEntities => car_ffi_common::nlp::extract_named_entities(text),
17710 }?;
17711 serde_json::from_str(&json).map_err(|e| format!("nlp result parse: {e}"))
17712}
17713
17714async fn handle_metrics_summary(session: &crate::session::ClientSession) -> Result<Value, String> {
17719 let log = session.runtime.log.lock().await;
17720 let summary = car_eventlog::summarize_log(&log);
17723 serde_json::to_value(&summary).map_err(|e| format!("serialize metrics.summary: {e}"))
17724}
17725
17726async fn handle_metrics_alerts(
17735 req: &JsonRpcMessage,
17736 session: &crate::session::ClientSession,
17737) -> Result<Value, String> {
17738 let thresholds: car_eventlog::AlertThresholds = if req.params.is_null() {
17739 Default::default()
17740 } else {
17741 serde_json::from_value(req.params.clone())
17742 .map_err(|e| format!("invalid metrics.alerts thresholds: {e}"))?
17743 };
17744 let log = session.runtime.log.lock().await;
17745 let summary = car_eventlog::summarize_log(&log);
17748 let alerts = car_eventlog::evaluate_alerts(&summary, &thresholds);
17749 for a in &alerts {
17750 tracing::warn!(target: "car::alerts", kind = ?a.kind, "operational alert: {}", a.message);
17751 }
17752 Ok(serde_json::json!({ "summary": summary, "alerts": alerts }))
17753}
17754
17755async fn handle_events_cost_by_agent(
17761 session: &crate::session::ClientSession,
17762) -> Result<Value, String> {
17763 let log = session.runtime.log.lock().await;
17764 let report = log.cost_by_agent();
17765 serde_json::to_value(&report).map_err(|e| format!("serialize cost_by_agent: {e}"))
17766}
17767
17768async fn handle_events_retention(
17774 msg: &JsonRpcMessage,
17775 session: &crate::session::ClientSession,
17776) -> Result<Value, String> {
17777 let mut log = session.runtime.log.lock().await;
17778 if let Some(p) = msg.params.get("policy") {
17779 let policy: car_eventlog::RetentionPolicy = serde_json::from_value(p.clone())
17780 .map_err(|e| format!("invalid retention policy: {e}"))?;
17781 let removed = log.enforce_retention(&policy, chrono::Utc::now());
17782 log.set_retention(Some(policy.clone()));
17783 Ok(serde_json::json!({ "policy": policy, "removed": removed }))
17784 } else {
17785 Ok(serde_json::json!({ "policy": log.retention() }))
17786 }
17787}
17788
17789async fn handle_events_chain_enable(
17794 session: &crate::session::ClientSession,
17795) -> Result<Value, String> {
17796 session.runtime.enable_event_log_hash_chaining().await;
17797 Ok(serde_json::json!({ "ok": true }))
17798}
17799
17800async fn handle_events_chain_verify(
17806 session: &crate::session::ClientSession,
17807) -> Result<Value, String> {
17808 match session.runtime.verify_event_log_chain().await {
17809 Ok(n) => Ok(serde_json::json!({ "verified": n })),
17810 Err(i) => Ok(serde_json::json!({ "tampered_at": i })),
17811 }
17812}
17813
17814async fn handle_events_stats(session: &crate::session::ClientSession) -> Result<Value, String> {
17815 let stats = session.runtime.log.lock().await.stats();
17816 serde_json::to_value(stats).map_err(|e| e.to_string())
17817}
17818
17819#[derive(Deserialize)]
17820#[serde(rename_all = "camelCase")]
17821struct EventsTruncateParams {
17822 #[serde(default)]
17823 max_events: Option<usize>,
17824 #[serde(default)]
17825 max_spans: Option<usize>,
17826}
17827
17828async fn handle_events_truncate(
17829 msg: &JsonRpcMessage,
17830 session: &crate::session::ClientSession,
17831) -> Result<Value, String> {
17832 let params: EventsTruncateParams =
17833 serde_json::from_value(msg.params.clone()).unwrap_or(EventsTruncateParams {
17834 max_events: None,
17835 max_spans: None,
17836 });
17837 let mut log = session.runtime.log.lock().await;
17838 let removed_events = params
17839 .max_events
17840 .map(|max| log.truncate_events_keep_last(max))
17841 .unwrap_or(0);
17842 let removed_spans = params
17843 .max_spans
17844 .map(|max| log.truncate_spans_keep_last(max))
17845 .unwrap_or(0);
17846 let stats = log.stats();
17847 Ok(serde_json::json!({
17848 "removedEvents": removed_events,
17849 "removedSpans": removed_spans,
17850 "stats": stats,
17851 }))
17852}
17853
17854async fn handle_events_clear(session: &crate::session::ClientSession) -> Result<Value, String> {
17855 let mut log = session.runtime.log.lock().await;
17856 let removed = log.clear();
17857 Ok(serde_json::json!({ "removed": removed, "stats": log.stats() }))
17858}
17859
17860async fn resolve_run_agent_id(
17881 session: &crate::session::ClientSession,
17882 req: &car_proto::RunStartRequest,
17883) -> Result<String, String> {
17884 if let Some(bound) = session.agent_id.lock().await.clone() {
17892 if let Some(id) = req.agent_id.as_deref() {
17893 let id = id.trim();
17894 if !id.is_empty() && id != bound {
17895 return Err(format!(
17896 "runs.start `agent_id` (`{id}`) does not match this session's bound \
17897 agent: a bound connection can only record runs under its own agent"
17898 ));
17899 }
17900 }
17901 return Ok(bound);
17902 }
17903 if let Some(id) = req.agent_id.as_deref() {
17907 let id = id.trim();
17908 if !id.is_empty() {
17909 return Ok(id.to_string());
17910 }
17911 }
17912 if let Ok(env_id) = std::env::var("CAR_AGENT_ID") {
17914 let env_id = env_id.trim().to_string();
17915 if !env_id.is_empty() {
17916 return Ok(env_id);
17917 }
17918 }
17919 if let Some(name) = req.agent_name.as_deref() {
17922 if let Some(synth) = synthesize_agent_id(name) {
17923 return Ok(synth);
17924 }
17925 }
17926 Err(
17927 "runs.start could not resolve an agent_id: no `agent_id` param, no bound \
17928 session.auth {agent_id}, no CAR_AGENT_ID env, and no usable `agent_name` \
17929 to synthesize one from"
17930 .to_string(),
17931 )
17932}
17933
17934fn synthesize_agent_id(name: &str) -> Option<String> {
17942 let mut slug = String::new();
17943 let mut prev_dash = false;
17944 for ch in name.chars() {
17945 if ch.is_ascii_alphanumeric() {
17946 slug.push(ch.to_ascii_lowercase());
17947 prev_dash = false;
17948 } else if !prev_dash {
17949 slug.push('-');
17950 prev_dash = true;
17951 }
17952 }
17953 let slug = slug.trim_matches('-');
17954 if slug.is_empty() {
17955 return None;
17956 }
17957 Some(format!("name:{slug}"))
17958}
17959
17960async fn release_new_run_reservation_after_error(
17961 state: &ServerState,
17962 session: &crate::session::ClientSession,
17963 requested: &crate::session::RunMeta,
17964 error: String,
17965) -> String {
17966 match state
17967 .release_unpersisted_run_reservation(session, requested)
17968 .await
17969 {
17970 Ok(()) => error,
17971 Err(cleanup) => format!("{error}; failed to release new run reservation: {cleanup}"),
17972 }
17973}
17974
17975async fn handle_runs_start(
17976 req: &JsonRpcMessage,
17977 session: &crate::session::ClientSession,
17978 state: &Arc<ServerState>,
17979) -> Result<Value, String> {
17980 let params: car_proto::RunStartRequest = serde_json::from_value(req.params.clone())
17981 .map_err(|e| format!("runs.start requires {{ intent, agent_id?, agent_name?, outcome_description?, idempotency_key? }}: {e}"))?;
17982 if params.intent.trim().is_empty() {
17983 return Err("runs.start requires a non-empty `intent`".to_string());
17984 }
17985
17986 let agent_id = resolve_run_agent_id(session, ¶ms).await?;
17987 let _run_guard = session.run_lifecycle_guard.lock().await;
17988 let idempotency_key = params
17989 .idempotency_key
17990 .as_deref()
17991 .map(str::trim)
17992 .filter(|k| !k.is_empty())
17993 .map(str::to_string);
17994 let run_id = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
17995 let current_run = session.current_run_id.lock().await.clone();
17996 let requested_meta = crate::session::RunMeta {
17997 run_id: run_id.clone(),
17998 agent_id: agent_id.clone(),
17999 client_id: session.client_id.clone(),
18000 active_client_id: session.client_id.clone(),
18001 resume_predecessor_client_id: None,
18002 resume_lease: None,
18003 intent: params.intent.clone(),
18004 outcome_description: params.outcome_description.clone(),
18005 started_at: chrono::Utc::now(),
18006 termination: None,
18007 ended_at: None,
18008 turns: Vec::new(),
18009 start_committed: false,
18010 pending_terminal: None,
18011 cancellation_pending: None,
18012 cancellation_receipt: None,
18013 trace_corruption: None,
18014 durability_generation: 0,
18015 };
18016 if let Some(current) = current_run.as_deref() {
18017 if let Some(pending) = state
18018 .run_store
18019 .pending_proposal(current)
18020 .map_err(|error| proposal_durability_quarantine(current, "finalization", &error))?
18021 {
18022 return Err(format!(
18023 "proposal finalization pending for run_id `{current}` / original submission `{}`",
18024 pending.original_proposal_id
18025 ));
18026 }
18027 if let Some(marker) = state
18028 .run_store
18029 .execution_marker(current)
18030 .map_err(|error| proposal_durability_quarantine(current, "execution", &error))?
18031 {
18032 return Err(format!(
18033 "proposal execution outcome unknown for run_id `{current}` / original submission `{}`; CAR will not replace or terminalize the quarantined run",
18034 marker.original_proposal_id
18035 ));
18036 }
18037 }
18038 if let Some(pending) = state
18039 .run_store
18040 .pending_proposal(&run_id)
18041 .map_err(|error| proposal_durability_quarantine(&run_id, "finalization", &error))?
18042 {
18043 return Err(format!(
18044 "proposal finalization pending for run_id `{run_id}` / original submission `{}`",
18045 pending.original_proposal_id
18046 ));
18047 }
18048 if let Some(marker) = state
18049 .run_store
18050 .execution_marker(&run_id)
18051 .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))?
18052 {
18053 return Err(format!(
18054 "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not reopen or redispatch automatically",
18055 marker.original_proposal_id
18056 ));
18057 }
18058 let reservation = state.reserve_run(requested_meta.clone()).await?;
18062 let reservation_is_new = matches!(&reservation, crate::session::RunReservation::New);
18063
18064 if let crate::session::RunReservation::Existing(existing) = &reservation {
18065 if let Some(error) = &existing.trace_corruption {
18066 return Err(error.clone());
18067 }
18068 if existing.is_terminal() {
18069 return Err(format!(
18070 "{} run `{run_id}` is already terminal",
18071 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18072 ));
18073 }
18074 }
18075
18076 let current_resolution: Result<Option<Value>, String> = async {
18077 if let Some(current) = current_run.as_deref() {
18078 if current == run_id {
18079 let (owner, terminal, committed, pending) = state
18080 .run_lifecycle_state(current)
18081 .await
18082 .ok_or_else(|| {
18083 format!("reserved run `{current}` is absent from CAR registry")
18084 })?;
18085 if owner != session.client_id || terminal {
18086 return Err(format!(
18087 "{} run `{current}` is not resumable by this client",
18088 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18089 ));
18090 }
18091 session.require_run_journal_binding(current).await?;
18092 if pending {
18093 let ended = state
18094 .prepare_run_incomplete(current)
18095 .await
18096 .ok_or_else(|| {
18097 format!("active run `{current}` could not resume its pending terminal")
18098 })?;
18099 session.append_run_terminal_event(&ended).await?;
18100 state.commit_run_completion(&ended).await?;
18101 session.clear_run_journal_binding(current).await?;
18102 *session.current_run_id.lock().await = None;
18103 return Err(format!(
18104 "{} run `{current}` is already terminal",
18105 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18106 ));
18107 }
18108 if committed {
18109 return serde_json::to_value(car_proto::RunStartResponse {
18110 run_id: current.to_string(),
18111 agent_id: agent_id.clone(),
18112 client_id: session.client_id.clone(),
18113 })
18114 .map(Some)
18115 .map_err(|e| e.to_string());
18116 }
18117 } else {
18118 let (owner, terminal, committed, _pending) = state
18119 .run_lifecycle_state(current)
18120 .await
18121 .ok_or_else(|| {
18122 format!("active run `{current}` is absent from CAR registry")
18123 })?;
18124 if owner != session.client_id {
18125 return Err(format!(
18126 "{} active run `{current}` belongs to another client",
18127 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18128 ));
18129 }
18130 session.require_run_journal_binding(current).await?;
18131 if !terminal {
18132 if !committed {
18133 return Err(format!(
18134 "{} active run `{current}` has an unacknowledged durable start; retry its exact idempotency key and occurrence-defining payload before starting `{run_id}`",
18135 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18136 ));
18137 }
18138 let ended = state
18141 .prepare_run_incomplete(current)
18142 .await
18143 .ok_or_else(|| {
18144 format!("active run `{current}` could not be terminalized")
18145 })?;
18146 session.append_run_terminal_event(&ended).await?;
18147 state.commit_run_completion(&ended).await?;
18148 }
18149 session.clear_run_journal_binding(current).await?;
18150 *session.current_run_id.lock().await = None;
18151 }
18152 }
18153 Ok(None)
18154 }
18155 .await;
18156 match current_resolution {
18157 Ok(Some(response)) => return Ok(response),
18158 Ok(None) => {}
18159 Err(error) => {
18160 if reservation_is_new {
18161 return Err(release_new_run_reservation_after_error(
18162 state,
18163 session,
18164 &requested_meta,
18165 error,
18166 )
18167 .await);
18168 }
18169 return Err(error);
18170 }
18171 }
18172
18173 let binding_preexisting = current_run.as_deref() == Some(run_id.as_str());
18174 if !binding_preexisting {
18175 if let Err(error) = session.bind_run_journal(&run_id).await {
18176 if reservation_is_new {
18177 return Err(release_new_run_reservation_after_error(
18178 state,
18179 session,
18180 &requested_meta,
18181 error,
18182 )
18183 .await);
18184 }
18185 return Err(error);
18186 }
18187 }
18188
18189 *session.current_run_id.lock().await = Some(run_id.clone());
18190 let start_result = async {
18191 let started = state.persist_run_start(&run_id).await?;
18192 session.append_run_started_event(&started).await?;
18193 state.commit_run_start(&run_id).await
18194 }
18195 .await;
18196 if let Err(error) = start_result {
18197 match state.run_store.run_started(&run_id) {
18198 Ok(None) => {
18199 match state
18200 .reconcile_or_release_unacknowledged_start(session, &run_id)
18201 .await
18202 {
18203 Ok(true) => return Err(error),
18204 Ok(false) => {
18205 return Err(format!(
18206 "{error}; run `{run_id}` became durable during rollback; retry the exact idempotency key and occurrence-defining payload"
18207 ));
18208 }
18209 Err(cleanup) => {
18210 return Err(format!(
18211 "{error}; failed to release rejected start reservation: {cleanup}"
18212 ));
18213 }
18214 }
18215 }
18216 Ok(Some(_)) => {
18217 return Err(format!(
18218 "{error}; {} run `{run_id}` has an unacknowledged durable start; retry the exact idempotency key and occurrence-defining payload",
18219 car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18220 ));
18221 }
18222 Err(read_error) => {
18223 return Err(format!(
18224 "{error}; durable start state for run `{run_id}` is unreadable ({read_error}); retry only the exact idempotency key and occurrence-defining payload"
18225 ));
18226 }
18227 }
18228 }
18229
18230 serde_json::to_value(car_proto::RunStartResponse {
18231 run_id,
18232 agent_id,
18233 client_id: session.client_id.clone(),
18234 })
18235 .map_err(|e| e.to_string())
18236}
18237
18238async fn handle_runs_resume(
18239 req: &JsonRpcMessage,
18240 session: &crate::session::ClientSession,
18241 state: &Arc<ServerState>,
18242) -> Result<Value, String> {
18243 if !session_has_capability(session, car_proto::RUNS_RESUME_CAPABILITY) {
18244 return Err(format!(
18245 "runs.resume requires negotiated capability `{}`",
18246 car_proto::RUNS_RESUME_CAPABILITY
18247 ));
18248 }
18249 let params: car_proto::RunResumeRequest = serde_json::from_value(req.params.clone())
18250 .map_err(|error| format!("runs.resume requires exactly {{ run_id }}: {error}"))?;
18251 if params.run_id.trim().is_empty() {
18252 return Err("runs.resume run_id must be non-empty".into());
18253 }
18254 if !session.authenticated.load(Ordering::Acquire) {
18255 return Err("runs.resume requires an authenticated agent session".into());
18256 }
18257 let agent_id = session
18258 .agent_id
18259 .lock()
18260 .await
18261 .clone()
18262 .ok_or_else(|| "runs.resume requires session.auth with an agent_id".to_string())?;
18263
18264 let _run_guard = session.run_lifecycle_guard.lock().await;
18265 let current = session.current_run_id.lock().await.clone();
18266 if current
18267 .as_deref()
18268 .is_some_and(|current| current != params.run_id)
18269 {
18270 return Err(format!(
18271 "runs.resume cannot replace this socket's active run `{}`",
18272 current.expect("checked as present")
18273 ));
18274 }
18275
18276 let binding_preexisting = current.as_deref() == Some(params.run_id.as_str());
18277 if !binding_preexisting {
18278 session.bind_run_journal(¶ms.run_id).await?;
18279 }
18280 let resumed = match state
18281 .resume_run(¶ms.run_id, &agent_id, &session.client_id)
18282 .await
18283 {
18284 Ok(binding) => binding,
18285 Err(error) => {
18286 if !binding_preexisting {
18287 let _ = session.clear_run_journal_binding(¶ms.run_id).await;
18288 }
18289 return Err(error);
18290 }
18291 };
18292 *session.current_run_id.lock().await = Some(params.run_id.clone());
18293
18294 serde_json::to_value(car_proto::RunResumeResponse {
18295 run_id: resumed.run_id,
18296 agent_id: resumed.agent_id,
18297 client_id: resumed.active_client_id,
18298 resumed_from_client_id: resumed.resumed_from_client_id,
18299 })
18300 .map_err(|error| error.to_string())
18301}
18302
18303async fn handle_runs_complete(
18304 req: &JsonRpcMessage,
18305 session: &crate::session::ClientSession,
18306 state: &Arc<ServerState>,
18307) -> Result<Value, String> {
18308 let params: car_proto::RunCompleteRequest = serde_json::from_value(req.params.clone())
18309 .map_err(|e| format!("runs.complete requires {{ run_id, outcome }}: {e}"))?;
18310
18311 let _run_guard = session.run_lifecycle_guard.lock().await;
18312 let current = session.current_run_id.lock().await.clone();
18313 if current.as_deref() != Some(params.run_id.as_str()) {
18314 return Err(format!(
18315 "runs.complete run_id `{}` is not this client's active run",
18316 params.run_id
18317 ));
18318 }
18319 let (owner_client, _terminal, start_committed, _completion_pending) = state
18320 .run_lifecycle_state(¶ms.run_id)
18321 .await
18322 .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18323 let (durable_client, active_client) = state
18324 .run_owner_binding(¶ms.run_id)
18325 .await
18326 .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18327 if owner_client != session.client_id || !start_committed {
18328 return Err("runs.complete client/run binding mismatch".into());
18329 }
18330 if active_client != session.client_id {
18331 return Err("runs.complete active owner mismatch".into());
18332 }
18333 session.require_run_journal_binding(¶ms.run_id).await?;
18334 if let Some(pending) = state
18335 .run_store
18336 .pending_proposal(¶ms.run_id)
18337 .map_err(|error| proposal_durability_quarantine(¶ms.run_id, "finalization", &error))?
18338 {
18339 return Err(format!(
18340 "proposal finalization pending for run_id `{}` / original submission `{}`",
18341 params.run_id, pending.original_proposal_id
18342 ));
18343 }
18344 if let Some(marker) = state
18345 .run_store
18346 .execution_marker(¶ms.run_id)
18347 .map_err(|error| proposal_durability_quarantine(¶ms.run_id, "execution", &error))?
18348 {
18349 return Err(format!(
18350 "proposal execution outcome unknown for run_id `{}` / original submission `{}`; runs.complete is quarantined",
18351 params.run_id, marker.original_proposal_id
18352 ));
18353 }
18354
18355 let termination = car_proto::RunTermination::Outcome {
18356 status: params.outcome.status,
18357 outcome: params.outcome.clone(),
18358 };
18359 let ended = state
18360 .prepare_run_completion_for_active_owner(
18361 ¶ms.run_id,
18362 termination,
18363 Some(&session.client_id),
18364 )
18365 .await?;
18366 if durable_client == session.client_id {
18367 session.append_run_terminal_event(&ended).await?;
18368 } else {
18369 session
18370 .append_resumed_run_terminal_event(&ended, &durable_client)
18371 .await?;
18372 }
18373 state.commit_run_completion(&ended).await?;
18374 let completion_digest = ended
18375 .completion_digest
18376 .clone()
18377 .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18378
18379 session.clear_run_journal_binding(¶ms.run_id).await?;
18383 *session.current_run_id.lock().await = None;
18384
18385 serde_json::to_value(car_proto::RunCompleteResponse {
18386 run_id: params.run_id,
18387 ok: true,
18388 completion_digest,
18389 })
18390 .map_err(|e| e.to_string())
18391}
18392
18393#[derive(Serialize)]
18394struct RunCancelReceiptPreimage<'a> {
18395 receipt_version: u32,
18396 run_id: &'a str,
18397 idempotency_key: &'a str,
18398 reason_digest: &'a str,
18399 principal: &'a str,
18400 status: car_proto::RunCancellationStatus,
18401 terminal_digest: &'a Option<String>,
18402 action_id: &'a Option<String>,
18403 request_id: &'a Option<String>,
18404}
18405
18406fn run_cancel_receipt(
18407 requested: &car_proto::RunCancellationRequested,
18408 status: car_proto::RunCancellationStatus,
18409 terminal_digest: Option<String>,
18410) -> Result<car_proto::RunCancelResponse, String> {
18411 let preimage = RunCancelReceiptPreimage {
18412 receipt_version: requested.receipt_version,
18413 run_id: &requested.run_id,
18414 idempotency_key: &requested.idempotency_key,
18415 reason_digest: &requested.reason_digest,
18416 principal: &requested.principal,
18417 status,
18418 terminal_digest: &terminal_digest,
18419 action_id: &requested.action_id,
18420 request_id: &requested.request_id,
18421 };
18422 let receipt_digest = car_proto::canonical_sha256(&preimage)?;
18423 Ok(car_proto::RunCancelResponse {
18424 receipt_version: requested.receipt_version,
18425 run_id: requested.run_id.clone(),
18426 idempotency_key: requested.idempotency_key.clone(),
18427 reason_digest: requested.reason_digest.clone(),
18428 principal: requested.principal.clone(),
18429 status,
18430 terminal_digest,
18431 action_id: requested.action_id.clone(),
18432 request_id: requested.request_id.clone(),
18433 receipt_digest,
18434 })
18435}
18436
18437fn run_cancel_terminal_receipt(
18438 params: &car_proto::RunCancelRequest,
18439 reason_digest: &str,
18440 principal: &str,
18441 ended: &car_proto::RunEnded,
18442) -> Result<car_proto::RunCancelResponse, String> {
18443 let terminal_digest = ended
18444 .completion_digest
18445 .clone()
18446 .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18447 let (requested, status) = match &ended.termination {
18448 car_proto::RunTermination::Cancelled { cancellation }
18449 if cancellation.idempotency_key == params.idempotency_key
18450 && cancellation.reason_digest == reason_digest
18451 && cancellation.principal == principal =>
18452 {
18453 (
18454 car_proto::RunCancellationRequested {
18455 receipt_version: cancellation.receipt_version,
18456 run_id: cancellation.run_id.clone(),
18457 idempotency_key: cancellation.idempotency_key.clone(),
18458 reason_digest: cancellation.reason_digest.clone(),
18459 principal: cancellation.principal.clone(),
18460 action_id: cancellation.action_id.clone(),
18461 request_id: cancellation.request_id.clone(),
18462 },
18463 car_proto::RunCancellationStatus::CancelledConfirmed,
18464 )
18465 }
18466 car_proto::RunTermination::Cancelled { cancellation } => (
18467 car_proto::RunCancellationRequested {
18468 receipt_version: 1,
18469 run_id: params.run_id.clone(),
18470 idempotency_key: params.idempotency_key.clone(),
18471 reason_digest: reason_digest.to_string(),
18472 principal: principal.to_string(),
18473 action_id: cancellation.action_id.clone(),
18474 request_id: cancellation.request_id.clone(),
18475 },
18476 car_proto::RunCancellationStatus::AlreadyTerminal,
18477 ),
18478 _ => (
18479 car_proto::RunCancellationRequested {
18480 receipt_version: 1,
18481 run_id: params.run_id.clone(),
18482 idempotency_key: params.idempotency_key.clone(),
18483 reason_digest: reason_digest.to_string(),
18484 principal: principal.to_string(),
18485 action_id: None,
18486 request_id: None,
18487 },
18488 car_proto::RunCancellationStatus::AlreadyTerminal,
18489 ),
18490 };
18491 run_cancel_receipt(&requested, status, Some(terminal_digest))
18492}
18493
18494async fn handle_runs_cancel(
18495 req: &JsonRpcMessage,
18496 session: &crate::session::ClientSession,
18497 state: &Arc<ServerState>,
18498) -> Result<Value, String> {
18499 if !session_has_capability(session, car_proto::RUNS_CANCEL_CAPABILITY) {
18500 return Err(format!(
18501 "runs.cancel requires negotiated capability `{}`",
18502 car_proto::RUNS_CANCEL_CAPABILITY
18503 ));
18504 }
18505 let params: car_proto::RunCancelRequest =
18506 serde_json::from_value(req.params.clone()).map_err(|error| {
18507 format!("runs.cancel requires exactly {{ run_id, idempotency_key, reason }}: {error}")
18508 })?;
18509 if params.run_id.trim().is_empty()
18510 || params.idempotency_key.trim().is_empty()
18511 || params.reason.trim().is_empty()
18512 {
18513 return Err("runs.cancel fields must be non-empty".into());
18514 }
18515 if params.idempotency_key.len() > 128 {
18516 return Err("runs.cancel idempotency_key exceeds 128 bytes".into());
18517 }
18518 if params.reason.len() > 1024 {
18519 return Err("runs.cancel reason exceeds 1024 bytes".into());
18520 }
18521
18522 let meta = state.run_meta(¶ms.run_id).await;
18523 let owning_agent = meta
18524 .as_ref()
18525 .map(|meta| meta.agent_id.clone())
18526 .or_else(|| state.run_store.agent_for_run(¶ms.run_id));
18527 let Some(owning_agent) = owning_agent else {
18528 return Err("run not found or not authorized".into());
18529 };
18530 if authorize_run_access(session, state, &owning_agent)
18531 .await
18532 .is_err()
18533 {
18534 return Err("run not found or not authorized".into());
18535 }
18536 let principal = if session.is_host.load(Ordering::Acquire) {
18537 "host".to_string()
18538 } else {
18539 format!("agent:{owning_agent}")
18540 };
18541 let reason_digest = format!("{:x}", Sha256::digest(params.reason.as_bytes()));
18542
18543 let records = state
18544 .run_store
18545 .get_run_trace_for_checked(&owning_agent, ¶ms.run_id)
18546 .map_err(|error| format!("runs.cancel trace read failed: {error}"))?
18547 .ok_or_else(|| "run not found or not authorized".to_string())?;
18548 let existing_result = records.iter().find_map(|record| match record {
18549 car_proto::RunRecord::CancellationResult(result) => Some(result),
18550 _ => None,
18551 });
18552 if let Some(existing) = existing_result {
18553 if existing.idempotency_key != params.idempotency_key
18554 || existing.reason_digest != reason_digest
18555 || existing.principal != principal
18556 {
18557 return Err("run already has a different cancellation request".into());
18558 }
18559 }
18560 if let Some(ended) = records.iter().find_map(|record| match record {
18561 car_proto::RunRecord::Ended(ended) => Some(ended),
18562 _ => None,
18563 }) {
18564 return serde_json::to_value(run_cancel_terminal_receipt(
18565 ¶ms,
18566 &reason_digest,
18567 &principal,
18568 ended,
18569 )?)
18570 .map_err(|error| error.to_string());
18571 }
18572
18573 let existing_requested = records.iter().find_map(|record| match record {
18574 car_proto::RunRecord::CancellationRequested(requested) => Some(requested),
18575 _ => None,
18576 });
18577 if let Some(existing) = existing_result {
18578 let owner_session = match meta.as_ref() {
18579 Some(meta) => state.sessions.lock().await.get(&meta.client_id).cloned(),
18580 None => None,
18581 };
18582 match owner_session {
18583 Some(owner_session) => {
18584 state
18585 .persist_run_cancellation_result(&owner_session, existing)
18586 .await?;
18587 }
18588 None => {
18589 state
18590 .persist_recovered_run_cancellation_result(&owning_agent, existing)
18591 .await?;
18592 }
18593 }
18594 return serde_json::to_value(existing).map_err(|error| error.to_string());
18595 }
18596 let Some(meta) = meta else {
18597 let Some(requested) = existing_requested else {
18598 return Err("run cancellation control is unavailable".into());
18599 };
18600 if requested.idempotency_key != params.idempotency_key
18601 || requested.reason_digest != reason_digest
18602 || requested.principal != principal
18603 {
18604 return Err("run already has a different cancellation request".into());
18605 }
18606 let response = run_cancel_receipt(
18607 requested,
18608 car_proto::RunCancellationStatus::TerminationUnconfirmed,
18609 None,
18610 )?;
18611 state
18612 .persist_recovered_run_cancellation_result(&owning_agent, &response)
18613 .await?;
18614 return serde_json::to_value(response).map_err(|error| error.to_string());
18615 };
18616 let owner_session = state.sessions.lock().await.get(&meta.client_id).cloned();
18617 let Some(owner_session) = owner_session else {
18618 let Some(requested) = existing_requested else {
18619 return Err("run cancellation control is unavailable".into());
18620 };
18621 if requested.idempotency_key != params.idempotency_key
18622 || requested.reason_digest != reason_digest
18623 || requested.principal != principal
18624 {
18625 return Err("run already has a different cancellation request".into());
18626 }
18627 let response = run_cancel_receipt(
18628 requested,
18629 car_proto::RunCancellationStatus::TerminationUnconfirmed,
18630 None,
18631 )?;
18632 state
18633 .persist_recovered_run_cancellation_result(&owning_agent, &response)
18634 .await?;
18635 return serde_json::to_value(response).map_err(|error| error.to_string());
18636 };
18637
18638 let _run_guard = owner_session.run_lifecycle_guard.lock().await;
18644 let guarded_records = state
18645 .run_store
18646 .get_run_trace_for_checked(&owning_agent, ¶ms.run_id)
18647 .map_err(|error| format!("runs.cancel guarded trace read failed: {error}"))?
18648 .ok_or_else(|| "run not found or not authorized".to_string())?;
18649 if let Some(ended) = guarded_records.iter().find_map(|record| match record {
18650 car_proto::RunRecord::Ended(ended) => Some(ended),
18651 _ => None,
18652 }) {
18653 return serde_json::to_value(run_cancel_terminal_receipt(
18654 ¶ms,
18655 &reason_digest,
18656 &principal,
18657 ended,
18658 )?)
18659 .map_err(|error| error.to_string());
18660 }
18661
18662 let mut active: Vec<(String, String)> = owner_session
18663 .channel
18664 .active_actions
18665 .lock()
18666 .await
18667 .iter()
18668 .map(|(request_id, action_id)| (request_id.clone(), action_id.clone()))
18669 .collect();
18670 active.sort();
18671 let active_inferences = owner_session
18672 .inference_control
18673 .active_for_run(¶ms.run_id);
18674 let (request_id, action_id) = active
18675 .first()
18676 .map(|(request_id, action_id)| (Some(request_id.clone()), Some(action_id.clone())))
18677 .or_else(|| {
18678 active_inferences.first().map(|(inference_id, request_id)| {
18679 (
18680 request_id.clone().or_else(|| Some(inference_id.clone())),
18681 None,
18682 )
18683 })
18684 })
18685 .unwrap_or((None, None));
18686 let requested = if let Some(existing) = existing_requested {
18687 if existing.idempotency_key != params.idempotency_key
18688 || existing.reason_digest != reason_digest
18689 || existing.principal != principal
18690 {
18691 return Err("run already has a different cancellation request".into());
18692 }
18693 existing.clone()
18694 } else {
18695 car_proto::RunCancellationRequested {
18696 receipt_version: 1,
18697 run_id: params.run_id.clone(),
18698 idempotency_key: params.idempotency_key.clone(),
18699 reason_digest,
18700 principal,
18701 action_id,
18702 request_id,
18703 }
18704 };
18705 state
18706 .persist_run_cancellation_requested(&owner_session, &requested)
18707 .await?;
18708
18709 for (request_id, action_id) in &active {
18710 crate::session::write_tool_cancel(
18711 &owner_session.channel,
18712 request_id.clone(),
18713 action_id.clone(),
18714 params.reason.clone(),
18715 )
18716 .await;
18717 }
18718 let mut inference_stopped = true;
18719 for (inference_id, _) in &active_inferences {
18720 let _pending = owner_session
18721 .inference_control
18722 .try_acquire_control()
18723 .map_err(|error| format!("runs.cancel inference control rejected: {error:?}"))?;
18724 let status = owner_session
18725 .inference_control
18726 .control(
18727 inference_id,
18728 crate::inference_control::ControlCause::Cancel,
18729 exact_backend_termination_ack(inference_id),
18730 )
18731 .await;
18732 if !matches!(
18733 status,
18734 car_proto::InferenceControlStatus::CancelledConfirmed
18735 | car_proto::InferenceControlStatus::AlreadyTerminal
18736 ) {
18737 inference_stopped = false;
18738 }
18739 }
18740 let callbacks_stopped = if active.is_empty() {
18741 true
18742 } else {
18743 tokio::time::timeout(std::time::Duration::from_secs(5), async {
18744 loop {
18745 let pending = owner_session.channel.pending.lock().await;
18746 let any_active = active
18747 .iter()
18748 .any(|(request_id, _)| pending.contains_key(request_id));
18749 drop(pending);
18750 if !any_active {
18751 break;
18752 }
18753 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
18754 }
18755 })
18756 .await
18757 .is_ok()
18758 };
18759 let stopped = callbacks_stopped && inference_stopped;
18760
18761 if !stopped {
18762 let response = run_cancel_receipt(
18763 &requested,
18764 car_proto::RunCancellationStatus::TerminationUnconfirmed,
18765 None,
18766 )?;
18767 state
18768 .persist_run_cancellation_result(&owner_session, &response)
18769 .await?;
18770 return serde_json::to_value(response).map_err(|error| error.to_string());
18771 }
18772
18773 let cancellation = car_proto::RunCancellationIdentity {
18774 receipt_version: requested.receipt_version,
18775 run_id: requested.run_id.clone(),
18776 idempotency_key: requested.idempotency_key.clone(),
18777 reason_digest: requested.reason_digest.clone(),
18778 principal: requested.principal.clone(),
18779 action_id: requested.action_id.clone(),
18780 request_id: requested.request_id.clone(),
18781 };
18782 let ended = state
18783 .prepare_run_completion(
18784 ¶ms.run_id,
18785 car_proto::RunTermination::Cancelled { cancellation },
18786 )
18787 .await?;
18788 owner_session.append_run_terminal_event(&ended).await?;
18789 state.commit_run_completion(&ended).await?;
18790 let terminal_digest = ended
18791 .completion_digest
18792 .clone()
18793 .ok_or_else(|| "cancelled terminal is missing completion_digest".to_string())?;
18794 let response = run_cancel_receipt(
18795 &requested,
18796 car_proto::RunCancellationStatus::CancelledConfirmed,
18797 Some(terminal_digest),
18798 )?;
18799 owner_session
18800 .append_run_cancellation_result_event(&response)
18801 .await?;
18802 owner_session
18803 .clear_run_journal_binding(¶ms.run_id)
18804 .await?;
18805 let mut current = owner_session.current_run_id.lock().await;
18806 if current.as_deref() == Some(params.run_id.as_str()) {
18807 *current = None;
18808 }
18809 serde_json::to_value(response).map_err(|error| error.to_string())
18810}
18811
18812const RECORD_TURN_FIELD_CAP_BYTES: usize = 16 * 1024;
18823
18824const RECORD_TURN_TRUNC_MARKER: &str = "…[truncated]";
18827
18828const RECORD_TURN_MAX_BYTES: usize = 256 * 1024;
18837
18838const RECORD_TURN_OVERSIZE_MARKER: &str = "…[truncated: turn exceeded 256 KiB]";
18842
18843const RECORD_TURNS_MAX_BATCH: usize = 256;
18847
18848const RECORD_TURNS_RUN_CEILING: usize = crate::session::RECORD_TURNS_RUN_CEILING;
18863
18864fn truncate_turn_string(s: &mut String) {
18868 if s.len() > RECORD_TURN_FIELD_CAP_BYTES {
18869 let mut end = RECORD_TURN_FIELD_CAP_BYTES;
18871 while end > 0 && !s.is_char_boundary(end) {
18872 end -= 1;
18873 }
18874 s.truncate(end);
18875 s.push_str(RECORD_TURN_TRUNC_MARKER);
18876 }
18877}
18878
18879fn truncate_turn_value(value: &mut Value) {
18885 match value {
18886 Value::String(s) => truncate_turn_string(s),
18887 Value::Array(items) => {
18888 for item in items.iter_mut() {
18889 truncate_turn_value(item);
18890 }
18891 }
18892 Value::Object(map) => {
18893 for (_k, v) in map.iter_mut() {
18894 truncate_turn_value(v);
18895 }
18896 }
18897 _ => {}
18898 }
18899}
18900
18901const RECORD_TURN_REINDEX_HEADROOM: usize = 16;
18911
18912const RECORD_TURN_MEASURE_CAP: usize = RECORD_TURN_MAX_BYTES - RECORD_TURN_REINDEX_HEADROOM;
18916
18917fn turn_encoded_len(turn: &car_proto::RunTurn) -> usize {
18921 serde_json::to_vec(turn).map_or(usize::MAX, |v| v.len())
18922}
18923
18924pub(crate) fn enforce_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
18945 let car_proto::RunTurn {
18950 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;
18966
18967 if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
18968 return true;
18969 }
18970 if !turn.parameters.is_null() {
18972 turn.parameters = Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string());
18973 }
18974 if turn.output.is_some() {
18975 turn.output = Some(Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string()));
18976 }
18977 if turn.prompt.is_some() {
18978 turn.prompt = Some(RECORD_TURN_OVERSIZE_MARKER.to_string());
18979 }
18980 if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
18981 return true;
18982 }
18983 if let Some(tool) = turn.tool.as_mut() {
18986 truncate_turn_string(tool);
18987 }
18988 if let Some(pr) = turn.policy_rejected.as_mut() {
18989 truncate_turn_string(&mut pr.rule);
18990 if let Some(param) = pr.param.as_mut() {
18991 truncate_turn_string(param);
18992 }
18993 }
18994 turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19001}
19002
19003pub(crate) fn enforce_proposal_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19007 let parameters = turn.parameters.clone();
19008 if !enforce_turn_byte_cap(turn) {
19009 return false;
19010 }
19011 turn.parameters = parameters;
19012 turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19013}
19014
19015fn record_turns_dropped(run_id: &str, reason: &str) -> Result<Value, String> {
19019 serde_json::to_value(car_proto::RunRecordTurnsResponse {
19020 run_id: run_id.to_string(),
19021 base_index: 0,
19022 count: 0,
19023 ok: false,
19024 dropped: Some(reason.to_string()),
19025 })
19026 .map_err(|e| e.to_string())
19027}
19028
19029async fn handle_runs_record_turns(
19058 req: &JsonRpcMessage,
19059 session: &crate::session::ClientSession,
19060 state: &Arc<ServerState>,
19061) -> Result<Value, String> {
19062 let raw_run_id = req
19069 .params
19070 .get("run_id")
19071 .and_then(Value::as_str)
19072 .map(str::to_string);
19073 let run_id = match raw_run_id {
19074 Some(id) if !id.trim().is_empty() => id,
19075 _ => return Err("runs.record_turns requires { run_id, turns: [RunTurn] }".to_string()),
19076 };
19077
19078 let raw_turns =
19079 match req.params.get("turns").and_then(Value::as_array) {
19080 Some(arr) => arr,
19081 None => return Err(
19082 "runs.record_turns requires { run_id, turns: [RunTurn] }: `turns` must be an array"
19083 .to_string(),
19084 ),
19085 };
19086 if raw_turns.is_empty() {
19087 return Err("runs.record_turns requires a non-empty `turns` array".to_string());
19088 }
19089 if raw_turns.len() > RECORD_TURNS_MAX_BATCH {
19090 return Err(format!(
19091 "runs.record_turns batch too large: {} turns exceeds the per-call cap of {}",
19092 raw_turns.len(),
19093 RECORD_TURNS_MAX_BATCH
19094 ));
19095 }
19096
19097 let mut turns: Vec<car_proto::RunTurn> = Vec::with_capacity(raw_turns.len());
19102 for (i, raw) in raw_turns.iter().enumerate() {
19103 let mut obj = raw.clone();
19104 match obj.as_object_mut() {
19105 Some(map) => {
19106 map.insert("index".to_string(), Value::Number(0.into()));
19115 map.entry("verifier_verdict")
19116 .or_insert_with(|| Value::String("not_run".to_string()));
19117 if let Some(p) = map.get_mut("prompt") {
19120 truncate_turn_value(p);
19121 }
19122 if let Some(o) = map.get_mut("output") {
19123 truncate_turn_value(o);
19124 }
19125 if let Some(params) = map.get_mut("parameters") {
19126 truncate_turn_value(params);
19127 }
19128 }
19129 None => {
19130 return Err(format!(
19131 "runs.record_turns requires {{ run_id, turns: [RunTurn] }}: \
19132 turn {i} is not an object"
19133 ));
19134 }
19135 }
19136 let mut turn: car_proto::RunTurn = serde_json::from_value(obj).map_err(|e| {
19137 format!("runs.record_turns requires {{ run_id, turns: [RunTurn] }}: turn {i}: {e}")
19138 })?;
19139 if !enforce_turn_byte_cap(&mut turn) {
19146 tracing::warn!(
19147 run_id = %run_id,
19148 turn = i,
19149 "runs.record_turns: turn could not be bounded under the per-turn byte cap; dropping batch"
19150 );
19151 return record_turns_dropped(&run_id, "turn_too_large");
19152 }
19153 turns.push(turn);
19154 }
19155
19156 let header = state.run_header(&run_id).await;
19166 let owning_agent = match &header {
19167 Some((agent_id, _terminal, _len, _corruption)) => agent_id.clone(),
19168 None => match state.run_store.agent_for_run(&run_id) {
19169 Some(a) => a,
19170 None => return record_turns_dropped(&run_id, "run_not_found"),
19173 },
19174 };
19175
19176 let bound = session.agent_id.lock().await.clone();
19182 if bound.as_deref() != Some(owning_agent.as_str()) {
19183 tracing::debug!(
19184 run_id = %run_id,
19185 owning_agent = %owning_agent,
19186 client_id = %session.client_id,
19187 "runs.record_turns denied (not the owning agent); returning uniform not-found"
19188 );
19189 return record_turns_dropped(&run_id, "run_not_found");
19190 }
19191
19192 if let Some((_agent_id, _terminal, _len, Some(error))) = &header {
19193 return Err(error.clone());
19194 }
19195
19196 if let Some((_agent_id, terminal, len, _corruption)) = &header {
19204 if *terminal {
19205 return record_turns_dropped(&run_id, "run_terminal");
19206 }
19207 if *len + turns.len() > RECORD_TURNS_RUN_CEILING {
19215 return record_turns_dropped(&run_id, "run_turn_limit");
19216 }
19217 }
19218
19219 let count = turns.len();
19226 let records: Vec<car_proto::RunRecord> =
19227 turns.into_iter().map(car_proto::RunRecord::Turn).collect();
19228 let new_total = match state
19229 .record_run_turns_for_active_owner(&run_id, &session.client_id, records)
19230 .await
19231 {
19232 crate::session::RecordRunTurnsOutcome::Appended { new_total } => new_total,
19233 crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
19234 return record_turns_dropped(&run_id, "run_turn_limit");
19238 }
19239 crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {
19240 return record_turns_dropped(&run_id, "run_terminal");
19245 }
19246 crate::session::RecordRunTurnsOutcome::PersistenceFailed(error) => {
19247 if error.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
19248 return Err(error);
19249 }
19250 return Err(format!(
19251 "runs.record_turns persistence failed; retry exact batch safely: {error}"
19252 ));
19253 }
19254 };
19255
19256 serde_json::to_value(car_proto::RunRecordTurnsResponse {
19257 run_id,
19258 base_index: new_total - count,
19259 count,
19260 ok: true,
19261 dropped: None,
19262 })
19263 .map_err(|e| e.to_string())
19264}
19265
19266async fn authorize_run_access(
19302 session: &crate::session::ClientSession,
19303 _state: &Arc<ServerState>,
19304 owning_agent_id: &str,
19305) -> Result<(), String> {
19306 if let Some(bound) = session.agent_id.lock().await.clone() {
19308 if bound == owning_agent_id {
19309 return Ok(());
19310 }
19311 }
19312 if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
19320 return Ok(());
19321 }
19322 tracing::debug!(
19327 owning_agent = %owning_agent_id,
19328 client_id = %session.client_id,
19329 "run access denied: connection neither owns the agent nor is the host client"
19330 );
19331 Err(
19332 "not authorized to access this run: this connection neither owns the \
19333 owning agent (via session.auth) nor is the host management client"
19334 .to_string(),
19335 )
19336}
19337
19338async fn handle_runs_subscribe(
19354 req: &JsonRpcMessage,
19355 session: &crate::session::ClientSession,
19356 state: &Arc<ServerState>,
19357) -> Result<Value, String> {
19358 const MAX_LIMIT: usize = 500;
19359 #[derive(Deserialize)]
19360 struct LegacyRequest {
19361 run_id: String,
19362 }
19363 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19364 let (run_id, cursor, limit) = if paginated {
19365 let params: car_proto::RunSubscribeRequest = serde_json::from_value(req.params.clone())
19366 .map_err(|e| format!("runs.subscribe requires {{ run_id, cursor, limit }}: {e}"))?;
19367 (params.run_id, params.cursor, params.limit)
19368 } else {
19369 let params: LegacyRequest = serde_json::from_value(req.params.clone())
19370 .map_err(|e| format!("runs.subscribe requires {{ run_id }}: {e}"))?;
19371 (params.run_id, 0, crate::session::RECORD_TURNS_RUN_CEILING)
19372 };
19373 if !(1..=MAX_LIMIT).contains(&limit) && paginated {
19374 return Err(format!(
19375 "runs.subscribe limit must be between 1 and {MAX_LIMIT}"
19376 ));
19377 }
19378
19379 let not_found = || {
19382 serde_json::to_value(serde_json::json!({
19383 "run_id": run_id,
19384 "not_found": true,
19385 }))
19386 .map_err(|e| e.to_string())
19387 };
19388
19389 let owning_agent = match state.run_header(&run_id).await {
19393 Some((agent_id, _, _, _)) => agent_id,
19394 None => {
19395 let run_store = state.run_store.clone();
19396 let durable_run_id = run_id.clone();
19397 match run_store_blocking("runs.subscribe owner lookup", move || {
19398 Ok(run_store.agent_for_run(&durable_run_id))
19399 })
19400 .await?
19401 {
19402 Some(agent_id) => agent_id,
19403 None => return not_found(),
19404 }
19405 }
19406 };
19407
19408 if authorize_run_access(session, state, &owning_agent)
19412 .await
19413 .is_err()
19414 {
19415 tracing::debug!(
19416 run_id = %run_id,
19417 owning_agent = %owning_agent,
19418 client_id = %session.client_id,
19419 "runs.subscribe denied (unauthorized); returning uniform not-found"
19420 );
19421 return not_found();
19422 }
19423
19424 let page = match state
19425 .subscribe_run_page(
19426 &run_id,
19427 &session.client_id,
19428 session.channel.clone(),
19429 cursor,
19430 limit,
19431 )
19432 .await?
19433 {
19434 Some(crate::session::RunSubscribePageResult::Ready(page)) => page,
19435 Some(crate::session::RunSubscribePageResult::Durable { agent_id, status }) => {
19436 let run_store = state.run_store.clone();
19437 let durable_run_id = run_id.clone();
19438 let page = strict_run_trace_read_blocking(
19439 "runs.subscribe durable page",
19440 state,
19441 run_id.clone(),
19442 move || run_store.get_run_turn_page_for(&agent_id, &durable_run_id, cursor, limit),
19443 )
19444 .await?;
19445 let Some((turns, next_cursor, live_cursor, durable_status)) = page else {
19446 return not_found();
19447 };
19448 let status = match durable_status {
19449 crate::run_store::RunStatus::InProgress => status,
19450 crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19451 crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19452 crate::run_store::RunStatus::CancellationPending => {
19453 car_proto::RunLiveStatus::CancellationPending
19454 }
19455 crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19456 };
19457 car_proto::RunSubscribeResponse {
19458 run_id: run_id.clone(),
19459 agent_id: owning_agent.clone(),
19460 turns,
19461 cursor,
19462 limit,
19463 next_cursor,
19464 live_cursor,
19465 subscribed: next_cursor.is_none(),
19466 status,
19467 }
19468 }
19469 None => {
19474 let run_store = state.run_store.clone();
19475 let durable_run_id = run_id.clone();
19476 let durable_agent = owning_agent.clone();
19477 let page = strict_run_trace_read_blocking(
19478 "runs.subscribe retained durable page",
19479 state,
19480 run_id.clone(),
19481 move || {
19482 run_store.get_run_turn_page_for(&durable_agent, &durable_run_id, cursor, limit)
19483 },
19484 )
19485 .await?;
19486 if let Some((turns, next_cursor, live_cursor, durable_status)) = page {
19487 let status = match durable_status {
19488 crate::run_store::RunStatus::InProgress => car_proto::RunLiveStatus::InProgress,
19489 crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19490 crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19491 crate::run_store::RunStatus::CancellationPending => {
19492 car_proto::RunLiveStatus::CancellationPending
19493 }
19494 crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19495 };
19496 car_proto::RunSubscribeResponse {
19497 run_id: run_id.clone(),
19498 agent_id: owning_agent.clone(),
19499 turns,
19500 cursor,
19501 limit,
19502 next_cursor,
19503 live_cursor,
19504 subscribed: next_cursor.is_none(),
19505 status,
19506 }
19507 } else if !paginated {
19508 let run_store = state.run_store.clone();
19514 let durable_run_id = run_id.clone();
19515 let durable_agent = owning_agent.clone();
19516 let records = strict_run_trace_read_blocking(
19517 "runs.subscribe pre-v3 durable trace",
19518 state,
19519 run_id.clone(),
19520 move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19521 )
19522 .await?;
19523 let Some(records) = records else {
19524 return not_found();
19525 };
19526 let mut status = car_proto::RunLiveStatus::InProgress;
19527 let mut turns = Vec::new();
19528 for record in records {
19529 match record {
19530 turn @ car_proto::RunRecord::Turn(_) => turns.push(turn),
19531 car_proto::RunRecord::CancellationRequested(_)
19532 | car_proto::RunRecord::CancellationResult(_) => {
19533 if status == car_proto::RunLiveStatus::InProgress {
19534 status = car_proto::RunLiveStatus::CancellationPending;
19535 }
19536 }
19537 car_proto::RunRecord::Ended(ended) => {
19538 status = match ended.termination {
19539 car_proto::RunTermination::Outcome { .. } => {
19540 car_proto::RunLiveStatus::Completed
19541 }
19542 car_proto::RunTermination::Incomplete => {
19543 car_proto::RunLiveStatus::Incomplete
19544 }
19545 car_proto::RunTermination::Cancelled { .. } => {
19546 car_proto::RunLiveStatus::Cancelled
19547 }
19548 };
19549 }
19550 car_proto::RunRecord::Started(_) => {}
19551 }
19552 }
19553 let live_cursor = turns.len();
19554 car_proto::RunSubscribeResponse {
19555 run_id: run_id.clone(),
19556 agent_id: owning_agent.clone(),
19557 turns,
19558 cursor: 0,
19559 limit,
19560 next_cursor: None,
19561 live_cursor,
19562 subscribed: false,
19563 status,
19564 }
19565 } else {
19566 return not_found();
19567 }
19568 }
19569 };
19570 if paginated {
19571 serde_json::to_value(page).map_err(|e| e.to_string())
19572 } else {
19573 serde_json::to_value(serde_json::json!({
19574 "run_id": page.run_id,
19575 "agent_id": page.agent_id,
19576 "turns_so_far": page.turns,
19577 "cursor": page.live_cursor,
19578 "status": page.status,
19579 }))
19580 .map_err(|e| e.to_string())
19581 }
19582}
19583
19584async fn handle_runs_unsubscribe(
19589 req: &JsonRpcMessage,
19590 session: &crate::session::ClientSession,
19591 state: &Arc<ServerState>,
19592) -> Result<Value, String> {
19593 let params: car_proto::RunUnsubscribeRequest = serde_json::from_value(req.params.clone())
19594 .map_err(|e| format!("runs.unsubscribe requires {{ run_id }}: {e}"))?;
19595 let removed = state
19596 .unsubscribe_run(¶ms.run_id, &session.client_id)
19597 .await;
19598 serde_json::to_value(car_proto::RunUnsubscribeResponse {
19599 run_id: params.run_id,
19600 removed,
19601 })
19602 .map_err(|e| e.to_string())
19603}
19604
19605async fn handle_runs_list(
19617 req: &JsonRpcMessage,
19618 session: &crate::session::ClientSession,
19619 state: &Arc<ServerState>,
19620) -> Result<Value, String> {
19621 #[derive(Deserialize)]
19622 struct LegacyRequest {
19623 agent_id: String,
19624 }
19625 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19626 let (agent_id, page) = if paginated {
19627 let params: car_proto::RunListRequest = serde_json::from_value(req.params.clone())
19628 .map_err(|e| format!("runs.list requires {{ agent_id, cursor, limit }}: {e}"))?;
19629 (params.agent_id, Some((params.cursor, params.limit)))
19630 } else {
19631 let params: LegacyRequest = serde_json::from_value(req.params.clone())
19632 .map_err(|e| format!("runs.list requires {{ agent_id }}: {e}"))?;
19633 (params.agent_id, None)
19634 };
19635
19636 authorize_run_access(session, state, &agent_id).await?;
19639
19640 const MAX_LIMIT: usize = 200;
19641 if let Some((_, limit)) = page {
19642 if !(1..=MAX_LIMIT).contains(&limit) {
19643 return Err(format!("runs.list limit must be between 1 and {MAX_LIMIT}"));
19644 }
19645 }
19646 let run_store = state.run_store.clone();
19647 let durable_agent_id = agent_id.clone();
19648 let Some((cursor, limit)) = page else {
19649 let runs = run_store_blocking("runs.list legacy complete read", move || {
19650 Ok(run_store.list_runs(&durable_agent_id))
19651 })
19652 .await?;
19653 return Ok(serde_json::json!({"agent_id": agent_id, "runs": runs}));
19654 };
19655 let (runs, next_cursor) = run_store_blocking("runs.list page", move || {
19656 run_store
19657 .list_runs_page(&durable_agent_id, cursor, limit)
19658 .map_err(|error| format!("runs.list durable index read failed: {error}"))
19659 })
19660 .await?;
19661 let mut response = serde_json::json!({
19662 "agent_id": agent_id,
19663 "runs": runs,
19664 "cursor": cursor,
19665 "limit": limit,
19666 });
19667 if let Some(next_cursor) = next_cursor {
19668 response["next_cursor"] = serde_json::json!(next_cursor);
19669 }
19670 Ok(response)
19671}
19672
19673async fn handle_runs_get_trace(
19688 req: &JsonRpcMessage,
19689 session: &crate::session::ClientSession,
19690 state: &Arc<ServerState>,
19691) -> Result<Value, String> {
19692 #[derive(Deserialize)]
19693 struct LegacyRequest {
19694 run_id: String,
19695 #[serde(default)]
19696 cursor: Option<usize>,
19697 }
19698 let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19699 let (run_id, cursor, limit) = if paginated {
19700 let params: car_proto::RunGetTraceRequest = serde_json::from_value(req.params.clone())
19701 .map_err(|e| format!("runs.get_trace requires {{ run_id, cursor, limit }}: {e}"))?;
19702 (params.run_id, params.cursor, Some(params.limit))
19703 } else {
19704 let params: LegacyRequest = serde_json::from_value(req.params.clone())
19705 .map_err(|e| format!("runs.get_trace requires {{ run_id }}: {e}"))?;
19706 (params.run_id, params.cursor.unwrap_or(0), None)
19707 };
19708 const MAX_LIMIT: usize = 500;
19709 if let Some(limit) = limit {
19710 if !(1..=MAX_LIMIT).contains(&limit) {
19711 return Err(format!(
19712 "runs.get_trace limit must be between 1 and {MAX_LIMIT}"
19713 ));
19714 }
19715 }
19716
19717 let run_store = state.run_store.clone();
19724 let durable_run_id = run_id.clone();
19725 let owning_agent = run_store_blocking("runs.get_trace owner lookup", move || {
19726 Ok(run_store.agent_for_run(&durable_run_id))
19727 })
19728 .await?;
19729 let Some(owning_agent) = owning_agent else {
19730 return serde_json::to_value(serde_json::json!({
19731 "run_id": run_id,
19732 "not_found": true,
19733 }))
19734 .map_err(|e| e.to_string());
19735 };
19736
19737 authorize_run_access(session, state, &owning_agent).await?;
19739
19740 let run_store = state.run_store.clone();
19743 let durable_agent = owning_agent.clone();
19744 let durable_run_id = run_id.clone();
19745 if limit.is_none() {
19746 let records = strict_run_trace_read_blocking(
19747 "runs.get_trace legacy complete read",
19748 state,
19749 run_id.clone(),
19750 move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19751 )
19752 .await?;
19753 let Some(records) = records else {
19754 return Ok(serde_json::json!({"run_id": run_id, "not_found": true}));
19755 };
19756 let records = records.into_iter().skip(cursor).collect::<Vec<_>>();
19757 return Ok(serde_json::json!({
19758 "run_id": run_id,
19759 "agent_id": owning_agent,
19760 "records": records,
19761 "cursor": cursor,
19762 }));
19763 }
19764 let limit = limit.expect("paginated path has a limit");
19765 let page =
19766 strict_run_trace_read_blocking("runs.get_trace page", state, run_id.clone(), move || {
19767 run_store.get_run_trace_page_for(&durable_agent, &durable_run_id, cursor, limit)
19768 })
19769 .await?;
19770 let Some((paged, next_cursor)) = page else {
19771 return serde_json::to_value(serde_json::json!({
19772 "run_id": run_id,
19773 "not_found": true,
19774 }))
19775 .map_err(|e| e.to_string());
19776 };
19777
19778 let mut response = serde_json::json!({
19779 "run_id": run_id,
19780 "agent_id": owning_agent,
19781 "records": paged,
19782 "cursor": cursor,
19783 "limit": limit,
19784 });
19785 if let Some(next_cursor) = next_cursor {
19786 response["next_cursor"] = serde_json::json!(next_cursor);
19787 }
19788 Ok(response)
19789}
19790
19791fn run_trace_read_error(run_id: &str, error: std::io::Error) -> String {
19792 if crate::run_store::is_trace_corruption_error(&error) {
19793 format!(
19794 "{} run `{run_id}`: {error}",
19795 car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX
19796 )
19797 } else {
19798 format!("run trace read failed for `{run_id}`: {error}")
19799 }
19800}
19801
19802async fn handle_replan_set_config(
19807 msg: &JsonRpcMessage,
19808 session: &crate::session::ClientSession,
19809) -> Result<Value, String> {
19810 let max_replans = msg
19811 .params
19812 .get("max_replans")
19813 .and_then(|v| v.as_u64())
19814 .unwrap_or(0) as u32;
19815 let delay_ms = msg
19816 .params
19817 .get("delay_ms")
19818 .and_then(|v| v.as_u64())
19819 .unwrap_or(0);
19820 let verify_before_execute = msg
19821 .params
19822 .get("verify_before_execute")
19823 .and_then(|v| v.as_bool())
19824 .unwrap_or(true);
19825 let replan_on_rejected = msg
19826 .params
19827 .get("replan_on_rejected")
19828 .and_then(|v| v.as_bool())
19829 .unwrap_or(false);
19830 let cfg = car_engine::ReplanConfig {
19831 max_replans,
19832 delay_ms,
19833 verify_before_execute,
19834 replan_on_rejected,
19835 };
19836 session.runtime.set_replan_config(cfg).await;
19837 Ok(Value::Null)
19838}
19839
19840async fn handle_skills_list(
19841 msg: &JsonRpcMessage,
19842 session: &crate::session::ClientSession,
19843) -> Result<Value, String> {
19844 let domain = msg.params.get("domain").and_then(|v| v.as_str());
19845 let engine = session.memgine.lock().await;
19846 let skills: Vec<serde_json::Value> = engine
19847 .graph
19848 .inner
19849 .node_indices()
19850 .filter_map(|nix| {
19851 let node = engine.graph.inner.node_weight(nix)?;
19852 if node.kind != car_memgine::MemKind::Skill {
19853 return None;
19854 }
19855 let meta = car_memgine::SkillMeta::from_node(node)?;
19856 if let Some(d) = domain {
19857 match &meta.scope {
19858 car_memgine::SkillScope::Global => {}
19859 car_memgine::SkillScope::Domain(sd) if sd == d => {}
19860 _ => return None,
19861 }
19862 }
19863 Some(serde_json::to_value(&meta).unwrap_or_default())
19864 })
19865 .collect();
19866 serde_json::to_value(&skills).map_err(|e| e.to_string())
19867}
19868
19869#[derive(serde::Deserialize)]
19870struct SecretParams {
19871 #[serde(default)]
19872 service: Option<String>,
19873 key: String,
19874 #[serde(default)]
19875 value: Option<String>,
19876 #[serde(default)]
19880 operator_broker: bool,
19881}
19882
19883fn canonical_secret_key(key: &str) -> &str {
19884 if key.eq_ignore_ascii_case("openrouter") {
19885 car_inference::openrouter::API_KEY_ENV
19886 } else {
19887 key
19888 }
19889}
19890
19891fn reject_reserved_oauth_secret(service: Option<&str>, key: &str) -> Result<(), String> {
19892 if car_inference::openrouter::is_reserved_oauth_secret(service, key) {
19893 Err("reserved_private_secret: the OpenRouter OAuth credential is managed only by openrouter.auth_start/openrouter.disconnect".to_string())
19894 } else {
19895 Ok(())
19896 }
19897}
19898
19899fn is_openrouter_pasted_authority_slot(service: Option<&str>, key: &str) -> bool {
19900 let is_default_service = service.is_none() || service == Some(car_secrets::DEFAULT_SERVICE);
19901 is_default_service && key == car_inference::openrouter::API_KEY_ENV
19902}
19903
19904async fn handle_secret_put(req: &JsonRpcMessage) -> Result<Value, String> {
19905 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
19906 let value = p.value.ok_or_else(|| "missing 'value'".to_string())?;
19907 let key = canonical_secret_key(&p.key);
19908 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
19909 if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
19910 return car_ffi_common::secrets::put(p.service.as_deref(), key, &value);
19911 }
19912 let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(true, || {
19913 car_ffi_common::secrets::put(p.service.as_deref(), key, &value)
19914 })
19915 .await;
19916 result.map(|value| with_openrouter_authority_generation(value, &final_status))
19917}
19918
19919async fn handle_secret_get(
19920 req: &JsonRpcMessage,
19921 session: &crate::session::ClientSession,
19922 state: &crate::session::ServerState,
19923) -> Result<Value, String> {
19924 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
19925 let key = canonical_secret_key(&p.key);
19926 let agent_id = session.agent_id.lock().await.clone();
19927
19928 if p.operator_broker {
19929 if state.auth_token.get().is_none()
19935 || !session
19936 .authenticated
19937 .load(std::sync::atomic::Ordering::Acquire)
19938 || !session.is_host.load(std::sync::atomic::Ordering::Acquire)
19939 || agent_id.is_some()
19940 {
19941 return Err(
19942 "operator_broker_denied: secret.get operator broker requires an authenticated local host session"
19943 .to_string(),
19944 );
19945 }
19946 let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
19947 let action = car_ir::Action::tool_call("secret.get")
19948 .with_param("service", Value::String(service.to_string()))
19949 .with_param("key", Value::String(key.to_string()))
19950 .with_param("caller_role", Value::String("operator".to_string()));
19951 let result =
19952 car_ffi_common::secrets::get_for_daemon_operator_broker(p.service.as_deref(), key);
19953 let (kind, outcome) = if result.is_ok() {
19954 (car_eventlog::EventKind::ActionSucceeded, "read")
19955 } else {
19956 (car_eventlog::EventKind::ActionFailed, "secret_store_error")
19957 };
19958 session.runtime.log.lock().await.append(
19959 kind,
19960 Some(&action.id),
19961 None,
19962 operator_secret_read_audit_data(service, key, outcome),
19963 );
19964 return result;
19965 }
19966
19967 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
19968
19969 let Some(agent_id) = agent_id else {
19970 return car_ffi_common::secrets::get(p.service.as_deref(), key);
19975 };
19976 let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
19977 let action = car_ir::Action::tool_call("secret.get")
19978 .with_param("service", Value::String(service.to_string()))
19979 .with_param("key", Value::String(key.to_string()))
19980 .with_param("agent_id", Value::String(agent_id.clone()));
19981 let violations = session
19982 .runtime
19983 .policies
19984 .read()
19985 .await
19986 .check(&action, &session.runtime.state);
19987 if !violations.is_empty() {
19988 let policies: Vec<Value> = violations
19989 .iter()
19990 .map(|violation| Value::String(violation.policy_name.clone()))
19991 .collect();
19992 let mut data = secret_read_audit_data(service, key, &agent_id, "policy_denied");
19993 data.insert("policies".to_string(), Value::Array(policies.clone()));
19994 session.runtime.log.lock().await.append(
19995 car_eventlog::EventKind::PolicyViolation,
19996 Some(&action.id),
19997 None,
19998 data,
19999 );
20000 return Err(serde_json::json!({
20001 "code": "policy_denied",
20002 "message": "secret.get was denied by the daemon policy engine",
20003 "context": {"service": service, "key": key, "agent_id": agent_id},
20004 "policies": policies,
20005 })
20006 .to_string());
20007 }
20008
20009 let result = car_ffi_common::secrets::get(p.service.as_deref(), key);
20010 let (kind, outcome) = if result.is_ok() {
20011 (car_eventlog::EventKind::ActionSucceeded, "read")
20012 } else {
20013 (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20014 };
20015 session.runtime.log.lock().await.append(
20016 kind,
20017 Some(&action.id),
20018 None,
20019 secret_read_audit_data(service, key, &agent_id, outcome),
20020 );
20021 result
20022}
20023
20024fn secret_read_audit_data(
20025 service: &str,
20026 key: &str,
20027 agent_id: &str,
20028 outcome: &str,
20029) -> HashMap<String, Value> {
20030 HashMap::from([
20031 ("tool".to_string(), Value::String("secret.get".to_string())),
20032 ("service".to_string(), Value::String(service.to_string())),
20033 ("key".to_string(), Value::String(key.to_string())),
20034 ("agent_id".to_string(), Value::String(agent_id.to_string())),
20035 ("outcome".to_string(), Value::String(outcome.to_string())),
20036 ])
20037}
20038
20039fn operator_secret_read_audit_data(
20040 service: &str,
20041 key: &str,
20042 outcome: &str,
20043) -> HashMap<String, Value> {
20044 HashMap::from([
20045 ("tool".to_string(), Value::String("secret.get".to_string())),
20046 ("service".to_string(), Value::String(service.to_string())),
20047 ("key".to_string(), Value::String(key.to_string())),
20048 (
20049 "caller_role".to_string(),
20050 Value::String("operator".to_string()),
20051 ),
20052 ("outcome".to_string(), Value::String(outcome.to_string())),
20053 ])
20054}
20055
20056async fn handle_secret_delete(req: &JsonRpcMessage) -> Result<Value, String> {
20057 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20058 let key = canonical_secret_key(&p.key);
20059 reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20060 if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20061 return car_ffi_common::secrets::delete(p.service.as_deref(), key);
20062 }
20063 let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(false, || {
20064 car_ffi_common::secrets::delete(p.service.as_deref(), key)
20065 })
20066 .await;
20067 result.map(|value| with_openrouter_authority_generation(value, &final_status))
20068}
20069
20070fn with_openrouter_authority_generation(
20071 mut value: Value,
20072 status: &crate::openrouter_auth::Status,
20073) -> Value {
20074 if let Value::Object(fields) = &mut value {
20075 fields.insert(
20076 "authority_generation".to_string(),
20077 Value::from(status.authority_generation),
20078 );
20079 return value;
20080 }
20081 serde_json::json!({
20082 "ok": true,
20083 "authority_generation": status.authority_generation,
20084 "result": value,
20085 })
20086}
20087
20088fn handle_secret_status(req: &JsonRpcMessage) -> Result<Value, String> {
20089 let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20090 car_ffi_common::secrets::status(p.service.as_deref(), canonical_secret_key(&p.key))
20091}
20092
20093#[cfg(test)]
20094mod openrouter_secret_service_authority_tests {
20095 use super::{handle_secret_delete, handle_secret_put, handle_secret_status, JsonRpcMessage};
20096 use serde_json::{json, Value};
20097
20098 static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
20099
20100 struct FileSecretEnvironment(Option<std::ffi::OsString>);
20101
20102 impl FileSecretEnvironment {
20103 fn install(path: &std::path::Path) -> Self {
20104 let previous = std::env::var_os("CAR_SECRETS_FILE_DIR");
20105 unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", path) };
20106 Self(previous)
20107 }
20108 }
20109
20110 impl Drop for FileSecretEnvironment {
20111 fn drop(&mut self) {
20112 match self.0.take() {
20113 Some(previous) => unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", previous) },
20114 None => unsafe { std::env::remove_var("CAR_SECRETS_FILE_DIR") },
20115 }
20116 }
20117 }
20118
20119 fn req(params: Value) -> JsonRpcMessage {
20120 JsonRpcMessage {
20121 jsonrpc: "2.0".into(),
20122 method: None,
20123 params,
20124 id: json!(1),
20125 result: None,
20126 error: None,
20127 }
20128 }
20129
20130 #[tokio::test(flavor = "current_thread")]
20131 async fn non_default_service_put_is_generic_and_does_not_publish_pasted_authority() {
20132 let _env_lock = ENV_LOCK.lock().await;
20133 let temp = tempfile::TempDir::new().unwrap();
20134 let _environment = FileSecretEnvironment::install(temp.path());
20135 let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20136
20137 let result = handle_secret_put(&req(json!({
20138 "service": "integration-test-vault",
20139 "key": "openrouter",
20140 "value": "custom-service-only"
20141 })))
20142 .await
20143 .unwrap();
20144 assert_eq!(result["service"], "integration-test-vault");
20145 assert_eq!(result["key"], car_inference::openrouter::API_KEY_ENV);
20146 assert!(
20147 result.get("authority_generation").is_none(),
20148 "a generic service write must not claim it changed OpenRouter authority: {result}"
20149 );
20150 let generic_status = handle_secret_status(&req(json!({
20151 "service": "integration-test-vault",
20152 "key": "openrouter"
20153 })))
20154 .unwrap();
20155 assert_eq!(generic_status["exists"], true);
20156 assert_eq!(
20157 serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20158 authority_before,
20159 "a non-default service cannot supersede OAuth/default-service resolution"
20160 );
20161 }
20162
20163 #[tokio::test(flavor = "current_thread")]
20164 async fn non_default_service_delete_is_generic_and_does_not_publish_false_absence() {
20165 let _env_lock = ENV_LOCK.lock().await;
20166 let temp = tempfile::TempDir::new().unwrap();
20167 let _environment = FileSecretEnvironment::install(temp.path());
20168 car_ffi_common::secrets::put(
20169 Some("integration-test-vault-delete"),
20170 car_inference::openrouter::API_KEY_ENV,
20171 "custom-service-only",
20172 )
20173 .unwrap();
20174 let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20175
20176 let result = handle_secret_delete(&req(json!({
20177 "service": "integration-test-vault-delete",
20178 "key": "openrouter"
20179 })))
20180 .await
20181 .unwrap();
20182 assert_eq!(result["service"], "integration-test-vault-delete");
20183 assert!(
20184 result.get("authority_generation").is_none(),
20185 "a generic service delete must not claim it changed OpenRouter authority: {result}"
20186 );
20187 let generic_status = handle_secret_status(&req(json!({
20188 "service": "integration-test-vault-delete",
20189 "key": "openrouter"
20190 })))
20191 .unwrap();
20192 assert_eq!(generic_status["exists"], false);
20193 assert_eq!(
20194 serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20195 authority_before,
20196 "deleting another service's key cannot publish false default-service absence"
20197 );
20198 }
20199}
20200
20201#[derive(serde::Deserialize)]
20202struct PermParams {
20203 domain: String,
20204 #[serde(default)]
20205 target_bundle_id: Option<String>,
20206}
20207
20208fn handle_perm_status(req: &JsonRpcMessage) -> Result<Value, String> {
20209 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20210 car_ffi_common::permissions::status(&p.domain, p.target_bundle_id.as_deref())
20211}
20212
20213fn handle_perm_request(req: &JsonRpcMessage) -> Result<Value, String> {
20214 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20215 car_ffi_common::permissions::request(&p.domain, p.target_bundle_id.as_deref())
20216}
20217
20218fn handle_perm_explain(req: &JsonRpcMessage) -> Result<Value, String> {
20219 let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20220 car_ffi_common::permissions::explain(&p.domain, p.target_bundle_id.as_deref())
20221}
20222
20223fn handle_calendar_events(req: &JsonRpcMessage) -> Result<Value, String> {
20224 #[derive(serde::Deserialize)]
20225 struct P {
20226 start: String,
20227 end: String,
20228 #[serde(default)]
20229 calendar_ids: Vec<String>,
20230 }
20231 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20232 let start = chrono::DateTime::parse_from_rfc3339(&p.start)
20233 .map_err(|e| format!("parse start: {}", e))?
20234 .with_timezone(&chrono::Utc);
20235 let end = chrono::DateTime::parse_from_rfc3339(&p.end)
20236 .map_err(|e| format!("parse end: {}", e))?
20237 .with_timezone(&chrono::Utc);
20238 car_ffi_common::integrations::calendar_events(start, end, &p.calendar_ids)
20239}
20240
20241fn handle_calendar_create_event(req: &JsonRpcMessage) -> Result<Value, String> {
20242 let raw = req.params.to_string();
20243 car_ffi_common::integrations::calendar_create_event(&raw)
20244}
20245
20246fn handle_calendar_update_event(req: &JsonRpcMessage) -> Result<Value, String> {
20247 let raw = req.params.to_string();
20248 car_ffi_common::integrations::calendar_update_event(&raw)
20249}
20250
20251fn handle_calendar_delete_event(req: &JsonRpcMessage) -> Result<Value, String> {
20252 #[derive(serde::Deserialize)]
20253 struct P {
20254 event_id: String,
20255 }
20256 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20257 car_ffi_common::integrations::calendar_delete_event(&p.event_id)
20258}
20259
20260fn handle_contacts_find(req: &JsonRpcMessage) -> Result<Value, String> {
20261 #[derive(serde::Deserialize)]
20262 struct P {
20263 query: String,
20264 #[serde(default = "default_limit")]
20265 limit: usize,
20266 #[serde(default)]
20267 container_ids: Vec<String>,
20268 }
20269 fn default_limit() -> usize {
20270 50
20271 }
20272 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20273 car_ffi_common::integrations::contacts_list(&p.query, &p.container_ids, p.limit)
20274}
20275
20276fn handle_mail_inbox(req: &JsonRpcMessage) -> Result<Value, String> {
20277 #[derive(serde::Deserialize, Default)]
20278 struct P {
20279 #[serde(default)]
20280 account_ids: Vec<String>,
20281 }
20282 let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20283 car_ffi_common::integrations::mail_inbox(&p.account_ids)
20284}
20285
20286fn handle_mail_mailboxes(req: &JsonRpcMessage) -> Result<Value, String> {
20287 #[derive(serde::Deserialize, Default)]
20288 struct P {
20289 #[serde(default)]
20290 account_ids: Vec<String>,
20291 }
20292 let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20293 car_ffi_common::integrations::mail_mailboxes(&p.account_ids)
20294}
20295
20296fn handle_mail_messages(req: &JsonRpcMessage) -> Result<Value, String> {
20301 let raw = if req.params.is_null() {
20302 "{}".to_string()
20303 } else {
20304 req.params.to_string()
20305 };
20306 car_ffi_common::integrations::mail_messages(&raw)
20307}
20308
20309fn handle_mail_message_body(req: &JsonRpcMessage) -> Result<Value, String> {
20310 #[derive(serde::Deserialize)]
20311 struct P {
20312 message_id: String,
20313 }
20314 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20315 car_ffi_common::integrations::mail_message_body(&p.message_id)
20316}
20317
20318fn handle_mail_send(req: &JsonRpcMessage) -> Result<Value, String> {
20319 let raw = req.params.to_string();
20320 car_ffi_common::integrations::mail_send(&raw)
20321}
20322
20323fn handle_messages_chats(req: &JsonRpcMessage) -> Result<Value, String> {
20324 #[derive(serde::Deserialize)]
20325 struct P {
20326 #[serde(default = "default_limit")]
20327 limit: usize,
20328 }
20329 fn default_limit() -> usize {
20330 50
20331 }
20332 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20333 car_ffi_common::integrations::messages_chats(p.limit)
20334}
20335
20336fn handle_messages_read(req: &JsonRpcMessage) -> Result<Value, String> {
20339 let raw = if req.params.is_null() {
20340 "{}".to_string()
20341 } else {
20342 req.params.to_string()
20343 };
20344 car_ffi_common::integrations::messages_read(&raw)
20345}
20346
20347fn handle_messages_send(req: &JsonRpcMessage) -> Result<Value, String> {
20348 let raw = req.params.to_string();
20349 car_ffi_common::integrations::messages_send(&raw)
20350}
20351
20352fn handle_notes_find(req: &JsonRpcMessage) -> Result<Value, String> {
20353 #[derive(serde::Deserialize)]
20354 struct P {
20355 query: String,
20356 #[serde(default = "default_limit")]
20357 limit: usize,
20358 }
20359 fn default_limit() -> usize {
20360 50
20361 }
20362 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20363 car_ffi_common::integrations::notes_find(&p.query, p.limit)
20364}
20365
20366fn handle_reminders_items(req: &JsonRpcMessage) -> Result<Value, String> {
20367 #[derive(serde::Deserialize)]
20368 struct P {
20369 #[serde(default = "default_limit")]
20370 limit: usize,
20371 }
20372 fn default_limit() -> usize {
20373 50
20374 }
20375 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20376 car_ffi_common::integrations::reminders_items(p.limit)
20377}
20378
20379fn handle_bookmarks_list(req: &JsonRpcMessage) -> Result<Value, String> {
20380 #[derive(serde::Deserialize)]
20381 struct P {
20382 #[serde(default = "default_limit")]
20383 limit: usize,
20384 }
20385 fn default_limit() -> usize {
20386 100
20387 }
20388 let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 100 });
20389 car_ffi_common::integrations::bookmarks_list(p.limit)
20390}
20391
20392fn handle_health_sleep(req: &JsonRpcMessage) -> Result<Value, String> {
20393 #[derive(serde::Deserialize)]
20394 struct P {
20395 start: String,
20396 end: String,
20397 }
20398 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20399 let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20400 .map_err(|e| format!("parse start: {}", e))?
20401 .with_timezone(&chrono::Utc);
20402 let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20403 .map_err(|e| format!("parse end: {}", e))?
20404 .with_timezone(&chrono::Utc);
20405 car_ffi_common::health::sleep_windows(s, e)
20406}
20407
20408fn handle_health_workouts(req: &JsonRpcMessage) -> Result<Value, String> {
20409 #[derive(serde::Deserialize)]
20410 struct P {
20411 start: String,
20412 end: String,
20413 }
20414 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20415 let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20416 .map_err(|e| format!("parse start: {}", e))?
20417 .with_timezone(&chrono::Utc);
20418 let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20419 .map_err(|e| format!("parse end: {}", e))?
20420 .with_timezone(&chrono::Utc);
20421 car_ffi_common::health::workouts(s, e)
20422}
20423
20424fn handle_health_activity(req: &JsonRpcMessage) -> Result<Value, String> {
20425 #[derive(serde::Deserialize)]
20426 struct P {
20427 start: String,
20428 end: String,
20429 }
20430 let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20431 let s = chrono::NaiveDate::parse_from_str(&p.start, "%Y-%m-%d")
20432 .map_err(|e| format!("parse start: {}", e))?;
20433 let e = chrono::NaiveDate::parse_from_str(&p.end, "%Y-%m-%d")
20434 .map_err(|e| format!("parse end: {}", e))?;
20435 car_ffi_common::health::activity(s, e)
20436}
20437
20438async fn handle_browser_close(session: &crate::session::ClientSession) -> Result<Value, String> {
20439 let closed = session.browser.close().await?;
20440 Ok(serde_json::json!({"closed": closed}))
20441}
20442
20443async fn handle_browser_run(
20444 req: &JsonRpcMessage,
20445 session: &crate::session::ClientSession,
20446) -> Result<Value, String> {
20447 #[derive(serde::Deserialize)]
20448 struct BrowserRunParams {
20449 script: Value,
20451 #[serde(default)]
20452 width: Option<u32>,
20453 #[serde(default)]
20454 height: Option<u32>,
20455 #[serde(default)]
20460 headed: Option<bool>,
20461 #[serde(default)]
20464 extra_args: Option<Vec<String>>,
20465 }
20466 let params: BrowserRunParams =
20467 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20468
20469 let script_json = browser_script_json(params.script)?;
20474
20475 let browser_session = session
20476 .browser
20477 .get_or_launch(car_ffi_common::browser::BrowserLaunchOptions {
20478 width: params.width.unwrap_or(1280),
20479 height: params.height.unwrap_or(720),
20480 headless: !params.headed.unwrap_or(false),
20481 extra_args: params.extra_args.unwrap_or_default(),
20482 profile_dir: None,
20487 })
20488 .await?;
20489
20490 let trace_json = browser_session.run(&script_json).await?;
20491 serde_json::from_str(&trace_json).map_err(|e| e.to_string())
20492}
20493
20494fn browser_script_json(script: Value) -> Result<String, String> {
20495 match script {
20496 Value::String(script) => Ok(script),
20497 script @ Value::Object(_) => Ok(script.to_string()),
20498 _ => Err("browser.run script must be a string or object".to_string()),
20499 }
20500}
20501
20502#[cfg(test)]
20503mod browser_run_script_tests {
20504 use super::browser_script_json;
20505 use serde_json::{json, Value};
20506
20507 #[test]
20508 fn accepts_only_the_documented_script_shapes_before_browser_launch() {
20509 assert_eq!(
20510 browser_script_json(Value::String(r#"{"operations":[]}"#.to_string()))
20511 .expect("inline JSON string"),
20512 r#"{"operations":[]}"#
20513 );
20514 assert_eq!(
20515 serde_json::from_str::<Value>(
20516 &browser_script_json(json!({"operations": []})).expect("structured object")
20517 )
20518 .expect("serialized object"),
20519 json!({"operations": []})
20520 );
20521
20522 for unsupported in [Value::Null, json!(1), json!(true), json!([])] {
20523 assert_eq!(
20524 browser_script_json(unsupported).expect_err("unsupported JSON shape"),
20525 "browser.run script must be a string or object"
20526 );
20527 }
20528 }
20529}
20530
20531#[derive(Deserialize)]
20544struct VoiceStartParams {
20545 session_id: String,
20546 audio_source: Value,
20547 #[serde(default)]
20548 options: Option<Value>,
20549}
20550
20551async fn handle_voice_transcribe_stream_start(
20552 req: &JsonRpcMessage,
20553 state: &Arc<ServerState>,
20554 session: &Arc<crate::session::ClientSession>,
20555) -> Result<Value, String> {
20556 let params: VoiceStartParams =
20557 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20558 let audio_source_json =
20559 serde_json::to_string(¶ms.audio_source).map_err(|e| e.to_string())?;
20560 let options_json = params
20561 .options
20562 .as_ref()
20563 .map(|v| serde_json::to_string(v).map_err(|e| e.to_string()))
20564 .transpose()?;
20565 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20566 channel: session.channel.clone(),
20567 });
20568 let json = car_ffi_common::voice::transcribe_stream_start(
20569 ¶ms.session_id,
20570 &audio_source_json,
20571 options_json.as_deref(),
20572 state.voice_sessions.clone(),
20573 sink,
20574 )
20575 .await?;
20576 serde_json::from_str(&json).map_err(|e| e.to_string())
20577}
20578
20579#[derive(Deserialize)]
20580struct VoiceStopParams {
20581 session_id: String,
20582}
20583
20584async fn handle_voice_transcribe_stream_stop(
20585 req: &JsonRpcMessage,
20586 state: &Arc<ServerState>,
20587) -> Result<Value, String> {
20588 let params: VoiceStopParams =
20589 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20590 let json = car_ffi_common::voice::transcribe_stream_stop(
20591 ¶ms.session_id,
20592 state.voice_sessions.clone(),
20593 )
20594 .await?;
20595 serde_json::from_str(&json).map_err(|e| e.to_string())
20596}
20597
20598#[derive(Deserialize)]
20599struct VoicePushParams {
20600 session_id: String,
20601 pcm_b64: String,
20605}
20606
20607async fn handle_voice_transcribe_stream_push(
20608 req: &JsonRpcMessage,
20609 state: &Arc<ServerState>,
20610) -> Result<Value, String> {
20611 use base64::Engine;
20612 let params: VoicePushParams =
20613 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20614 let pcm = base64::engine::general_purpose::STANDARD
20615 .decode(¶ms.pcm_b64)
20616 .map_err(|e| format!("invalid pcm_b64: {}", e))?;
20617 let json = car_ffi_common::voice::transcribe_stream_push(
20618 ¶ms.session_id,
20619 &pcm,
20620 state.voice_sessions.clone(),
20621 )
20622 .await?;
20623 serde_json::from_str(&json).map_err(|e| e.to_string())
20624}
20625
20626fn handle_voice_sessions_list(state: &Arc<ServerState>) -> Value {
20627 let json = car_ffi_common::voice::list_voice_sessions(state.voice_sessions.clone());
20628 serde_json::from_str(&json).unwrap_or(Value::Null)
20629}
20630
20631#[derive(Deserialize)]
20632struct VoiceTtsStreamStartParams {
20633 stream_id: String,
20637 text: String,
20640 #[serde(default)]
20643 options: Option<Value>,
20644}
20645
20646async fn handle_voice_tts_stream_start(
20647 req: &JsonRpcMessage,
20648 session: &Arc<crate::session::ClientSession>,
20649) -> Result<Value, String> {
20650 let params: VoiceTtsStreamStartParams =
20651 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20652 let opts_str = params
20653 .options
20654 .as_ref()
20655 .map(|v| v.to_string())
20656 .filter(|s| !s.is_empty());
20657 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20658 channel: session.channel.clone(),
20659 });
20660 let json = car_ffi_common::voice::tts_stream_start(
20661 ¶ms.stream_id,
20662 ¶ms.text,
20663 opts_str.as_deref(),
20664 sink,
20665 )
20666 .await?;
20667 serde_json::from_str(&json).map_err(|e| e.to_string())
20668}
20669
20670#[derive(Deserialize)]
20671struct VoiceTtsStreamCancelParams {
20672 stream_id: String,
20673}
20674
20675async fn handle_voice_tts_stream_cancel(req: &JsonRpcMessage) -> Result<Value, String> {
20676 let params: VoiceTtsStreamCancelParams =
20677 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20678 let json = car_ffi_common::voice::tts_stream_cancel(¶ms.stream_id).await?;
20679 serde_json::from_str(&json).map_err(|e| e.to_string())
20680}
20681
20682fn handle_voice_tts_stream_list() -> Value {
20683 let json = car_ffi_common::voice::list_tts_streams();
20684 serde_json::from_str(&json).unwrap_or(Value::Null)
20685}
20686
20687async fn handle_voice_dispatch_turn(
20688 req: &JsonRpcMessage,
20689 state: &Arc<ServerState>,
20690 session: &Arc<crate::session::ClientSession>,
20691) -> Result<Value, String> {
20692 let req_value = req.params.clone();
20693 let request: crate::voice_turn::DispatchVoiceTurnRequest =
20694 serde_json::from_value(req_value).map_err(|e| e.to_string())?;
20695 let engine = get_inference_engine(state).clone();
20696 let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20697 channel: session.channel.clone(),
20698 });
20699 let resp = crate::voice_turn::dispatch(engine, request, sink).await?;
20700 serde_json::to_value(resp).map_err(|e| e.to_string())
20701}
20702
20703async fn handle_voice_cancel_turn() -> Result<Value, String> {
20704 crate::voice_turn::cancel().await;
20705 Ok(serde_json::json!({"cancelled": true}))
20706}
20707
20708async fn handle_voice_prewarm_turn(state: &Arc<ServerState>) -> Result<Value, String> {
20709 let engine = get_inference_engine(state).clone();
20710 crate::voice_turn::prewarm(engine).await;
20711 Ok(serde_json::json!({"prewarmed": true}))
20712}
20713
20714fn ws_runner_sessions() -> &'static dashmap::DashMap<String, Arc<crate::session::WsChannel>> {
20744 static MAP: std::sync::OnceLock<dashmap::DashMap<String, Arc<crate::session::WsChannel>>> =
20745 std::sync::OnceLock::new();
20746 MAP.get_or_init(dashmap::DashMap::new)
20747}
20748
20749fn ws_runner_fallback() -> &'static std::sync::RwLock<Option<Arc<crate::session::WsChannel>>> {
20756 static SLOT: std::sync::OnceLock<std::sync::RwLock<Option<Arc<crate::session::WsChannel>>>> =
20757 std::sync::OnceLock::new();
20758 SLOT.get_or_init(|| std::sync::RwLock::new(None))
20759}
20760
20761fn resolve_runner_channel(caller: Option<&str>) -> Option<Arc<crate::session::WsChannel>> {
20764 if let Some(id) = caller {
20765 if let Some(entry) = ws_runner_sessions().get(id) {
20766 return Some(entry.value().clone());
20767 }
20768 }
20769 ws_runner_fallback().read().ok().and_then(|g| g.clone())
20770}
20771
20772fn ws_runner_calls() -> &'static dashmap::DashMap<String, car_inference::EventEmitter> {
20773 static MAP: std::sync::OnceLock<dashmap::DashMap<String, car_inference::EventEmitter>> =
20774 std::sync::OnceLock::new();
20775 MAP.get_or_init(dashmap::DashMap::new)
20776}
20777
20778fn ws_runner_completions() -> &'static dashmap::DashMap<
20779 String,
20780 tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
20781> {
20782 static MAP: std::sync::OnceLock<
20783 dashmap::DashMap<
20784 String,
20785 tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
20786 >,
20787 > = std::sync::OnceLock::new();
20788 MAP.get_or_init(dashmap::DashMap::new)
20789}
20790
20791struct WsInferenceRunner;
20792
20793#[async_trait::async_trait]
20794impl car_inference::InferenceRunner for WsInferenceRunner {
20795 async fn run(
20796 &self,
20797 request: car_inference::tasks::generate::GenerateRequest,
20798 emitter: car_inference::EventEmitter,
20799 ) -> std::result::Result<car_inference::RunnerResult, car_inference::RunnerError> {
20800 let channel = resolve_runner_channel(request.caller.as_deref()).ok_or_else(|| {
20803 car_inference::RunnerError::Declined(
20804 "no WebSocket inference runner registered — call inference.register_runner first"
20805 .into(),
20806 )
20807 })?;
20808
20809 let call_id = uuid::Uuid::new_v4().to_string();
20810 let request_json = serde_json::to_value(&request)
20811 .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
20812 let (tx, rx) = tokio::sync::oneshot::channel();
20813 ws_runner_calls().insert(call_id.clone(), emitter);
20814 ws_runner_completions().insert(call_id.clone(), tx);
20815
20816 use futures::SinkExt;
20818 let notification = serde_json::json!({
20819 "jsonrpc": "2.0",
20820 "method": "inference.runner.invoke",
20821 "params": {
20822 "call_id": call_id,
20823 "request": request_json,
20824 },
20825 });
20826 let text = serde_json::to_string(¬ification)
20827 .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
20828 let _ = channel
20829 .write
20830 .lock()
20831 .await
20832 .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
20833 .await;
20834
20835 let result = rx.await.map_err(|_| {
20836 car_inference::RunnerError::Failed("runner completion channel dropped".into())
20837 })?;
20838 ws_runner_calls().remove(&call_id);
20839 result.map_err(car_inference::RunnerError::Failed)
20840 }
20841}
20842
20843async fn handle_inference_register_runner(
20844 session: &Arc<crate::session::ClientSession>,
20845) -> Result<Value, String> {
20846 ws_runner_sessions().insert(session.client_id.clone(), session.channel.clone());
20847 let mut guard = ws_runner_fallback()
20851 .write()
20852 .map_err(|e| format!("ws runner slot poisoned: {e}"))?;
20853 let displaced = guard.is_some();
20854 *guard = Some(session.channel.clone());
20855 drop(guard);
20856 car_inference::set_inference_runner(Some(Arc::new(WsInferenceRunner)));
20857 Ok(serde_json::json!({
20861 "registered": true,
20862 "scope": "session",
20863 "runners_registered": ws_runner_sessions().len(),
20864 "became_fallback": true,
20865 "displaced_fallback": displaced,
20866 }))
20867}
20868
20869#[derive(serde::Deserialize)]
20870struct InferenceRunnerEventParams {
20871 call_id: String,
20872 event: Value,
20873}
20874
20875async fn handle_inference_runner_event(req: &JsonRpcMessage) -> Result<Value, String> {
20876 let params: InferenceRunnerEventParams =
20877 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20878 let stream_event = match parse_runner_event_value(¶ms.event) {
20879 Some(e) => e,
20880 None => return Err("unrecognised runner event shape".into()),
20881 };
20882 if let Some(entry) = ws_runner_calls().get(¶ms.call_id) {
20883 let emitter = entry.value().clone();
20884 tokio::spawn(async move { emitter.emit(stream_event).await });
20885 }
20886 Ok(serde_json::json!({"emitted": true}))
20887}
20888
20889#[derive(serde::Deserialize)]
20890struct InferenceRunnerCompleteParams {
20891 call_id: String,
20892 result: Value,
20893}
20894
20895async fn handle_inference_runner_complete(req: &JsonRpcMessage) -> Result<Value, String> {
20896 let params: InferenceRunnerCompleteParams =
20897 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20898 let result: std::result::Result<car_inference::RunnerResult, String> =
20899 serde_json::from_value(params.result)
20900 .map_err(|e| format!("invalid RunnerResult JSON: {e}"));
20901 if let Some((_, tx)) = ws_runner_completions().remove(¶ms.call_id) {
20902 let _ = tx.send(result);
20903 }
20904 Ok(serde_json::json!({"completed": true}))
20905}
20906
20907#[derive(serde::Deserialize)]
20908struct InferenceRunnerFailParams {
20909 call_id: String,
20910 error: String,
20911}
20912
20913async fn handle_inference_runner_fail(req: &JsonRpcMessage) -> Result<Value, String> {
20914 let params: InferenceRunnerFailParams =
20915 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20916 if let Some((_, tx)) = ws_runner_completions().remove(¶ms.call_id) {
20917 let _ = tx.send(Err(params.error));
20918 }
20919 Ok(serde_json::json!({"failed": true}))
20920}
20921
20922fn parse_runner_event_value(v: &Value) -> Option<car_inference::StreamEvent> {
20923 let ty = v.get("type").and_then(|t| t.as_str())?;
20924 match ty {
20925 "text" => Some(car_inference::StreamEvent::TextDelta(
20926 v.get("data")?.as_str()?.to_string(),
20927 )),
20928 "tool_start" => Some(car_inference::StreamEvent::ToolCallStart {
20929 name: v.get("name")?.as_str()?.to_string(),
20930 index: v.get("index")?.as_u64()? as usize,
20931 id: v.get("id").and_then(|i| i.as_str()).map(str::to_string),
20932 }),
20933 "tool_delta" => Some(car_inference::StreamEvent::ToolCallDelta {
20934 index: v.get("index")?.as_u64()? as usize,
20935 arguments_delta: v.get("data")?.as_str()?.to_string(),
20936 }),
20937 "usage" => Some(car_inference::StreamEvent::Usage {
20938 input_tokens: v.get("input_tokens")?.as_u64()?,
20939 output_tokens: v.get("output_tokens")?.as_u64()?,
20940 cache_read_input_tokens: v
20941 .get("cache_read_input_tokens")
20942 .and_then(|x| x.as_u64())
20943 .unwrap_or(0),
20944 cache_creation_input_tokens: v
20945 .get("cache_creation_input_tokens")
20946 .and_then(|x| x.as_u64())
20947 .unwrap_or(0),
20948 }),
20949 "provider_output_item" => Some(car_inference::StreamEvent::ProviderOutputItem(
20950 v.get("item")?.clone(),
20951 )),
20952 "error" => Some(car_inference::StreamEvent::Error(
20953 v.get("message")?.as_str()?.to_string(),
20954 )),
20955 "done" => Some(car_inference::StreamEvent::Done {
20956 text: v.get("text")?.as_str()?.to_string(),
20957 tool_calls: v
20958 .get("tool_calls")
20959 .and_then(|tc| serde_json::from_value(tc.clone()).ok())
20960 .unwrap_or_default(),
20961 }),
20962 _ => None,
20963 }
20964}
20965
20966#[derive(Deserialize)]
20967struct EnrollSpeakerParams {
20968 label: String,
20969 audio: Value,
20970}
20971
20972async fn handle_enroll_speaker(req: &JsonRpcMessage) -> Result<Value, String> {
20973 let params: EnrollSpeakerParams =
20974 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20975 let audio_json = serde_json::to_string(¶ms.audio).map_err(|e| e.to_string())?;
20976 let json = car_ffi_common::voice::enroll_speaker(¶ms.label, &audio_json).await?;
20977 serde_json::from_str(&json).map_err(|e| e.to_string())
20978}
20979
20980#[derive(Deserialize)]
20981struct RemoveEnrollmentParams {
20982 label: String,
20983}
20984
20985fn handle_remove_enrollment(req: &JsonRpcMessage) -> Result<Value, String> {
20986 let params: RemoveEnrollmentParams =
20987 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20988 let json = car_ffi_common::voice::remove_enrollment(¶ms.label)?;
20989 serde_json::from_str(&json).map_err(|e| e.to_string())
20990}
20991
20992#[derive(Deserialize)]
20993struct WorkflowRunParams {
20994 workflow: Value,
20995 #[serde(default)]
20999 initial_state: Option<std::collections::HashMap<String, Value>>,
21000}
21001
21002async fn handle_workflow_run(
21003 req: &JsonRpcMessage,
21004 session: &Arc<crate::session::ClientSession>,
21005) -> Result<Value, String> {
21006 let params: WorkflowRunParams =
21007 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21008 let workflow_json = serde_json::to_string(¶ms.workflow).map_err(|e| e.to_string())?;
21009 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21010 channel: session.channel.clone(),
21011 host: session.host.clone(),
21012 client_id: session.client_id.clone(),
21013 });
21014 let json = car_ffi_common::workflow::run_workflow(&workflow_json, params.initial_state, runner)
21015 .await?;
21016 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21017 persist_if_paused(&result)?;
21020 Ok(result)
21021}
21022
21023#[derive(Deserialize)]
21024struct WorkflowChainParams {
21025 workflows: Vec<Value>,
21029 #[serde(default)]
21033 initial_state: Option<std::collections::HashMap<String, Value>>,
21034}
21035
21036async fn handle_workflow_chain(
21048 req: &JsonRpcMessage,
21049 session: &Arc<crate::session::ClientSession>,
21050) -> Result<Value, String> {
21051 let params: WorkflowChainParams =
21052 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21053 let workflows_json = serde_json::to_string(¶ms.workflows).map_err(|e| e.to_string())?;
21054 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21055 channel: session.channel.clone(),
21056 host: session.host.clone(),
21057 client_id: session.client_id.clone(),
21058 });
21059 let json =
21060 car_ffi_common::workflow::chain_workflows(&workflows_json, params.initial_state, runner)
21061 .await?;
21062 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21063 if let Some(last) = result
21066 .get("results")
21067 .and_then(|r| r.as_array())
21068 .and_then(|r| r.last())
21069 {
21070 persist_if_paused(last)?;
21071 }
21072 Ok(result)
21073}
21074
21075pub fn recover_workflow_checkpoints() {
21080 let dir = match workflow_runs_dir() {
21081 Ok(d) => d,
21082 Err(e) => {
21083 tracing::debug!(error = %e, "no workflow checkpoint dir; skipping recovery");
21084 return;
21085 }
21086 };
21087 match car_workflow::CheckpointStore::open(&dir).and_then(|s| s.recover_orphaned()) {
21088 Ok(0) => {}
21089 Ok(n) => tracing::info!(rearmed = n, "recovered orphaned workflow checkpoints"),
21090 Err(e) => tracing::warn!(error = %e, "workflow checkpoint recovery failed"),
21091 }
21092}
21093
21094fn workflow_runs_dir() -> Result<std::path::PathBuf, String> {
21097 let root = car_home::root()
21098 .ok_or_else(|| "cannot resolve home directory for workflow checkpoints".to_string())?;
21099 Ok(root.join("workflow-runs"))
21100}
21101
21102fn persist_if_paused(result: &Value) -> Result<(), String> {
21104 if result.get("status").and_then(|s| s.as_str()) != Some("paused") {
21105 return Ok(());
21106 }
21107 let Some(paused_value) = result.get("paused") else {
21108 return Err("paused result missing checkpoint".to_string());
21109 };
21110 let paused: car_workflow::PausedWorkflow =
21111 serde_json::from_value(paused_value.clone()).map_err(|e| e.to_string())?;
21112 let store =
21113 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21114 store.save(&paused).map_err(|e| e.to_string())
21115}
21116
21117#[derive(Deserialize)]
21118struct WorkflowResumeParams {
21119 run_id: String,
21120 #[serde(default)]
21121 input: Value,
21122}
21123
21124async fn handle_workflow_list_paused() -> Result<Value, String> {
21131 let store =
21132 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21133 let summaries = store.list_summaries().map_err(|e| e.to_string())?;
21134 serde_json::to_value(&summaries).map_err(|e| format!("serialize list_paused: {e}"))
21135}
21136
21137async fn handle_workflow_resume(
21138 req: &JsonRpcMessage,
21139 session: &Arc<crate::session::ClientSession>,
21140) -> Result<Value, String> {
21141 let params: WorkflowResumeParams =
21142 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21143
21144 let store =
21145 car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21146 let paused = store
21149 .claim(¶ms.run_id)
21150 .map_err(|e| e.to_string())?
21151 .ok_or_else(|| {
21152 format!(
21153 "no paused workflow run '{}' (already resumed or unknown)",
21154 params.run_id
21155 )
21156 })?;
21157 let paused_json = serde_json::to_string(&paused).map_err(|e| e.to_string())?;
21158 let input_json = if params.input.is_null() {
21159 "{}".to_string()
21160 } else {
21161 serde_json::to_string(¶ms.input).map_err(|e| e.to_string())?
21162 };
21163
21164 let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21165 channel: session.channel.clone(),
21166 host: session.host.clone(),
21167 client_id: session.client_id.clone(),
21168 });
21169
21170 let json = car_ffi_common::workflow::resume_workflow(&paused_json, &input_json, runner).await;
21171 let result = match json {
21172 Ok(j) => serde_json::from_str::<Value>(&j).map_err(|e| e.to_string())?,
21173 Err(e) => {
21174 let _ = store.save(&paused);
21177 let _ = store.complete(¶ms.run_id);
21178 return Err(e);
21179 }
21180 };
21181 persist_if_paused(&result)?;
21184 store.complete(¶ms.run_id).map_err(|e| e.to_string())?;
21185 Ok(result)
21186}
21187
21188#[derive(Deserialize)]
21189struct BuilderBuildParams {
21190 goal: String,
21191 #[serde(default)]
21192 existing: Value,
21193 #[serde(default = "default_builder_attempts")]
21194 max_attempts: u32,
21195}
21196
21197fn default_builder_attempts() -> u32 {
21198 3
21199}
21200
21201async fn handle_builder_build(
21206 req: &JsonRpcMessage,
21207 state: &Arc<ServerState>,
21208 session: &Arc<crate::session::ClientSession>,
21209) -> Result<Value, String> {
21210 let params: BuilderBuildParams =
21211 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21212 if params.goal.trim().is_empty() {
21213 return Err("missing 'goal'".to_string());
21214 }
21215
21216 let engine = get_inference_engine(state).clone();
21217
21218 let tools: Vec<car_builder::ToolInfo> = session
21219 .runtime
21220 .registry
21221 .schemas()
21222 .await
21223 .into_iter()
21224 .map(|s| car_builder::ToolInfo {
21225 name: s.name,
21226 description: s.description,
21227 })
21228 .collect();
21229 let models: Vec<String> = engine
21230 .list_models_unified()
21231 .into_iter()
21232 .map(|m| m.id)
21233 .collect();
21234 let catalog = car_builder::ToolCatalog {
21235 tools,
21236 models,
21237 agents: Vec::new(),
21238 };
21239
21240 let existing = if params.existing.is_null() {
21241 None
21242 } else {
21243 serde_json::from_value::<car_workflow::Workflow>(params.existing.clone()).ok()
21244 };
21245
21246 let build_req = car_builder::BuildRequest {
21247 goal: params.goal,
21248 catalog,
21249 existing,
21250 feedback: None,
21251 max_attempts: params.max_attempts,
21252 };
21253
21254 let result = car_builder::build_workflow(
21255 |prompt: String| {
21256 let engine = engine.clone();
21257 async move {
21258 let greq = car_inference::GenerateRequest {
21259 prompt,
21260 model: None,
21261 expected_row_digest: None,
21262 expected_catalog_revision: None,
21263 params: car_inference::GenerateParams {
21264 temperature: 0.2,
21265 max_tokens: 4096,
21266 ..Default::default()
21267 },
21268 context: None,
21269 context_stable_prefix: None,
21270 tools: None,
21271 images: None,
21272 messages: None,
21273 cache_control: false,
21274 response_format: None,
21275 intent: None,
21276 client_ref: None,
21277 caller: None,
21278 };
21279 engine
21280 .generate_tracked(greq)
21281 .await
21282 .map(|r| r.text)
21283 .map_err(|e| e.to_string())
21284 }
21285 },
21286 &build_req,
21287 )
21288 .await;
21289
21290 Ok(serde_json::json!({
21291 "valid": result.valid,
21292 "workflow": result.workflow,
21293 "issues": result.issues,
21294 "warnings": result.warnings,
21295 "attempts": result.attempts,
21296 }))
21297}
21298
21299#[derive(Deserialize)]
21300struct WorkflowVerifyParams {
21301 workflow: Value,
21302}
21303
21304fn handle_workflow_verify(req: &JsonRpcMessage) -> Result<Value, String> {
21305 let params: WorkflowVerifyParams =
21306 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21307 let workflow_json = serde_json::to_string(¶ms.workflow).map_err(|e| e.to_string())?;
21308 let json = car_ffi_common::workflow::verify_workflow(&workflow_json)?;
21309 serde_json::from_str(&json).map_err(|e| e.to_string())
21310}
21311
21312#[derive(Deserialize)]
21313struct WorkflowBuildAutomationParams {
21314 spec: Value,
21315}
21316
21317fn handle_workflow_build_automation(req: &JsonRpcMessage) -> Result<Value, String> {
21321 let params: WorkflowBuildAutomationParams =
21322 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21323 let spec_json = serde_json::to_string(¶ms.spec).map_err(|e| e.to_string())?;
21324 let json = car_ffi_common::workflow::build_automation_workflow(&spec_json)?;
21325 serde_json::from_str(&json).map_err(|e| e.to_string())
21326}
21327
21328async fn handle_meeting_start(
21333 req: &JsonRpcMessage,
21334 state: &Arc<ServerState>,
21335 session: &Arc<crate::session::ClientSession>,
21336) -> Result<Value, String> {
21337 let mut req_value = req.params.clone();
21343 let meeting_id = req_value
21344 .get("id")
21345 .and_then(|v| v.as_str())
21346 .map(str::to_string)
21347 .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
21348 if let Some(map) = req_value.as_object_mut() {
21349 map.insert("id".into(), Value::String(meeting_id.clone()));
21350 }
21351 let request_json = serde_json::to_string(&req_value).map_err(|e| e.to_string())?;
21352
21353 let ws_upstream: Arc<dyn car_voice::VoiceEventSink> =
21354 Arc::new(crate::session::WsVoiceEventSink {
21355 channel: session.channel.clone(),
21356 });
21357
21358 let upstream: Arc<dyn car_voice::VoiceEventSink> =
21363 Arc::new(crate::session::WsMemgineIngestSink {
21364 meeting_id,
21365 engine: session.memgine.clone(),
21366 upstream: ws_upstream,
21367 });
21368
21369 let cwd = std::env::current_dir().ok();
21370 let json = crate::meeting::start_meeting(
21371 &request_json,
21372 state.meetings.clone(),
21373 state.voice_sessions.clone(),
21374 upstream,
21375 None,
21376 cwd,
21377 )
21378 .await?;
21379 serde_json::from_str(&json).map_err(|e| e.to_string())
21380}
21381
21382#[derive(Deserialize)]
21383struct MeetingStopParams {
21384 meeting_id: String,
21385 #[serde(default = "default_summarize")]
21386 summarize: bool,
21387}
21388
21389fn default_summarize() -> bool {
21390 true
21391}
21392
21393async fn handle_meeting_stop(
21394 req: &JsonRpcMessage,
21395 state: &Arc<ServerState>,
21396 _session: &Arc<crate::session::ClientSession>,
21397) -> Result<Value, String> {
21398 let params: MeetingStopParams =
21399 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21400 let inference = if params.summarize {
21401 Some(state.inference.get().cloned()).flatten()
21402 } else {
21403 None
21404 };
21405 let json = crate::meeting::stop_meeting(
21406 ¶ms.meeting_id,
21407 params.summarize,
21408 state.meetings.clone(),
21409 state.voice_sessions.clone(),
21410 inference,
21411 )
21412 .await?;
21413 serde_json::from_str(&json).map_err(|e| e.to_string())
21414}
21415
21416#[derive(Deserialize, Default)]
21417struct MeetingListParams {
21418 #[serde(default)]
21419 root: Option<std::path::PathBuf>,
21420}
21421
21422fn handle_meeting_list(req: &JsonRpcMessage) -> Result<Value, String> {
21423 let params: MeetingListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21424 let cwd = std::env::current_dir().ok();
21425 let json = crate::meeting::list_meetings(params.root, cwd)?;
21426 serde_json::from_str(&json).map_err(|e| e.to_string())
21427}
21428
21429#[derive(Deserialize)]
21430struct MeetingGetParams {
21431 meeting_id: String,
21432 #[serde(default)]
21433 root: Option<std::path::PathBuf>,
21434}
21435
21436fn handle_meeting_get(req: &JsonRpcMessage) -> Result<Value, String> {
21437 let params: MeetingGetParams =
21438 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21439 let cwd = std::env::current_dir().ok();
21440 let json = crate::meeting::get_meeting(¶ms.meeting_id, params.root, cwd)?;
21441 serde_json::from_str(&json).map_err(|e| e.to_string())
21442}
21443
21444#[derive(Deserialize, Default)]
21449struct RegistryRegisterParams {
21450 entry: Value,
21454 #[serde(default)]
21455 registry_path: Option<std::path::PathBuf>,
21456}
21457
21458fn handle_registry_register(req: &JsonRpcMessage) -> Result<Value, String> {
21459 let params: RegistryRegisterParams =
21460 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21461 let entry_json = serde_json::to_string(¶ms.entry).map_err(|e| e.to_string())?;
21462 car_ffi_common::registry::register_agent(&entry_json, params.registry_path)?;
21463 Ok(Value::Null)
21464}
21465
21466#[derive(Deserialize, Default)]
21467struct RegistryNameParams {
21468 name: String,
21469 #[serde(default)]
21470 registry_path: Option<std::path::PathBuf>,
21471}
21472
21473fn handle_registry_heartbeat(req: &JsonRpcMessage) -> Result<Value, String> {
21474 let params: RegistryNameParams =
21475 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21476 let json = car_ffi_common::registry::agent_heartbeat(¶ms.name, params.registry_path)?;
21477 serde_json::from_str(&json).map_err(|e| e.to_string())
21478}
21479
21480fn handle_registry_unregister(req: &JsonRpcMessage) -> Result<Value, String> {
21481 let params: RegistryNameParams =
21482 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21483 car_ffi_common::registry::unregister_agent(¶ms.name, params.registry_path)?;
21484 Ok(Value::Null)
21485}
21486
21487#[derive(Deserialize, Default)]
21488struct RegistryListParams {
21489 #[serde(default)]
21490 registry_path: Option<std::path::PathBuf>,
21491}
21492
21493fn handle_registry_list(req: &JsonRpcMessage) -> Result<Value, String> {
21494 let params: RegistryListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21495 let json = car_ffi_common::registry::list_agents(params.registry_path)?;
21496 serde_json::from_str(&json).map_err(|e| e.to_string())
21497}
21498
21499#[derive(Deserialize, Default)]
21500struct RegistryReapParams {
21501 #[serde(default = "default_reap_age")]
21504 max_age_secs: u64,
21505 #[serde(default)]
21506 registry_path: Option<std::path::PathBuf>,
21507}
21508
21509fn default_reap_age() -> u64 {
21510 60
21511}
21512
21513fn handle_registry_reap(req: &JsonRpcMessage) -> Result<Value, String> {
21514 let params: RegistryReapParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21515 let json =
21516 car_ffi_common::registry::reap_stale_agents(params.max_age_secs, params.registry_path)?;
21517 serde_json::from_str(&json).map_err(|e| e.to_string())
21518}
21519
21520async fn handle_a2a_start(
21527 req: &JsonRpcMessage,
21528 state: &Arc<ServerState>,
21529 session: &crate::session::ClientSession,
21530) -> Result<Value, String> {
21531 let params_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21532 let responder: Arc<dyn car_a2a::ChatResponder> = Arc::new(WsChatResponder {
21544 state: Arc::downgrade(state),
21545 host_client_id: session.client_id.clone(),
21546 });
21547 let json =
21548 crate::a2a::start_a2a(¶ms_json, Some(session.runtime.clone()), Some(responder)).await?;
21549 serde_json::from_str(&json).map_err(|e| e.to_string())
21550}
21551
21552fn handle_a2a_stop() -> Result<Value, String> {
21553 let json = crate::a2a::stop_a2a()?;
21554 serde_json::from_str(&json).map_err(|e| e.to_string())
21555}
21556
21557fn handle_a2a_status() -> Result<Value, String> {
21558 let json = crate::a2a::a2a_status()?;
21559 serde_json::from_str(&json).map_err(|e| e.to_string())
21560}
21561
21562#[derive(serde::Deserialize)]
21566struct A2aPeerAddParams {
21567 url: String,
21568 #[serde(default)]
21569 label: Option<String>,
21570 #[serde(default)]
21572 allow_untrusted: bool,
21573}
21574
21575fn handle_a2a_peers_add(req: &JsonRpcMessage) -> Result<Value, String> {
21576 let params: A2aPeerAddParams =
21577 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
21578 if !params.allow_untrusted && !is_loopback_http_endpoint(¶ms.url) {
21583 return Err(
21584 "a2a.peers.add endpoint must be loopback unless allowUntrusted is true".to_string(),
21585 );
21586 }
21587 let reg = car_a2a::peers::PeerRegistry::user_default()?;
21588 let entry = reg.add(¶ms.url, params.label)?;
21589 serde_json::to_value(entry).map_err(|e| e.to_string())
21590}
21591
21592fn handle_a2a_peers_list() -> Result<Value, String> {
21593 let reg = car_a2a::peers::PeerRegistry::user_default()?;
21594 Ok(serde_json::json!({ "peers": reg.list() }))
21595}
21596
21597#[derive(serde::Deserialize)]
21598struct A2aPeerRemoveParams {
21599 slug: String,
21600}
21601
21602fn handle_a2a_peers_remove(req: &JsonRpcMessage) -> Result<Value, String> {
21603 let params: A2aPeerRemoveParams =
21604 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
21605 let reg = car_a2a::peers::PeerRegistry::user_default()?;
21606 Ok(serde_json::json!({ "removed": reg.remove(¶ms.slug)? }))
21607}
21608
21609#[derive(Deserialize)]
21610#[serde(rename_all = "camelCase")]
21611struct A2aSendParams {
21612 endpoint: String,
21613 message: car_a2a::Message,
21614 #[serde(default)]
21615 blocking: bool,
21616 #[serde(default = "default_true")]
21617 ingest_a2ui: bool,
21618 #[serde(default)]
21619 route_auth: Option<A2aRouteAuth>,
21620 #[serde(default)]
21621 allow_untrusted_endpoint: bool,
21622}
21623
21624fn default_true() -> bool {
21625 true
21626}
21627
21628async fn handle_a2a_dispatch(
21638 method: &str,
21639 req: &JsonRpcMessage,
21640 state: &Arc<ServerState>,
21641) -> Result<Value, String> {
21642 let dispatcher = state.a2a_dispatcher().await;
21643 dispatcher
21644 .dispatch(method, req.params.clone())
21645 .await
21646 .map_err(|e| e.to_string())
21647}
21648
21649async fn handle_a2a_send(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
21650 let params: A2aSendParams =
21651 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21652 let endpoint = trusted_route_endpoint(
21653 Some(params.endpoint.clone()),
21654 params.allow_untrusted_endpoint,
21655 )
21656 .ok_or_else(|| {
21657 "`a2a.send` endpoint must be loopback unless allowUntrustedEndpoint is true".to_string()
21658 })?;
21659 let client = match params.route_auth.clone() {
21660 Some(auth) => {
21661 car_a2a::A2aClient::new(endpoint.clone()).with_auth(client_auth_from_route_auth(auth))
21662 }
21663 None => car_a2a::A2aClient::new(endpoint.clone()),
21664 };
21665 let result = client
21666 .send_message(params.message, params.blocking)
21667 .await
21668 .map_err(|e| e.to_string())?;
21669 let result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
21670 let mut applied = Vec::new();
21671 if params.ingest_a2ui {
21672 state
21673 .a2ui
21674 .validate_payload(&result_value)
21675 .map_err(|e| e.to_string())?;
21676 let routed_endpoint = Some(endpoint.clone());
21677 for envelope in car_a2ui::envelopes_from_value(&result_value).map_err(|e| e.to_string())? {
21678 let owner = car_a2ui::owner_from_value(&result_value).map(|owner| {
21679 if owner.endpoint.is_none() {
21680 owner.with_endpoint(routed_endpoint.clone())
21681 } else {
21682 owner
21683 }
21684 });
21685 applied.push(
21686 apply_a2ui_envelope(state, envelope, owner, params.route_auth.clone()).await?,
21687 );
21688 }
21689 }
21690 Ok(serde_json::json!({
21691 "result": result,
21692 "a2ui": {
21693 "applied": applied,
21694 }
21695 }))
21696}
21697
21698async fn handle_run_applescript(req: &JsonRpcMessage) -> Result<Value, String> {
21706 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21707 let json = car_ffi_common::automation::run_applescript(&args_json).await?;
21708 serde_json::from_str(&json).map_err(|e| e.to_string())
21709}
21710
21711async fn handle_run_powershell(req: &JsonRpcMessage) -> Result<Value, String> {
21712 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21713 let json = car_ffi_common::automation::run_powershell(&args_json).await?;
21714 serde_json::from_str(&json).map_err(|e| e.to_string())
21715}
21716
21717async fn handle_list_shortcuts(req: &JsonRpcMessage) -> Result<Value, String> {
21718 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21719 let json = car_ffi_common::automation::list_shortcuts(&args_json).await?;
21720 serde_json::from_str(&json).map_err(|e| e.to_string())
21721}
21722
21723async fn handle_run_shortcut(req: &JsonRpcMessage) -> Result<Value, String> {
21724 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21725 let json = car_ffi_common::automation::run_shortcut(&args_json).await?;
21726 serde_json::from_str(&json).map_err(|e| e.to_string())
21727}
21728
21729async fn handle_local_notification(req: &JsonRpcMessage) -> Result<Value, String> {
21730 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21731 let json = car_ffi_common::notifications::local(&args_json).await?;
21732 serde_json::from_str(&json).map_err(|e| e.to_string())
21733}
21734
21735async fn handle_vision_ocr(req: &JsonRpcMessage) -> Result<Value, String> {
21736 let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21737 let json = car_ffi_common::vision::ocr(&args_json).await?;
21738 serde_json::from_str(&json).map_err(|e| e.to_string())
21739}
21740
21741fn agent_to_wire(agent: &car_registry::supervisor::ManagedAgent) -> Result<Value, String> {
21760 let mut v = serde_json::to_value(agent).map_err(|e| e.to_string())?;
21761 if let Some(map) = v.as_object_mut() {
21762 map.remove("token");
21763 }
21764 Ok(v)
21765}
21766
21767async fn handle_agents_list(state: &Arc<ServerState>) -> Result<Value, String> {
21768 let (agents, manifest_path, log_dir) = match state.observer_manifest_path() {
21777 Some(p) => {
21778 let agents = car_registry::supervisor::Supervisor::list_from_manifest(p)
21779 .map_err(|e| e.to_string())?;
21780 let log_dir = p
21781 .parent()
21782 .map(|parent| parent.join("logs"))
21783 .unwrap_or_else(|| std::path::PathBuf::from("logs"));
21784 (agents, p.clone(), log_dir)
21785 }
21786 None => {
21787 let supervisor = state.supervisor()?;
21788 let agents = supervisor.list().await;
21789 (
21790 agents,
21791 supervisor.manifest_path().to_path_buf(),
21792 supervisor.log_dir().to_path_buf(),
21793 )
21794 }
21795 };
21796 let agents_dir = manifest_path
21797 .parent()
21798 .map(|parent| parent.join("agents"))
21799 .unwrap_or_else(|| std::path::PathBuf::from("agents"));
21800 let attached = state.attached_agents.lock().await.clone();
21807 let mut decorated: Vec<Value> = Vec::with_capacity(agents.len());
21808 for a in agents {
21809 let mut v = agent_to_wire(&a)?;
21810 let session_id = attached.get(&a.spec.id).cloned();
21811 if let Some(map) = v.as_object_mut() {
21812 map.insert("attached".to_string(), Value::Bool(session_id.is_some()));
21813 map.insert(
21814 "manifest_path".to_string(),
21815 Value::String(
21816 agents_dir
21817 .join(&a.spec.id)
21818 .join("manifest.toml")
21819 .to_string_lossy()
21820 .into_owned(),
21821 ),
21822 );
21823 map.insert(
21824 "log_path".to_string(),
21825 Value::String(
21826 log_dir
21827 .join(format!("{}.stdout.log", a.spec.id))
21828 .to_string_lossy()
21829 .into_owned(),
21830 ),
21831 );
21832 map.insert(
21833 "stderr_log_path".to_string(),
21834 Value::String(
21835 log_dir
21836 .join(format!("{}.stderr.log", a.spec.id))
21837 .to_string_lossy()
21838 .into_owned(),
21839 ),
21840 );
21841 if let Some(sid) = session_id {
21842 map.insert("session_id".to_string(), Value::String(sid));
21843 }
21844 }
21845 decorated.push(v);
21846 }
21847 decorated.extend(crate::coder::rpc::declarative_agent_rows(state).await);
21851 Ok(Value::Array(decorated))
21852}
21853
21854async fn handle_agents_upsert(
21855 req: &JsonRpcMessage,
21856 state: &Arc<ServerState>,
21857 session: &crate::session::ClientSession,
21858) -> Result<Value, String> {
21859 require_host_lifecycle_authority(session, state).await?;
21860 let mut params = req.params.clone();
21861 if let Some(name) = params
21870 .get("interpreter")
21871 .and_then(|v| v.as_str())
21872 .map(str::to_string)
21873 {
21874 let resolved =
21875 car_registry::supervisor::resolve_interpreter(&name).map_err(|e| e.to_string())?;
21876 params["command"] = Value::String(resolved.to_string_lossy().into_owned());
21877 }
21878 let spec: car_registry::supervisor::AgentSpec =
21879 serde_json::from_value(params).map_err(|e| e.to_string())?;
21880 let supervisor = state.supervisor()?;
21881 let agent = supervisor.upsert(spec).await.map_err(|e| e.to_string())?;
21882 agent_to_wire(&agent)
21883}
21884
21885async fn handle_agents_install(
21899 req: &JsonRpcMessage,
21900 state: &Arc<ServerState>,
21901 session: &crate::session::ClientSession,
21902) -> Result<Value, String> {
21903 require_host_lifecycle_authority(session, state).await?;
21904 let manifest: car_registry::manifest::AgentManifest =
21905 serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21906 let host = car_registry::install::HostCapabilities::daemon_default(env!("CARGO_PKG_VERSION"));
21907 let supervisor = state.supervisor()?;
21908 let (report, managed) = supervisor
21909 .install_manifest(manifest, &host)
21910 .await
21911 .map_err(|e| e.to_string())?;
21912 Ok(serde_json::json!({
21913 "report": {
21914 "missingOptional": report
21915 .missing_optional
21916 .iter()
21917 .map(|(ns, feat)| serde_json::json!({ "namespace": ns, "feature": feat }))
21918 .collect::<Vec<_>>(),
21919 },
21920 "agent": managed,
21921 }))
21922}
21923
21924async fn handle_agents_health(state: &Arc<ServerState>) -> Result<Value, String> {
21925 let entries = match state.observer_manifest_path() {
21931 Some(p) => car_registry::supervisor::Supervisor::health_from_manifest(p)
21932 .map_err(|e| e.to_string())?,
21933 None => {
21934 let supervisor = state.supervisor()?;
21935 supervisor.health().await
21936 }
21937 };
21938 serde_json::to_value(entries).map_err(|e| e.to_string())
21939}
21940
21941fn extract_agent_id(req: &JsonRpcMessage) -> Result<String, String> {
21942 req.params
21943 .get("id")
21944 .and_then(Value::as_str)
21945 .map(str::to_string)
21946 .ok_or_else(|| "missing required `id` parameter".to_string())
21947}
21948
21949async fn handle_agents_remove(
21950 req: &JsonRpcMessage,
21951 state: &Arc<ServerState>,
21952 session: &crate::session::ClientSession,
21953) -> Result<Value, String> {
21954 require_host_lifecycle_authority(session, state).await?;
21955 let id = extract_agent_id(req)?;
21956 let supervisor = state.supervisor()?;
21957 let removed = supervisor.remove(&id).await.map_err(|e| e.to_string())?;
21958 Ok(serde_json::json!({ "removed": removed }))
21959}
21960
21961async fn handle_agents_start(
21962 req: &JsonRpcMessage,
21963 state: &Arc<ServerState>,
21964 session: &crate::session::ClientSession,
21965) -> Result<Value, String> {
21966 let id = extract_agent_id(req)?;
21967 require_own_agent_or_host(session, state, "agents.start", &id).await?;
21968 let supervisor = state.supervisor()?;
21969 let agent = supervisor.start(&id).await.map_err(|e| e.to_string())?;
21970 agent_to_wire(&agent)
21971}
21972
21973async fn handle_agents_stop(
21974 req: &JsonRpcMessage,
21975 state: &Arc<ServerState>,
21976 session: &crate::session::ClientSession,
21977) -> Result<Value, String> {
21978 let id = extract_agent_id(req)?;
21979 require_own_agent_or_host(session, state, "agents.stop", &id).await?;
21980 let signal: car_registry::supervisor::StopSignal = req
21981 .params
21982 .get("signal")
21983 .map(|v| serde_json::from_value(v.clone()))
21984 .transpose()
21985 .map_err(|e| e.to_string())?
21986 .unwrap_or_default();
21987 let supervisor = state.supervisor()?;
21988 let agent = supervisor
21989 .stop(&id, signal)
21990 .await
21991 .map_err(|e| e.to_string())?;
21992 agent_to_wire(&agent)
21993}
21994
21995async fn handle_agents_restart(
21996 req: &JsonRpcMessage,
21997 state: &Arc<ServerState>,
21998 session: &crate::session::ClientSession,
21999) -> Result<Value, String> {
22000 let id = extract_agent_id(req)?;
22001 require_own_agent_or_host(session, state, "agents.restart", &id).await?;
22002 let supervisor = state.supervisor()?;
22003 let agent = supervisor.restart(&id).await.map_err(|e| e.to_string())?;
22004 agent_to_wire(&agent)
22005}
22006
22007const MAX_AGENT_WAIT_SECS: f64 = 600.0;
22013
22014async fn handle_agents_wait(
22017 req: &JsonRpcMessage,
22018 state: &Arc<ServerState>,
22019 bound_agent: Option<&str>,
22020) -> Result<Value, String> {
22021 let id = extract_agent_id(req)?;
22022 require_own_agent(bound_agent, "agents.wait", &id)?;
22023 let targets: Vec<car_registry::supervisor::AgentStatus> = match req.params.get("targets") {
22024 Some(v) => serde_json::from_value(v.clone())
22025 .map_err(|e| format!("invalid 'targets' (expected status string array): {e}"))?,
22026 None => vec![car_registry::supervisor::AgentStatus::Running],
22027 };
22028 let targets = if targets.is_empty() {
22029 vec![car_registry::supervisor::AgentStatus::Running]
22030 } else {
22031 targets
22032 };
22033 let timeout = std::time::Duration::from_secs_f64(
22034 req.params
22035 .get("timeout_secs")
22036 .and_then(Value::as_f64)
22037 .unwrap_or(30.0)
22038 .clamp(0.0, MAX_AGENT_WAIT_SECS),
22043 );
22044 let poll = std::time::Duration::from_millis(
22045 req.params
22046 .get("poll_ms")
22047 .and_then(Value::as_u64)
22048 .unwrap_or(200)
22049 .max(10),
22050 );
22051 let supervisor = state.supervisor()?;
22052 let agent = supervisor
22053 .wait_for(&id, &targets, timeout, poll)
22054 .await
22055 .map_err(|e| e.to_string())?;
22056 agent_to_wire(&agent)
22057}
22058
22059async fn handle_agents_tail_log(
22060 req: &JsonRpcMessage,
22061 state: &Arc<ServerState>,
22062 bound_agent: Option<&str>,
22063) -> Result<Value, String> {
22064 let id = extract_agent_id(req)?;
22065 require_own_agent(bound_agent, "agents.tail_log", &id)?;
22066 let n = req
22070 .params
22071 .get("n")
22072 .or_else(|| req.params.get("lines"))
22073 .and_then(Value::as_u64)
22074 .unwrap_or(100) as usize;
22075 let offset = req
22076 .params
22077 .get("offset")
22078 .and_then(Value::as_u64)
22079 .unwrap_or(0) as usize;
22080 let stream = car_registry::supervisor::LogStream::from_wire(
22081 req.params.get("stream").and_then(Value::as_str),
22082 );
22083 let supervisor = state.supervisor()?;
22084 let tail = supervisor
22085 .read_log(&id, stream, n, offset)
22086 .await
22087 .map_err(|e| e.to_string())?;
22088 serde_json::to_value(tail).map_err(|e| e.to_string())
22089}
22090
22091async fn handle_agents_list_external(req: &JsonRpcMessage) -> Result<Value, String> {
22102 let include_health = req
22103 .params
22104 .get("include_health")
22105 .and_then(Value::as_bool)
22106 .unwrap_or(false);
22107 let json = car_ffi_common::external_agents::list(include_health).await?;
22108 serde_json::from_str(&json).map_err(|e| e.to_string())
22109}
22110
22111async fn handle_agents_detect_external(req: &JsonRpcMessage) -> Result<Value, String> {
22112 let include_health = req
22113 .params
22114 .get("include_health")
22115 .and_then(Value::as_bool)
22116 .unwrap_or(false);
22117 let json = car_ffi_common::external_agents::detect(include_health).await?;
22118 serde_json::from_str(&json).map_err(|e| e.to_string())
22119}
22120
22121#[derive(Debug, Deserialize)]
22122struct AssistantInvokeParams {
22123 capability: String,
22124 #[serde(default)]
22125 agent_hint: Option<String>,
22126 payload_json: String,
22127}
22128
22129const ASSISTANT_INVOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22130
22131async fn handle_assistants_invoke(
22135 req: &JsonRpcMessage,
22136 state: &Arc<ServerState>,
22137 host_session: &Arc<crate::session::ClientSession>,
22138) -> Result<Value, String> {
22139 let params: AssistantInvokeParams =
22140 serde_json::from_value(req.params.clone()).map_err(|e| {
22141 format!("assistants.invoke requires {{ capability, agent_hint?, payload_json }}: {e}")
22142 })?;
22143 let capability = params.capability.trim();
22144 if capability.is_empty() {
22145 return Err("assistants.invoke requires a non-empty `capability`".to_string());
22146 }
22147
22148 let registry = car_engine::AgentCapabilityRegistry::new();
22149 car_engine::register_builtins(®istry);
22150 let agent = registry
22151 .select(capability, params.agent_hint.as_deref())
22152 .ok_or_else(|| format!("no ready-to-use assistant supports capability `{capability}`"))?;
22153 let task = car_engine::format_capability_payload(capability, ¶ms.payload_json)
22154 .map_err(|e| e.to_string())?;
22155 let system_prompt = car_engine::agent_metadata(&agent)
22156 .map(|m| m.system_prompt.to_string())
22157 .unwrap_or_else(|| "You are a helpful assistant. Carry out the user's task.".into());
22158
22159 let run_id = uuid::Uuid::new_v4().to_string();
22160 let started_at = chrono::Utc::now();
22161 state
22162 .start_run(crate::session::RunMeta {
22163 run_id: run_id.clone(),
22164 agent_id: agent.clone(),
22165 client_id: host_session.client_id.clone(),
22166 active_client_id: host_session.client_id.clone(),
22167 resume_predecessor_client_id: None,
22168 resume_lease: None,
22169 intent: task.clone(),
22170 outcome_description: Some(format!("Run {capability} with a ready-to-use assistant")),
22171 started_at,
22172 termination: None,
22173 ended_at: None,
22174 turns: Vec::new(),
22175 start_committed: false,
22176 pending_terminal: None,
22177 cancellation_pending: None,
22178 cancellation_receipt: None,
22179 trace_corruption: None,
22180 durability_generation: 0,
22181 })
22182 .await?;
22183
22184 let req = car_inference::GenerateRequest {
22185 prompt: task.clone(),
22186 context: Some(system_prompt),
22187 intent: Some(car_inference::IntentHint {
22188 prefer_fast: true,
22189 ..Default::default()
22190 }),
22191 ..Default::default()
22192 };
22193 let engine = get_inference_engine(state);
22194 let generated: Result<String, String> = match assistant_model_setup_message(engine, &req).await
22195 {
22196 Some(message) => Err(message),
22197 None => match tokio::time::timeout(ASSISTANT_INVOKE_TIMEOUT, engine.generate(req)).await {
22198 Ok(Ok(result)) => Ok(result),
22199 Ok(Err(e)) => Err(format!("Assistant failed: {e}")),
22200 Err(_) => Err(format!(
22201 "Assistant timed out after {} seconds. Choose a faster model in Models, or try again after model setup finishes.",
22202 ASSISTANT_INVOKE_TIMEOUT.as_secs()
22203 )),
22204 },
22205 };
22206
22207 match generated {
22208 Ok(result) => {
22209 registry.note_used(capability, &agent);
22210 let _ = state
22211 .record_run_turns(
22212 &run_id,
22213 vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22214 index: 0,
22215 proposal_id: None,
22216 action_id: None,
22217 action_status: None,
22218 action_duration_ms: None,
22219 action_completed_at: None,
22220 depends_on: None,
22221 state_dependencies: None,
22222 prompt: Some(task),
22223 tool: Some(capability.to_string()),
22224 parameters: serde_json::from_str(¶ms.payload_json).unwrap_or_else(
22225 |_| serde_json::json!({ "payload_json": params.payload_json }),
22226 ),
22227 output: Some(serde_json::json!({ "text": result })),
22228 cli_outcome: None,
22229 verifier_verdict: car_proto::VerifierVerdict::NotRun,
22230 policy_rejected: None,
22231 })],
22232 )
22233 .await;
22234 state
22235 .complete_run(
22236 &run_id,
22237 car_proto::RunTermination::Outcome {
22238 status: car_ir::OutcomeStatus::Success,
22239 outcome: car_ir::AgentOutcome {
22240 status: car_ir::OutcomeStatus::Success,
22241 summary: "Assistant completed successfully.".to_string(),
22242 evidence: Vec::new(),
22243 metrics: Default::default(),
22244 timestamp: chrono::Utc::now(),
22245 },
22246 },
22247 )
22248 .await?;
22249 Ok(serde_json::json!({
22250 "agent": agent,
22251 "result": result,
22252 "run_id": run_id,
22253 }))
22254 }
22255 Err(message) => {
22256 let _ = state
22257 .record_run_turns(
22258 &run_id,
22259 vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22260 index: 0,
22261 proposal_id: None,
22262 action_id: None,
22263 action_status: None,
22264 action_duration_ms: None,
22265 action_completed_at: None,
22266 depends_on: None,
22267 state_dependencies: None,
22268 prompt: Some(task),
22269 tool: Some(capability.to_string()),
22270 parameters: serde_json::from_str(¶ms.payload_json).unwrap_or_else(
22271 |_| serde_json::json!({ "payload_json": params.payload_json }),
22272 ),
22273 output: Some(serde_json::json!({ "error": message.clone() })),
22274 cli_outcome: None,
22275 verifier_verdict: car_proto::VerifierVerdict::NotRun,
22276 policy_rejected: None,
22277 })],
22278 )
22279 .await;
22280 state
22281 .complete_run(
22282 &run_id,
22283 car_proto::RunTermination::Outcome {
22284 status: car_ir::OutcomeStatus::Failure,
22285 outcome: car_ir::AgentOutcome {
22286 status: car_ir::OutcomeStatus::Failure,
22287 summary: message.clone(),
22288 evidence: Vec::new(),
22289 metrics: Default::default(),
22290 timestamp: chrono::Utc::now(),
22291 },
22292 },
22293 )
22294 .await?;
22295 Err(message)
22296 }
22297 }
22298}
22299
22300async fn assistant_model_setup_message(
22301 engine: &car_inference::InferenceEngine,
22302 req: &car_inference::GenerateRequest,
22303) -> Option<String> {
22304 let decision = engine
22305 .route_adaptive_with_intent(&req.prompt, req.intent.clone())
22306 .await;
22307 match engine
22308 .unified_registry
22309 .ready_without_download(&decision.model_id)
22310 {
22311 Some(true) => None,
22312 Some(false) => Some(format!(
22313 "Assistant needs model setup before it can run. Open Models and install {}, then try again.",
22314 decision.model_name
22315 )),
22316 None => Some(format!(
22317 "Assistant selected {}, but that model is not registered. Open Models and choose an available model.",
22318 decision.model_name
22319 )),
22320 }
22321}
22322
22323async fn handle_agents_invoke_external(
22341 req: &JsonRpcMessage,
22342 state: &Arc<ServerState>,
22343 host_session: &Arc<crate::session::ClientSession>,
22344) -> Result<Value, String> {
22345 let id = req
22346 .params
22347 .get("id")
22348 .and_then(Value::as_str)
22349 .ok_or_else(|| "missing required `id` parameter".to_string())?
22350 .to_string();
22351 let task = req
22352 .params
22353 .get("task")
22354 .and_then(Value::as_str)
22355 .ok_or_else(|| "missing required `task` parameter".to_string())?
22356 .to_string();
22357 let stream = req
22358 .params
22359 .get("stream")
22360 .and_then(Value::as_bool)
22361 .unwrap_or(false);
22362 let session_id = req
22363 .params
22364 .get("session_id")
22365 .and_then(Value::as_str)
22366 .map(str::to_string)
22367 .unwrap_or_else(|| format!("ext-{}", uuid::Uuid::new_v4().simple()));
22368
22369 let mut options_value = req.params.clone();
22375 if let Some(obj) = options_value.as_object_mut() {
22376 obj.remove("id");
22377 obj.remove("task");
22378 obj.remove("stream");
22379 obj.remove("session_id");
22380 let has_explicit_mcp = obj.contains_key("mcp_endpoint");
22389 if !has_explicit_mcp {
22390 if let Some(url) = state.mcp_url.get() {
22391 obj.insert("mcp_endpoint".to_string(), Value::String(url.clone()));
22392 }
22393 }
22394 }
22395
22396 if !stream {
22397 let options_json = options_value.to_string();
22400 let json = car_ffi_common::external_agents::invoke(&id, &task, &options_json).await?;
22401 let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
22402 append_external_agent_audit(&id, &task, &options_value, &result);
22403 return Ok(result);
22404 }
22405
22406 let opts: car_external_agents::InvokeOptions = serde_json::from_value(options_value.clone())
22412 .map_err(|e| format!("invalid options: {e}"))?;
22413
22414 let local_cancel = Arc::new(AtomicBool::new(false));
22421 let owns_chat_session = {
22422 let mut chats = state.chat_sessions.lock().await;
22432 if let Some(chat) = chats.get_mut(&session_id) {
22433 if chat.local_cancel.is_none() {
22434 chat.local_cancel = Some(local_cancel.clone());
22435 }
22436 false
22437 } else {
22438 let created_at = std::time::SystemTime::now()
22439 .duration_since(std::time::UNIX_EPOCH)
22440 .map(|d| d.as_secs())
22441 .unwrap_or(0);
22442 chats.insert(
22443 session_id.clone(),
22444 crate::session::ChatSession {
22445 agent_id: id.clone(),
22446 host_client_id: host_session.client_id.clone(),
22447 created_at,
22448 local_cancel: Some(local_cancel.clone()),
22449 },
22450 );
22451 true
22452 }
22453 };
22454
22455 use tokio::sync::mpsc;
22462 let (tx, mut rx) = mpsc::unbounded_channel::<car_external_agents::StreamEvent>();
22463
22464 let drain_state = state.clone();
22465 let drain_session_id = session_id.clone();
22466 let drain_agent_id = id.clone();
22467 tokio::spawn(async move {
22468 while let Some(event) = rx.recv().await {
22469 emit_external_chat_event(&drain_state, &drain_session_id, &drain_agent_id, event).await;
22470 }
22471 });
22472
22473 let emitter_tx = tx.clone();
22474 let emitter: car_external_agents::StreamEventEmitter = Arc::new(move |event| {
22475 let _ = emitter_tx.send(event);
22480 });
22481
22482 let spawn_state = state.clone();
22488 let spawn_session_id = session_id.clone();
22489 let spawn_id = id.clone();
22490 let spawn_task = task.clone();
22491 let spawn_options = options_value.clone();
22492 tokio::spawn(async move {
22493 let outcome = car_external_agents::invoke_with_emitter_and_cancel(
22494 &spawn_id,
22495 &spawn_task,
22496 opts,
22497 Some(emitter),
22498 Some(local_cancel.clone()),
22499 )
22500 .await;
22501 drop(tx); let terminal_params: Value;
22508 let result_value: Value;
22509 match outcome {
22510 Ok(res) => {
22511 let mut parts: Vec<String> = Vec::new();
22518 if res.turns > 0 {
22519 parts.push(format!(
22520 "{} turn{}",
22521 res.turns,
22522 if res.turns == 1 { "" } else { "s" }
22523 ));
22524 }
22525 if res.tool_calls > 0 {
22526 parts.push(format!(
22527 "{} tool{}",
22528 res.tool_calls,
22529 if res.tool_calls == 1 { "" } else { "s" }
22530 ));
22531 }
22532 if res.duration_ms > 0 {
22533 parts.push(format!("{:.1}s", res.duration_ms as f64 / 1000.0));
22534 }
22535 if res.dropped_attachments > 0 {
22536 parts.push(format!(
22537 "{} image{} skipped",
22538 res.dropped_attachments,
22539 if res.dropped_attachments == 1 {
22540 ""
22541 } else {
22542 "s"
22543 }
22544 ));
22545 }
22546 let summary = if parts.is_empty() {
22547 "stop".to_string()
22548 } else {
22549 parts.join(" · ")
22550 };
22551 if res.is_error {
22552 terminal_params = serde_json::json!({
22553 "session_id": spawn_session_id,
22554 "agent_id": spawn_id,
22555 "kind": "error",
22556 "error": res.error.clone().unwrap_or_else(|| "external agent reported error".to_string()),
22557 });
22558 } else {
22559 terminal_params = serde_json::json!({
22560 "session_id": spawn_session_id,
22561 "agent_id": spawn_id,
22562 "kind": "done",
22563 "finish_reason": summary,
22564 "dropped_attachments": res.dropped_attachments,
22567 });
22568 }
22569 result_value = serde_json::to_value(&res).unwrap_or(Value::Null);
22570 }
22571 Err(e) => {
22572 let message = format!("{e}");
22573 terminal_params = serde_json::json!({
22574 "session_id": spawn_session_id,
22575 "agent_id": spawn_id,
22576 "kind": "error",
22577 "error": message.clone(),
22578 });
22579 result_value = serde_json::json!({ "is_error": true, "error": message });
22580 }
22581 }
22582 send_external_chat_frame(&spawn_state, &spawn_session_id, terminal_params).await;
22583 remove_owned_external_chat_session(&spawn_state, &spawn_session_id, owns_chat_session)
22584 .await;
22585 append_external_agent_audit(&spawn_id, &spawn_task, &spawn_options, &result_value);
22586 });
22587
22588 Ok(serde_json::json!({
22589 "accepted": true,
22590 "session_id": session_id,
22591 }))
22592}
22593
22594async fn remove_owned_external_chat_session(
22595 state: &Arc<ServerState>,
22596 session_id: &str,
22597 owns_chat_session: bool,
22598) {
22599 if owns_chat_session {
22600 state.chat_sessions.lock().await.remove(session_id);
22601 }
22602}
22603
22604async fn emit_external_chat_event(
22621 state: &Arc<ServerState>,
22622 session_id: &str,
22623 agent_id: &str,
22624 event: car_external_agents::StreamEvent,
22625) {
22626 use car_external_agents::StreamEvent;
22627 match event {
22628 StreamEvent::Assistant(a) => {
22629 if let Some(content) = a.message.get("content").and_then(|v| v.as_array()) {
22630 for block in content {
22631 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
22632 match block_type {
22633 "text" => {
22634 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
22635 if !text.is_empty() {
22636 let params = serde_json::json!({
22637 "session_id": session_id,
22638 "agent_id": agent_id,
22639 "kind": "token",
22640 "delta": text,
22641 });
22642 send_external_chat_frame(state, session_id, params).await;
22643 }
22644 }
22645 }
22646 "tool_use" => {
22647 let name = block
22648 .get("name")
22649 .and_then(|v| v.as_str())
22650 .unwrap_or("(unknown tool)");
22651 let mut frame = serde_json::json!({
22657 "session_id": session_id,
22658 "agent_id": agent_id,
22659 "kind": "tool_call",
22660 "tool": name,
22661 "detail": name,
22662 });
22663 if let Some(input) = block.get("input") {
22664 if !input.is_null() {
22665 frame["params"] = input.clone();
22666 }
22667 }
22668 send_external_chat_frame(state, session_id, frame).await;
22669 }
22670 _ => {}
22671 }
22672 }
22673 }
22674 }
22675 _ => {
22676 }
22681 }
22682}
22683
22684async fn send_external_chat_frame(state: &Arc<ServerState>, session_id: &str, params: Value) {
22689 use futures::SinkExt;
22690 use tokio_tungstenite::tungstenite::Message;
22691
22692 update_chat_goal_from_event(state, session_id, ¶ms).await;
22693
22694 let host_client_id = state
22695 .chat_sessions
22696 .lock()
22697 .await
22698 .get(session_id)
22699 .map(|s| s.host_client_id.clone());
22700 let Some(host_client_id) = host_client_id else {
22701 return;
22702 };
22703 let host_channel = {
22704 let sessions = state.sessions.lock().await;
22705 sessions.get(&host_client_id).map(|s| s.channel.clone())
22706 };
22707 let Some(channel) = host_channel else {
22708 return;
22709 };
22710 let frame = serde_json::json!({
22711 "jsonrpc": "2.0",
22712 "method": "agents.chat.event",
22713 "params": params,
22714 });
22715 if let Ok(text) = serde_json::to_string(&frame) {
22716 let _ = channel
22717 .write
22718 .lock()
22719 .await
22720 .send(Message::Text(text.into()))
22721 .await;
22722 }
22723}
22724
22725fn append_external_agent_audit(id: &str, task: &str, options: &Value, result: &Value) {
22733 use std::io::Write;
22734 let car_dir = match car_home::root() {
22735 Some(root) => root,
22736 None => return,
22737 };
22738 if std::fs::create_dir_all(&car_dir).is_err() {
22739 return;
22740 }
22741 let path = car_dir.join("external-agents.jsonl");
22742 let record = serde_json::json!({
22743 "ts": chrono::Utc::now().to_rfc3339(),
22744 "adapter_id": id,
22745 "task": task,
22746 "options": options,
22747 "result": result,
22748 });
22749 let line = match serde_json::to_string(&record) {
22750 Ok(s) => s,
22751 Err(_) => return,
22752 };
22753 if let Ok(mut f) = std::fs::OpenOptions::new()
22754 .create(true)
22755 .append(true)
22756 .open(&path)
22757 {
22758 let _ = writeln!(f, "{}", line);
22759 } else {
22760 tracing::warn!(
22761 path = %path.display(),
22762 "failed to append external-agent audit record"
22763 );
22764 }
22765}
22766
22767async fn handle_agents_health_external(req: &JsonRpcMessage) -> Result<Value, String> {
22773 let force = req
22774 .params
22775 .get("force")
22776 .and_then(Value::as_bool)
22777 .unwrap_or(false);
22778 if let Some(id) = req.params.get("id").and_then(Value::as_str) {
22779 let json = car_ffi_common::external_agents::health_one(id, force).await?;
22780 serde_json::from_str(&json).map_err(|e| e.to_string())
22781 } else {
22782 let json = car_ffi_common::external_agents::health(force).await?;
22783 serde_json::from_str(&json).map_err(|e| e.to_string())
22784 }
22785}
22786
22787const AGENT_CHAT_ACK_TIMEOUT_SECS: u64 = 5;
22805
22806fn extract_chat_attachments(params: &Value) -> Result<Option<Value>, String> {
22816 const ALLOWED_MEDIA: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
22817 let items = match params.get("attachments") {
22818 None | Some(Value::Null) => return Ok(None),
22819 Some(Value::Array(items)) => items,
22820 Some(_) => return Err("`agents.chat` `attachments` must be an array".to_string()),
22821 };
22822 for item in items {
22827 let ty = item.get("type").and_then(Value::as_str).ok_or_else(|| {
22828 "`agents.chat` `attachments` entries must have a string `type`".to_string()
22829 })?;
22830 match ty {
22831 "image_base64" => {
22832 if item.get("data").and_then(Value::as_str).is_none() {
22833 return Err("`image_base64` attachment requires a string `data`".to_string());
22834 }
22835 let media = item.get("media_type").and_then(Value::as_str).unwrap_or("");
22836 if !ALLOWED_MEDIA.contains(&media) {
22837 return Err(format!(
22838 "`image_base64` attachment `media_type` must be one of {ALLOWED_MEDIA:?}"
22839 ));
22840 }
22841 }
22842 "image_url" => {
22843 let url = item.get("url").and_then(Value::as_str).unwrap_or("");
22844 if !(url.starts_with("https://") || url.starts_with("http://")) {
22845 return Err("`image_url` attachment `url` must be http(s)".to_string());
22846 }
22847 }
22848 other => {
22849 return Err(format!(
22850 "`agents.chat` `attachments` entries must be image ContentBlocks \
22851 (`image_base64` or `image_url`), got `{other}`"
22852 ));
22853 }
22854 }
22855 }
22856 Ok(Some(Value::Array(items.clone())))
22857}
22858
22859fn extract_chat_goal(params: &Value) -> Result<Option<Value>, String> {
22863 let Some(goal) = params.get("goal") else {
22864 return Ok(None);
22865 };
22866 if goal.is_null() {
22867 return Ok(None);
22868 }
22869 let obj = goal
22870 .as_object()
22871 .ok_or_else(|| "`agents.chat` `goal` must be an object".to_string())?;
22872 let check = obj
22873 .get("check")
22874 .and_then(Value::as_str)
22875 .map(str::trim)
22876 .filter(|s| !s.is_empty())
22877 .ok_or_else(|| "`agents.chat` `goal.check` must be a non-empty string".to_string())?;
22878 let max_iterations = obj
22879 .get("max_iterations")
22880 .and_then(Value::as_u64)
22881 .unwrap_or(8)
22882 .clamp(1, 50);
22883 Ok(Some(serde_json::json!({
22884 "check": check,
22885 "max_iterations": max_iterations,
22886 })))
22887}
22888
22889fn extract_chat_model(params: &Value) -> Result<Option<String>, String> {
22894 match params.get("model") {
22895 None | Some(Value::Null) => Ok(None),
22896 Some(Value::String(model)) => {
22897 Ok((!model.trim().is_empty()).then(|| model.trim().to_string()))
22898 }
22899 Some(_) => Err("`agents.chat` `model` must be a string".to_string()),
22900 }
22901}
22902
22903fn unix_secs_now() -> u64 {
22904 std::time::SystemTime::now()
22905 .duration_since(std::time::UNIX_EPOCH)
22906 .map(|d| d.as_secs())
22907 .unwrap_or(0)
22908}
22909
22910fn chat_goal_value_to_state(session_id: &str, goal: &Value) -> Result<ChatGoalState, String> {
22911 let obj = goal
22912 .as_object()
22913 .ok_or_else(|| "`goal` must be an object".to_string())?;
22914 let check = obj
22915 .get("check")
22916 .and_then(Value::as_str)
22917 .map(str::trim)
22918 .filter(|s| !s.is_empty())
22919 .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
22920 let max_iterations = obj
22921 .get("max_iterations")
22922 .and_then(Value::as_u64)
22923 .unwrap_or(8)
22924 .clamp(1, 50) as u32;
22925 Ok(ChatGoalState {
22926 session_id: session_id.to_string(),
22927 check: check.to_string(),
22928 max_iterations,
22929 status: "active".to_string(),
22930 last_iteration: None,
22931 last_met: None,
22932 last_grounded: None,
22933 last_reason: None,
22934 terminal_kind: None,
22935 terminal_message: None,
22936 updated_at: unix_secs_now(),
22937 })
22938}
22939
22940fn chat_goal_state_to_value(goal: &ChatGoalState) -> Result<Value, String> {
22941 serde_json::to_value(goal).map_err(|e| e.to_string())
22942}
22943
22944fn chat_goal_state_to_wire_goal(goal: &ChatGoalState) -> Value {
22945 serde_json::json!({
22946 "check": goal.check,
22947 "max_iterations": goal.max_iterations,
22948 })
22949}
22950
22951async fn ensure_chat_goal_running(
22952 state: &Arc<ServerState>,
22953 session_id: &str,
22954 goal_value: &Value,
22955) -> Result<(), String> {
22956 let mut inserted = false;
22957 {
22958 let mut goals = state.chat_goals.lock().await;
22959 if let Some(stored) = goals.get_mut(session_id) {
22960 stored.status = "running".to_string();
22961 stored.terminal_kind = None;
22962 stored.terminal_message = None;
22963 stored.updated_at = unix_secs_now();
22964 } else {
22965 let mut goal = chat_goal_value_to_state(session_id, goal_value)?;
22966 goal.status = "running".to_string();
22967 goals.insert(session_id.to_string(), goal);
22968 inserted = true;
22969 }
22970 }
22971 state.persist_chat_goals().await?;
22972 if inserted {
22973 tracing::debug!(
22974 session_id,
22975 "created durable chat goal status for inline goal"
22976 );
22977 }
22978 Ok(())
22979}
22980
22981async fn handle_goal_suggest(
22982 req: &JsonRpcMessage,
22983 state: &Arc<ServerState>,
22984) -> Result<Value, String> {
22985 let prompt = req
22986 .params
22987 .get("prompt")
22988 .or_else(|| req.params.get("objective"))
22989 .and_then(Value::as_str)
22990 .map(str::trim)
22991 .filter(|s| !s.is_empty())
22992 .ok_or_else(|| "`goal.suggest` requires `prompt` or `objective`".to_string())?;
22993 let cwd = req
22994 .params
22995 .get("working_dir")
22996 .or_else(|| req.params.get("cwd"))
22997 .and_then(Value::as_str)
22998 .map(std::path::PathBuf::from);
22999 let mut result = crate::goal_suggest::synthesize_goal_check(prompt, cwd.as_deref());
23000
23001 let should_set = req
23002 .params
23003 .get("set")
23004 .and_then(Value::as_bool)
23005 .unwrap_or(false);
23006 if should_set {
23007 let session_id = req
23008 .params
23009 .get("session_id")
23010 .and_then(Value::as_str)
23011 .map(str::trim)
23012 .filter(|s| !s.is_empty())
23013 .ok_or_else(|| "`goal.suggest` with `set: true` requires `session_id`".to_string())?;
23014 let goal_value = result.get("goal").cloned().unwrap_or(Value::Null);
23015 if goal_value.is_null() {
23016 return Err("`goal.suggest` could not infer a goal to set".to_string());
23017 }
23018 let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23019 state
23020 .chat_goals
23021 .lock()
23022 .await
23023 .insert(session_id.to_string(), goal.clone());
23024 state.persist_chat_goals().await?;
23025 if let Some(obj) = result.as_object_mut() {
23026 obj.insert("set".to_string(), Value::Bool(true));
23027 obj.insert(
23028 "session_id".to_string(),
23029 Value::String(session_id.to_string()),
23030 );
23031 obj.insert("stored_goal".to_string(), chat_goal_state_to_value(&goal)?);
23032 }
23033 }
23034
23035 Ok(result)
23036}
23037
23038async fn handle_goal_set(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
23039 let session_id = req
23040 .params
23041 .get("session_id")
23042 .and_then(Value::as_str)
23043 .map(str::trim)
23044 .filter(|s| !s.is_empty())
23045 .ok_or_else(|| "`goal.set` requires `session_id`".to_string())?;
23046 let goal_value = if let Some(goal) = req.params.get("goal") {
23047 goal.clone()
23048 } else {
23049 serde_json::json!({
23050 "check": req.params.get("check").cloned().unwrap_or(Value::Null),
23051 "max_iterations": req.params.get("max_iterations").cloned().unwrap_or(Value::Null),
23052 })
23053 };
23054 let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23055 state
23056 .chat_goals
23057 .lock()
23058 .await
23059 .insert(session_id.to_string(), goal.clone());
23060 state.persist_chat_goals().await?;
23061 Ok(serde_json::json!({
23062 "set": true,
23063 "goal": chat_goal_state_to_value(&goal)?,
23064 }))
23065}
23066
23067async fn handle_goal_status(
23068 req: &JsonRpcMessage,
23069 state: &Arc<ServerState>,
23070) -> Result<Value, String> {
23071 let goals = state.chat_goals.lock().await;
23072 if let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) {
23073 let goal = goals.get(session_id).cloned();
23074 return Ok(serde_json::json!({
23075 "session_id": session_id,
23076 "goal": goal
23077 .as_ref()
23078 .map(chat_goal_state_to_value)
23079 .transpose()?
23080 .unwrap_or(Value::Null),
23081 }));
23082 }
23083 let mut values = Vec::with_capacity(goals.len());
23084 for goal in goals.values() {
23085 values.push(chat_goal_state_to_value(goal)?);
23086 }
23087 Ok(serde_json::json!({ "goals": values }))
23088}
23089
23090async fn handle_goal_clear(
23091 req: &JsonRpcMessage,
23092 state: &Arc<ServerState>,
23093) -> Result<Value, String> {
23094 let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) else {
23095 let mut goals = state.chat_goals.lock().await;
23096 let removed = goals.len();
23097 goals.clear();
23098 drop(goals);
23099 state.persist_chat_goals().await?;
23100 return Ok(serde_json::json!({ "cleared": removed }));
23101 };
23102 let removed = state.chat_goals.lock().await.remove(session_id).is_some();
23103 state.persist_chat_goals().await?;
23104 Ok(serde_json::json!({
23105 "cleared": removed,
23106 "session_id": session_id,
23107 }))
23108}
23109
23110fn build_agent_chat_params(
23115 session_id: &str,
23116 prompt: &str,
23117 stream: bool,
23118 host_client_id: &str,
23119 voice_input: bool,
23120 model: Option<String>,
23121 attachments: Option<Value>,
23122 goal: Option<Value>,
23123) -> Value {
23124 let mut params = serde_json::json!({
23125 "session_id": session_id,
23126 "prompt": prompt,
23127 "stream": stream,
23128 "context": {
23129 "host_client_id": host_client_id,
23130 "voice_input": voice_input,
23131 },
23132 });
23133 if let Some(model) = model {
23134 if let Some(obj) = params.as_object_mut() {
23135 obj.insert("model".to_string(), Value::String(model));
23136 }
23137 }
23138 if let Some(att) = attachments {
23139 if let Some(obj) = params.as_object_mut() {
23140 obj.insert("attachments".to_string(), att);
23141 }
23142 }
23143 if let Some(goal) = goal {
23144 if let Some(obj) = params.as_object_mut() {
23145 obj.insert("goal".to_string(), goal);
23146 }
23147 }
23148 params
23149}
23150
23151async fn resolve_assistant_agent_alias(agent_id: String, state: &Arc<ServerState>) -> String {
23159 if !crate::assistant::register::is_assistant_alias(&agent_id) {
23160 return agent_id;
23161 }
23162 let attached = state.attached_agents.lock().await;
23163 if attached.contains_key(&agent_id) {
23164 return agent_id;
23165 }
23166 for alias in [
23167 crate::assistant::register::ASSISTANT_AGENT_ID,
23168 crate::assistant::register::LEGACY_ASSISTANT_AGENT_ID,
23169 ] {
23170 if alias != agent_id && attached.contains_key(alias) {
23171 return alias.to_string();
23172 }
23173 }
23174 agent_id
23175}
23176
23177async fn handle_agents_chat(
23178 req: &JsonRpcMessage,
23179 state: &Arc<ServerState>,
23180 host_session: &Arc<crate::session::ClientSession>,
23181) -> Result<Value, String> {
23182 use futures::SinkExt;
23183 use tokio::sync::oneshot;
23184 use tokio_tungstenite::tungstenite::Message;
23185
23186 let agent_id = req
23187 .params
23188 .get("agent_id")
23189 .and_then(Value::as_str)
23190 .ok_or_else(|| "`agents.chat` requires `agent_id`".to_string())?
23191 .to_string();
23192 let agent_id = resolve_assistant_agent_alias(agent_id, state).await;
23198 let prompt = req
23199 .params
23200 .get("prompt")
23201 .and_then(Value::as_str)
23202 .ok_or_else(|| "`agents.chat` requires `prompt`".to_string())?
23203 .to_string();
23204 let session_id = req
23205 .params
23206 .get("session_id")
23207 .and_then(Value::as_str)
23208 .map(str::to_string)
23209 .unwrap_or_else(|| format!("chat-{}", uuid::Uuid::new_v4().simple()));
23210 let stream = req
23211 .params
23212 .get("stream")
23213 .and_then(Value::as_bool)
23214 .unwrap_or(true);
23215 let voice_input = req
23216 .params
23217 .get("voice_input")
23218 .and_then(Value::as_bool)
23219 .unwrap_or(false);
23220 let model = extract_chat_model(&req.params)?;
23221
23222 let attachments = extract_chat_attachments(&req.params)?;
23227 let inline_goal = extract_chat_goal(&req.params)?;
23228 let stored_goal = if inline_goal.is_none() {
23229 state
23230 .chat_goals
23231 .lock()
23232 .await
23233 .get(&session_id)
23234 .map(chat_goal_state_to_wire_goal)
23235 } else {
23236 None
23237 };
23238 let goal = inline_goal.or(stored_goal);
23239 if goal.is_some() && attachments.is_some() {
23240 return Err(
23241 "`agents.chat` `goal` currently cannot be combined with `attachments`".to_string(),
23242 );
23243 }
23244 let has_attached_agent = state.attached_agents.lock().await.contains_key(&agent_id);
23245 let declarative_spec = if has_attached_agent {
23246 None
23247 } else {
23248 state.declagents().ok().and_then(|reg| reg.get(&agent_id))
23249 };
23250
23251 if !has_attached_agent && declarative_spec.is_none() {
23267 return Err(format!(
23268 "`{agent_id}` is not attached to this daemon and is not a declarative agent"
23269 ));
23270 }
23271 let chat_principal = session_principal_for_peers(host_session).await;
23272 {
23273 let sender_agent = host_session.agent_id.lock().await.clone();
23274 let is_host = host_session
23275 .is_host
23276 .load(std::sync::atomic::Ordering::Acquire);
23277 crate::peers::admit_turn(
23278 &state.clone(),
23279 &chat_principal,
23280 sender_agent,
23281 is_host,
23282 &agent_id,
23283 &prompt,
23284 )
23285 .await?;
23286 }
23287
23288 if !has_attached_agent {
23289 if let Some(spec) = declarative_spec {
23290 return handle_declarative_agents_chat(
23291 state,
23292 host_session,
23293 spec,
23294 prompt,
23295 session_id,
23296 stream,
23297 model,
23298 attachments,
23299 goal,
23300 )
23301 .await;
23302 }
23303 }
23304 let resolved = async {
23315 let agent_client_id = state
23316 .attached_agents
23317 .lock()
23318 .await
23319 .get(&agent_id)
23320 .cloned()
23321 .ok_or_else(|| {
23322 format!(
23323 "agent `{}` is not attached to this daemon — supervisor may have it stopped, or it hasn't called session.auth yet",
23324 agent_id
23325 )
23326 })?;
23327 let sessions = state.sessions.lock().await;
23328 sessions
23329 .get(&agent_client_id)
23330 .map(|s| s.channel.clone())
23331 .ok_or_else(|| {
23332 format!(
23333 "agent `{}` client_id `{}` not found in session registry (raced with disconnect)",
23334 agent_id, agent_client_id
23335 )
23336 })
23337 }
23338 .await;
23339 let agent_channel = match resolved {
23340 Ok(ch) => ch,
23341 Err(e) => {
23342 crate::peers::forget_synchronous_turn(state, &chat_principal, &agent_id, &prompt).await;
23343 return Err(e);
23344 }
23345 };
23346
23347 crate::peers::append_agent_chat_audit(
23350 state,
23351 &session_principal(host_session).await,
23352 &agent_id,
23353 &session_id,
23354 );
23355
23356 if let Some(goal_value) = &goal {
23357 ensure_chat_goal_running(state, &session_id, goal_value).await?;
23358 }
23359
23360 {
23366 let created_at = std::time::SystemTime::now()
23367 .duration_since(std::time::UNIX_EPOCH)
23368 .map(|d| d.as_secs())
23369 .unwrap_or(0);
23370 state.chat_sessions.lock().await.insert(
23371 session_id.clone(),
23372 crate::session::ChatSession {
23373 agent_id: agent_id.clone(),
23374 host_client_id: host_session.client_id.clone(),
23375 created_at,
23376 local_cancel: None,
23377 },
23378 );
23379 }
23380
23381 let request_id = agent_channel.next_request_id();
23388 let (tx, rx) = oneshot::channel();
23389 agent_channel
23390 .pending
23391 .lock()
23392 .await
23393 .insert(request_id.clone(), tx);
23394
23395 let chat_params = build_agent_chat_params(
23396 &session_id,
23397 &prompt,
23398 stream,
23399 &host_session.client_id,
23400 voice_input,
23401 model,
23402 attachments,
23403 goal,
23404 );
23405 let rpc_request = serde_json::json!({
23406 "jsonrpc": "2.0",
23407 "method": "agent.chat",
23408 "params": chat_params,
23409 "id": request_id,
23410 });
23411 let msg = Message::Text(
23412 serde_json::to_string(&rpc_request)
23413 .map_err(|e| e.to_string())?
23414 .into(),
23415 );
23416 if let Err(e) = agent_channel.write.lock().await.send(msg).await {
23417 agent_channel.pending.lock().await.remove(&request_id);
23421 state.chat_sessions.lock().await.remove(&session_id);
23422 update_chat_goal_from_event(
23423 state,
23424 &session_id,
23425 &serde_json::json!({
23426 "kind": "error",
23427 "error": format!("failed to deliver agent.chat to `{}`: {}", agent_id, e),
23428 }),
23429 )
23430 .await;
23431 return Err(format!(
23432 "failed to deliver agent.chat to `{}`: {}",
23433 agent_id, e
23434 ));
23435 }
23436
23437 let ack = match tokio::time::timeout(
23442 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23443 rx,
23444 )
23445 .await
23446 {
23447 Ok(Ok(resp)) => resp,
23448 Ok(Err(_)) => {
23449 state.chat_sessions.lock().await.remove(&session_id);
23451 update_chat_goal_from_event(
23452 state,
23453 &session_id,
23454 &serde_json::json!({
23455 "kind": "error",
23456 "error": format!("agent `{}` disconnected before acking agents.chat", agent_id),
23457 }),
23458 )
23459 .await;
23460 return Err(format!(
23461 "agent `{}` disconnected before acking agents.chat",
23462 agent_id
23463 ));
23464 }
23465 Err(_) => {
23466 agent_channel.pending.lock().await.remove(&request_id);
23470 state.chat_sessions.lock().await.remove(&session_id);
23471 update_chat_goal_from_event(
23472 state,
23473 &session_id,
23474 &serde_json::json!({
23475 "kind": "error",
23476 "error": format!(
23477 "agent `{}` did not ack agents.chat within {}s",
23478 agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
23479 ),
23480 }),
23481 )
23482 .await;
23483 return Err(format!(
23484 "agent `{}` did not ack agents.chat within {}s",
23485 agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
23486 ));
23487 }
23488 };
23489
23490 if let Some(err) = ack.error {
23491 state.chat_sessions.lock().await.remove(&session_id);
23493 update_chat_goal_from_event(
23494 state,
23495 &session_id,
23496 &serde_json::json!({
23497 "kind": "error",
23498 "error": format!("agent `{}` rejected chat: {}", agent_id, err),
23499 }),
23500 )
23501 .await;
23502 return Err(format!("agent `{}` rejected chat: {}", agent_id, err));
23503 }
23504
23505 Ok(serde_json::json!({
23506 "accepted": true,
23507 "session_id": session_id,
23508 }))
23509}
23510
23511async fn handle_declarative_agents_chat(
23512 state: &Arc<ServerState>,
23513 host_session: &Arc<crate::session::ClientSession>,
23514 spec: car_registry::declarative::DeclarativeAgentSpec,
23515 prompt: String,
23516 session_id: String,
23517 _stream: bool,
23518 model: Option<String>,
23519 attachments: Option<Value>,
23520 goal: Option<Value>,
23521) -> Result<Value, String> {
23522 if attachments.is_some() {
23523 return Err("declarative agents do not support `agents.chat` attachments".to_string());
23524 }
23525 if goal.is_some() {
23526 return Err(
23527 "`agents.chat` inline/standing goals are only supported by attached chat agents; \
23528 declarative agents use their manifest `goal`"
23529 .to_string(),
23530 );
23531 }
23532 if !spec.enabled {
23533 return Err(format!("agent '{}' is disabled", spec.id));
23534 }
23535
23536 let local_cancel = Arc::new(AtomicBool::new(false));
23537 {
23538 let created_at = std::time::SystemTime::now()
23539 .duration_since(std::time::UNIX_EPOCH)
23540 .map(|d| d.as_secs())
23541 .unwrap_or(0);
23542 state.chat_sessions.lock().await.insert(
23543 session_id.clone(),
23544 crate::session::ChatSession {
23545 agent_id: spec.id.clone(),
23546 host_client_id: host_session.client_id.clone(),
23547 created_at,
23548 local_cancel: Some(local_cancel.clone()),
23549 },
23550 );
23551 }
23552
23553 let state_for_task = state.clone();
23554 let sid = session_id.clone();
23555 tokio::spawn(async move {
23556 let result = crate::coder::rpc::run_declarative_with_cancel_and_model(
23557 &spec,
23558 &prompt,
23559 &state_for_task,
23560 Some(local_cancel.clone()),
23561 model,
23562 )
23563 .await;
23564 match result {
23565 Ok(run) => {
23566 if local_cancel.load(Ordering::SeqCst) {
23567 send_external_chat_frame(
23568 &state_for_task,
23569 &sid,
23570 serde_json::json!({
23571 "session_id": sid.clone(),
23572 "agent_id": spec.id.clone(),
23573 "kind": "error",
23574 "error": "cancelled",
23575 "result": crate::coder::rpc::run_result_json(&run),
23576 }),
23577 )
23578 .await;
23579 state_for_task.chat_sessions.lock().await.remove(&sid);
23580 return;
23581 }
23582 crate::coder::rpc::record_routing_outcome(&state_for_task, &spec.id, &run);
23583 if let Some(goal) = &run.goal {
23584 send_external_chat_frame(
23585 &state_for_task,
23586 &sid,
23587 serde_json::json!({
23588 "session_id": sid.clone(),
23589 "agent_id": spec.id.clone(),
23590 "kind": "goal_evaluated",
23591 "iteration": goal.iterations,
23592 "met": goal.met,
23593 "grounded": goal.grounded,
23594 "reason": goal.last_reason,
23595 }),
23596 )
23597 .await;
23598 }
23599 let result_json = crate::coder::rpc::run_result_json(&run);
23600 if let Some(err) = run.error.clone() {
23601 send_external_chat_frame(
23602 &state_for_task,
23603 &sid,
23604 serde_json::json!({
23605 "session_id": sid.clone(),
23606 "agent_id": spec.id.clone(),
23607 "kind": "error",
23608 "error": err,
23609 "result": result_json,
23610 }),
23611 )
23612 .await;
23613 } else {
23614 let text = run.output.clone();
23615 if !text.is_empty() {
23616 send_external_chat_frame(
23617 &state_for_task,
23618 &sid,
23619 serde_json::json!({
23620 "session_id": sid.clone(),
23621 "agent_id": spec.id.clone(),
23622 "kind": "token",
23623 "delta": text,
23624 }),
23625 )
23626 .await;
23627 }
23628 send_external_chat_frame(
23629 &state_for_task,
23630 &sid,
23631 serde_json::json!({
23632 "session_id": sid.clone(),
23633 "agent_id": spec.id.clone(),
23634 "kind": "done",
23635 "text": text,
23636 "finish_reason": "declarative agent completed",
23637 "result": result_json,
23638 }),
23639 )
23640 .await;
23641 }
23642 }
23643 Err(e) => {
23644 send_external_chat_frame(
23645 &state_for_task,
23646 &sid,
23647 serde_json::json!({
23648 "session_id": sid.clone(),
23649 "agent_id": spec.id.clone(),
23650 "kind": "error",
23651 "error": e,
23652 }),
23653 )
23654 .await;
23655 }
23656 }
23657 state_for_task.chat_sessions.lock().await.remove(&sid);
23658 });
23659
23660 Ok(serde_json::json!({
23661 "accepted": true,
23662 "session_id": session_id,
23663 "in_daemon": true,
23664 }))
23665}
23666
23667const A2A_CHAT_TIMEOUT_SECS: u64 = 180;
23672
23673pub(crate) const A2A_CONVERSATIONAL_PRINCIPAL: &str = "a2a:conversational";
23685
23686pub(crate) async fn chat_collect(
23687 state: &Arc<ServerState>,
23688 host_channel: &Arc<crate::session::WsChannel>,
23689 host_client_id: &str,
23690 prompt: &str,
23691) -> Result<String, String> {
23692 use futures::SinkExt;
23693 use tokio::sync::{mpsc, oneshot};
23694 use tokio_tungstenite::tungstenite::Message;
23695
23696 crate::peers::admit_turn(
23706 state,
23707 A2A_CONVERSATIONAL_PRINCIPAL,
23708 None,
23709 false,
23710 host_client_id,
23711 prompt,
23712 )
23713 .await?;
23714
23715 let session_id = format!("a2a-chat-{}", uuid::Uuid::new_v4().simple());
23716 let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<crate::session::ChatStreamChunk>();
23717 state.chat_collectors.lock().await.insert(
23718 session_id.clone(),
23719 crate::session::ChatCollector {
23720 tx: chunk_tx,
23721 host_client_id: host_client_id.to_string(),
23722 },
23723 );
23724
23725 let request_id = host_channel.next_request_id();
23727 let (ack_tx, ack_rx) = oneshot::channel();
23728 host_channel
23729 .pending
23730 .lock()
23731 .await
23732 .insert(request_id.clone(), ack_tx);
23733
23734 let req = serde_json::json!({
23735 "jsonrpc": "2.0",
23736 "method": "agent.chat",
23737 "params": {
23738 "session_id": session_id,
23739 "prompt": prompt,
23740 "stream": true,
23741 "context": { "source": "a2a" },
23742 },
23743 "id": request_id,
23744 });
23745 if let Err(e) = host_channel
23746 .write
23747 .lock()
23748 .await
23749 .send(Message::Text(
23750 serde_json::to_string(&req).unwrap_or_default().into(),
23751 ))
23752 .await
23753 {
23754 host_channel.pending.lock().await.remove(&request_id);
23755 state.chat_collectors.lock().await.remove(&session_id);
23756 return Err(format!("failed to deliver agent.chat to host: {e}"));
23757 }
23758
23759 let ack = tokio::time::timeout(
23761 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23762 ack_rx,
23763 )
23764 .await;
23765 match ack {
23766 Ok(Ok(resp)) => {
23767 if let Some(err) = resp.error {
23768 state.chat_collectors.lock().await.remove(&session_id);
23769 return Err(format!("host agent rejected chat: {err}"));
23770 }
23771 }
23772 Ok(Err(_)) => {
23773 state.chat_collectors.lock().await.remove(&session_id);
23774 return Err("host agent disconnected before acking agent.chat".to_string());
23775 }
23776 Err(_) => {
23777 host_channel.pending.lock().await.remove(&request_id);
23778 state.chat_collectors.lock().await.remove(&session_id);
23779 return Err(format!(
23780 "host agent did not ack agent.chat within {AGENT_CHAT_ACK_TIMEOUT_SECS}s"
23781 ));
23782 }
23783 }
23784
23785 let collect = async {
23787 let mut text = String::new();
23788 while let Some(chunk) = chunk_rx.recv().await {
23789 match chunk.kind.as_str() {
23790 "token" => {
23791 if let Some(d) = chunk.delta {
23792 text.push_str(&d);
23793 }
23794 }
23795 "done" => return Ok(text),
23796 "error" => {
23797 return Err(chunk
23798 .error
23799 .unwrap_or_else(|| "host agent error".to_string()));
23800 }
23801 _ => {}
23802 }
23803 }
23804 Err("agent.chat stream ended before completion".to_string())
23805 };
23806 let result = tokio::time::timeout(
23807 std::time::Duration::from_secs(A2A_CHAT_TIMEOUT_SECS),
23808 collect,
23809 )
23810 .await;
23811 state.chat_collectors.lock().await.remove(&session_id);
23812 match result {
23813 Ok(r) => r,
23814 Err(_) => Err(format!(
23815 "host agent did not complete the reply within {A2A_CHAT_TIMEOUT_SECS}s"
23816 )),
23817 }
23818}
23819
23820pub(crate) struct WsChatResponder {
23826 pub state: std::sync::Weak<ServerState>,
23827 pub host_client_id: String,
23828}
23829
23830#[async_trait::async_trait]
23831impl car_a2a::ChatResponder for WsChatResponder {
23832 async fn respond(&self, prompt: &str) -> Result<String, String> {
23833 let state = self
23834 .state
23835 .upgrade()
23836 .ok_or_else(|| "daemon is shutting down".to_string())?;
23837 let host_channel = {
23838 let sessions = state.sessions.lock().await;
23839 sessions
23840 .get(&self.host_client_id)
23841 .map(|s| s.channel.clone())
23842 }
23843 .ok_or_else(|| "host session for the A2A listener has disconnected".to_string())?;
23844 chat_collect(&state, &host_channel, &self.host_client_id, prompt).await
23845 }
23846}
23847
23848async fn handle_agents_chat_cancel(
23856 req: &JsonRpcMessage,
23857 state: &Arc<ServerState>,
23858 caller_client_id: &str,
23859 caller_is_host: bool,
23860) -> Result<Value, String> {
23861 use futures::SinkExt;
23862 use tokio_tungstenite::tungstenite::Message;
23863
23864 let session_id = req
23865 .params
23866 .get("session_id")
23867 .and_then(Value::as_str)
23868 .ok_or_else(|| "`agents.chat.cancel` requires `session_id`".to_string())?
23869 .to_string();
23870
23871 {
23877 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
23878 if let Some(chat) = chat {
23879 if !caller_is_host && caller_client_id != chat.host_client_id {
23880 return Err(
23881 "`agents.chat.cancel` requires the originating host session or \
23882 host-management role"
23883 .to_string(),
23884 );
23885 }
23886 }
23887 }
23888
23889 let chat = state.chat_sessions.lock().await.remove(&session_id);
23890 let chat = match chat {
23891 Some(c) => c,
23892 None => {
23893 return Ok(serde_json::json!({ "cancelled": false, "reason": "unknown session_id" }));
23895 }
23896 };
23897 if let Some(flag) = &chat.local_cancel {
23898 flag.store(true, Ordering::SeqCst);
23899 }
23900 update_chat_goal_from_event(
23901 state,
23902 &session_id,
23903 &serde_json::json!({
23904 "kind": "error",
23905 "error": "cancelled",
23906 }),
23907 )
23908 .await;
23909
23910 let agent_client_id = state
23913 .attached_agents
23914 .lock()
23915 .await
23916 .get(&chat.agent_id)
23917 .cloned();
23918 if let Some(client_id) = agent_client_id {
23919 let channel_opt = {
23920 let sessions = state.sessions.lock().await;
23921 sessions.get(&client_id).map(|s| s.channel.clone())
23922 };
23923 if let Some(channel) = channel_opt {
23924 let notification = serde_json::json!({
23925 "jsonrpc": "2.0",
23926 "method": "agent.chat.cancel",
23927 "params": { "session_id": session_id },
23928 });
23929 if let Ok(text) = serde_json::to_string(¬ification) {
23930 let _ = channel
23931 .write
23932 .lock()
23933 .await
23934 .send(Message::Text(text.into()))
23935 .await;
23936 }
23937 }
23938 }
23939
23940 Ok(serde_json::json!({ "cancelled": true, "session_id": session_id }))
23941}
23942
23943async fn handle_agents_chat_approve(
23962 req: &JsonRpcMessage,
23963 state: &Arc<ServerState>,
23964 caller_client_id: &str,
23965 caller_is_host: bool,
23966) -> Result<Value, String> {
23967 use futures::SinkExt;
23968 use tokio::sync::oneshot;
23969 use tokio_tungstenite::tungstenite::Message;
23970
23971 let session_id = req
23972 .params
23973 .get("session_id")
23974 .and_then(Value::as_str)
23975 .ok_or_else(|| "`agents.chat.approve` requires `session_id`".to_string())?
23976 .to_string();
23977 let approval_id = req
23978 .params
23979 .get("approval_id")
23980 .and_then(Value::as_str)
23981 .ok_or_else(|| "`agents.chat.approve` requires `approval_id`".to_string())?
23982 .to_string();
23983 let decision = req
23987 .params
23988 .get("decision")
23989 .cloned()
23990 .unwrap_or(Value::Bool(false));
23991
23992 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
23995 let chat = chat.ok_or_else(|| {
23996 format!("`agents.chat.approve`: unknown or already-finished session_id `{session_id}`")
23997 })?;
23998 if !caller_is_host && caller_client_id != chat.host_client_id {
23999 return Err(
24000 "`agents.chat.approve` requires the originating host session or host-management role"
24001 .to_string(),
24002 );
24003 }
24004
24005 let agent_client_id = state
24007 .attached_agents
24008 .lock()
24009 .await
24010 .get(&chat.agent_id)
24011 .cloned()
24012 .ok_or_else(|| {
24013 format!(
24014 "agent `{}` is not attached to this daemon (raced with disconnect)",
24015 chat.agent_id
24016 )
24017 })?;
24018 let agent_channel = {
24019 let sessions = state.sessions.lock().await;
24020 sessions
24021 .get(&agent_client_id)
24022 .map(|s| s.channel.clone())
24023 .ok_or_else(|| {
24024 format!(
24025 "agent `{}` client_id `{}` not found in session registry",
24026 chat.agent_id, agent_client_id
24027 )
24028 })?
24029 };
24030
24031 let request_id = agent_channel.next_request_id();
24034 let (tx, rx) = oneshot::channel();
24035 agent_channel
24036 .pending
24037 .lock()
24038 .await
24039 .insert(request_id.clone(), tx);
24040
24041 let rpc_request = serde_json::json!({
24042 "jsonrpc": "2.0",
24043 "method": "agent.chat.approve",
24044 "params": { "approval_id": approval_id, "decision": decision },
24045 "id": request_id,
24046 });
24047 let msg = Message::Text(
24048 serde_json::to_string(&rpc_request)
24049 .map_err(|e| e.to_string())?
24050 .into(),
24051 );
24052 if let Err(e) = agent_channel.write.lock().await.send(msg).await {
24053 agent_channel.pending.lock().await.remove(&request_id);
24054 return Err(format!(
24055 "failed to deliver agent.chat.approve to `{}`: {}",
24056 chat.agent_id, e
24057 ));
24058 }
24059
24060 let ack = match tokio::time::timeout(
24061 std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24062 rx,
24063 )
24064 .await
24065 {
24066 Ok(Ok(resp)) => resp,
24067 Ok(Err(_)) => {
24068 return Err(format!(
24069 "agent `{}` disconnected before acking agents.chat.approve",
24070 chat.agent_id
24071 ));
24072 }
24073 Err(_) => {
24074 agent_channel.pending.lock().await.remove(&request_id);
24075 return Err(format!(
24076 "agent `{}` did not ack agents.chat.approve within {}s",
24077 chat.agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24078 ));
24079 }
24080 };
24081 if let Some(err) = ack.error {
24082 return Err(format!(
24083 "agent `{}` rejected agents.chat.approve: {}",
24084 chat.agent_id, err
24085 ));
24086 }
24087
24088 let resolved = ack
24091 .output
24092 .as_ref()
24093 .and_then(|r| r.get("resolved"))
24094 .and_then(Value::as_bool)
24095 .unwrap_or(false);
24096 Ok(serde_json::json!({ "resolved": resolved, "session_id": session_id }))
24097}
24098
24099async fn update_chat_goal_from_event(state: &Arc<ServerState>, session_id: &str, params: &Value) {
24100 let Some(kind) = params.get("kind").and_then(Value::as_str) else {
24101 return;
24102 };
24103 let changed = {
24104 let mut goals = state.chat_goals.lock().await;
24105 let Some(goal) = goals.get_mut(session_id) else {
24106 return;
24107 };
24108 match kind {
24109 "goal_evaluated" => {
24110 goal.last_iteration = params
24111 .get("iteration")
24112 .and_then(Value::as_u64)
24113 .and_then(|n| u32::try_from(n).ok());
24114 goal.last_met = params.get("met").and_then(Value::as_bool);
24115 goal.last_grounded = params.get("grounded").and_then(Value::as_bool);
24116 goal.last_reason = params
24117 .get("reason")
24118 .and_then(Value::as_str)
24119 .map(str::to_string);
24120 goal.status = if goal.last_met == Some(true) && goal.last_grounded == Some(true) {
24121 "met".to_string()
24122 } else {
24123 "running".to_string()
24124 };
24125 goal.updated_at = unix_secs_now();
24126 true
24127 }
24128 "done" | "error" => {
24129 goal.terminal_kind = Some(kind.to_string());
24130 goal.terminal_message = params
24131 .get("finish_reason")
24132 .or_else(|| params.get("error"))
24133 .and_then(Value::as_str)
24134 .map(str::to_string);
24135 let unevaluated = params
24145 .get("goal_unevaluated")
24146 .and_then(Value::as_bool)
24147 .unwrap_or(false);
24148 goal.status = if kind == "error" {
24149 "error".to_string()
24150 } else if unevaluated {
24151 "unevaluated".to_string()
24152 } else {
24153 "met".to_string()
24154 };
24155 goal.updated_at = unix_secs_now();
24156 true
24157 }
24158 _ => false,
24159 }
24160 };
24161 if changed {
24162 if let Err(e) = state.persist_chat_goals().await {
24163 tracing::warn!(
24164 session_id,
24165 error = %e,
24166 "failed to persist chat goal status update"
24167 );
24168 }
24169 }
24170}
24171
24172pub(crate) async fn try_forward_agent_chat_event(
24183 parsed: &JsonRpcMessage,
24184 state: &Arc<ServerState>,
24185) -> bool {
24186 use futures::SinkExt;
24187 use tokio_tungstenite::tungstenite::Message;
24188
24189 let Some(method) = parsed.method.as_deref() else {
24193 return false;
24194 };
24195 if method != "agent.chat.event" {
24196 return false;
24197 }
24198 if !parsed.id.is_null() {
24199 return false;
24202 }
24203 let Some(session_id) = parsed.params.get("session_id").and_then(Value::as_str) else {
24204 return false;
24205 };
24206 let session_id = session_id.to_string();
24207
24208 let collector = state
24213 .chat_collectors
24214 .lock()
24215 .await
24216 .get(&session_id)
24217 .map(|c| c.tx.clone());
24218 if let Some(tx) = collector {
24219 let kind = parsed
24220 .params
24221 .get("kind")
24222 .and_then(Value::as_str)
24223 .map(str::to_string)
24224 .unwrap_or_else(|| {
24225 if parsed.params.get("error").is_some() {
24226 "error".to_string()
24227 } else if parsed.params.get("finish_reason").is_some() {
24228 "done".to_string()
24229 } else {
24230 "token".to_string()
24231 }
24232 });
24233 let delta = parsed
24234 .params
24235 .get("delta")
24236 .and_then(Value::as_str)
24237 .map(str::to_string);
24238 let error = parsed
24239 .params
24240 .get("error")
24241 .and_then(Value::as_str)
24242 .map(str::to_string);
24243 let _ = tx.send(crate::session::ChatStreamChunk { kind, delta, error });
24244 return true;
24245 }
24246
24247 let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24252 let Some(chat) = chat else {
24253 return true; };
24255
24256 let kind = parsed
24265 .params
24266 .get("kind")
24267 .and_then(Value::as_str)
24268 .map(str::to_string)
24269 .unwrap_or_else(|| {
24270 if parsed.params.get("error").is_some() {
24271 "error".to_string()
24272 } else if parsed.params.get("finish_reason").is_some() {
24273 "done".to_string()
24274 } else {
24275 "token".to_string()
24276 }
24277 });
24278 update_chat_goal_from_event(
24279 state,
24280 &session_id,
24281 &serde_json::json!({
24282 "kind": kind.clone(),
24283 "finish_reason": parsed.params.get("finish_reason").cloned().unwrap_or(Value::Null),
24284 "error": parsed.params.get("error").cloned().unwrap_or(Value::Null),
24285 "iteration": parsed.params.get("iteration").cloned().unwrap_or(Value::Null),
24286 "met": parsed.params.get("met").cloned().unwrap_or(Value::Null),
24287 "grounded": parsed.params.get("grounded").cloned().unwrap_or(Value::Null),
24288 "reason": parsed.params.get("reason").cloned().unwrap_or(Value::Null),
24289 }),
24290 )
24291 .await;
24292
24293 let host_channel = {
24297 let sessions = state.sessions.lock().await;
24298 sessions
24299 .get(&chat.host_client_id)
24300 .map(|s| s.channel.clone())
24301 };
24302 if let Some(channel) = host_channel {
24303 let mut params = parsed.params.clone();
24304 if let Some(obj) = params.as_object_mut() {
24305 obj.insert("agent_id".to_string(), Value::String(chat.agent_id.clone()));
24306 obj.entry("kind")
24311 .or_insert_with(|| Value::String(kind.clone()));
24312 }
24313 let forward = serde_json::json!({
24314 "jsonrpc": "2.0",
24315 "method": "agents.chat.event",
24316 "params": params,
24317 });
24318 if let Ok(text) = serde_json::to_string(&forward) {
24319 let send_result = channel
24320 .write
24321 .lock()
24322 .await
24323 .send(Message::Text(text.into()))
24324 .await;
24325 if let Err(e) = send_result {
24326 tracing::warn!(
24327 session_id = %session_id,
24328 agent_id = %chat.agent_id,
24329 host_client_id = %chat.host_client_id,
24330 kind = %kind,
24331 error = %e,
24332 "agent.chat.event forward to host failed at the WS send step"
24333 );
24334 }
24335 }
24336 } else {
24337 tracing::warn!(
24344 session_id = %session_id,
24345 agent_id = %chat.agent_id,
24346 host_client_id = %chat.host_client_id,
24347 kind = %kind,
24348 "agent.chat.event from supervised agent had no host channel \
24349 (host disconnected since `agents.chat`); dropping routing entry"
24350 );
24351 state.chat_sessions.lock().await.remove(&session_id);
24352 return true;
24353 }
24354
24355 if matches!(kind.as_str(), "done" | "error") {
24359 state.chat_sessions.lock().await.remove(&session_id);
24360 }
24361
24362 true
24363}
24364
24365#[cfg(test)]
24366mod credential_event_lag_regression {
24367 use super::{credential_fanout_test_gate, credential_handoff_test_gate, run_dispatch};
24368 use futures::{Sink, SinkExt, StreamExt};
24369 use std::pin::Pin;
24370 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
24371 use std::sync::Arc;
24372 use std::task::{Context, Poll};
24373 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24374
24375 const HOST_TOKEN: &str = "lag-test-host-token-cccccccccccccccccccc";
24376
24377 struct StallAfterTwoWrites {
24378 outbound: futures::channel::mpsc::UnboundedSender<Message>,
24379 sends: Arc<AtomicUsize>,
24380 stalled: Arc<AtomicBool>,
24381 }
24382
24383 impl Sink<Message> for StallAfterTwoWrites {
24384 type Error = WsError;
24385
24386 fn poll_ready(
24387 self: Pin<&mut Self>,
24388 _context: &mut Context<'_>,
24389 ) -> Poll<Result<(), Self::Error>> {
24390 if self.sends.load(Ordering::SeqCst) >= 2 {
24391 self.stalled.store(true, Ordering::SeqCst);
24392 Poll::Pending
24393 } else {
24394 Poll::Ready(Ok(()))
24395 }
24396 }
24397
24398 fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
24399 self.outbound
24400 .unbounded_send(message)
24401 .map_err(|_| WsError::ConnectionClosed)?;
24402 self.sends.fetch_add(1, Ordering::SeqCst);
24403 Ok(())
24404 }
24405
24406 fn poll_flush(
24407 self: Pin<&mut Self>,
24408 _context: &mut Context<'_>,
24409 ) -> Poll<Result<(), Self::Error>> {
24410 Poll::Ready(Ok(()))
24411 }
24412
24413 fn poll_close(
24414 self: Pin<&mut Self>,
24415 _context: &mut Context<'_>,
24416 ) -> Poll<Result<(), Self::Error>> {
24417 Poll::Ready(Ok(()))
24418 }
24419 }
24420
24421 fn request(id: &str, method: &str, params: serde_json::Value) -> Message {
24422 Message::Text(
24423 serde_json::json!({
24424 "jsonrpc": "2.0",
24425 "id": id,
24426 "method": method,
24427 "params": params,
24428 })
24429 .to_string()
24430 .into(),
24431 )
24432 }
24433
24434 #[tokio::test]
24435 async fn credential_event_queue_overflow_terminates_host_dispatch() {
24436 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_LAG_CHILD";
24437 if std::env::var_os(SENTINEL).is_none() {
24438 let status = std::process::Command::new(std::env::current_exe().unwrap())
24439 .arg("--exact")
24440 .arg(
24441 "handler::credential_event_lag_regression::credential_event_queue_overflow_terminates_host_dispatch",
24442 )
24443 .arg("--nocapture")
24444 .env(SENTINEL, "1")
24445 .env("CAR_NO_INFERENCE_WORKER", "1")
24446 .status()
24447 .expect("spawn isolated credential-event lag contract");
24448 assert!(
24449 status.success(),
24450 "isolated credential-event lag contract failed"
24451 );
24452 return;
24453 }
24454
24455 let temp = tempfile::TempDir::new().unwrap();
24456 let secrets = tempfile::TempDir::new().unwrap();
24457 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24458 std::env::set_var(car_home::ENV_VAR, temp.path());
24459 car_auth::commit_login(
24460 "https://api.example.test",
24461 &car_auth::TokenSet {
24462 access_token: "lag-test-access".into(),
24463 refresh_token: "lag-test-refresh".into(),
24464 expires_in: 3_600,
24465 token_type: "Bearer".into(),
24466 },
24467 r#"{"Account":{"Id":"lag-account","Email":"lag@example.test"}}"#,
24468 None,
24469 )
24470 .await
24471 .unwrap();
24472
24473 let state = Arc::new(crate::session::ServerState::standalone(
24474 temp.path().join("journal"),
24475 ));
24476 state
24477 .install_host_token(HOST_TOKEN.to_string())
24478 .expect("install host token");
24479
24480 let (inbound_tx, inbound_rx) =
24481 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24482 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24483 let sends = Arc::new(AtomicUsize::new(0));
24484 let credential_write_stalled = Arc::new(AtomicBool::new(false));
24485 let sink = StallAfterTwoWrites {
24486 outbound: outbound_tx,
24487 sends: Arc::clone(&sends),
24488 stalled: Arc::clone(&credential_write_stalled),
24489 };
24490 let write: crate::session::WsSink = Box::pin(sink);
24491 let dispatch_state = Arc::clone(&state);
24492 let mut dispatch = tokio::spawn(async move {
24493 run_dispatch(
24494 inbound_rx,
24495 write,
24496 "credential-lag-test".to_string(),
24497 dispatch_state,
24498 )
24499 .await
24500 .map_err(|error| error.to_string())
24501 });
24502
24503 inbound_tx
24504 .unbounded_send(Ok(request(
24505 "auth",
24506 "session.auth",
24507 serde_json::json!({ "host_token": HOST_TOKEN }),
24508 )))
24509 .unwrap();
24510 let auth_response = outbound_rx.next().await.expect("host auth response");
24511 assert!(auth_response
24512 .into_text()
24513 .unwrap()
24514 .contains("\"role\":\"host\""));
24515
24516 inbound_tx
24517 .unbounded_send(Ok(request(
24518 "handshake",
24519 "server.handshake",
24520 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24521 )))
24522 .unwrap();
24523 let handshake_response = outbound_rx.next().await.expect("handshake response");
24524 assert!(handshake_response.into_text().unwrap().contains(&format!(
24525 "\"protocol_version\":{}",
24526 car_proto::PROTOCOL_VERSION
24527 )));
24528
24529 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24530 .await
24531 .expect("credential generation should resolve");
24532 for _ in 0..100 {
24533 if credential_write_stalled.load(Ordering::SeqCst) {
24534 break;
24535 }
24536 tokio::task::yield_now().await;
24537 }
24538 assert!(
24539 credential_write_stalled.load(Ordering::SeqCst),
24540 "the real host fanout must be stalled on its credential-event write"
24541 );
24542 for _ in 0..3 {
24543 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24544 .await
24545 .expect("credential generation should resolve while host fanout is stalled");
24546 }
24547
24548 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24549 .await
24550 .expect("credential-event overflow must cancel the owning host connection")
24551 .expect("dispatcher task should join");
24552 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24553 assert!(
24554 state.sessions.lock().await.is_empty(),
24555 "a lagged host cannot remain connected but silently unsubscribed"
24556 );
24557 }
24558
24559 #[tokio::test]
24560 async fn preauth_activity_cannot_disconnect_a_later_eligible_host() {
24561 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_PREAUTH_CHILD";
24562 if std::env::var_os(SENTINEL).is_none() {
24563 let status = std::process::Command::new(std::env::current_exe().unwrap())
24564 .arg("--exact")
24565 .arg(
24566 "handler::credential_event_lag_regression::preauth_activity_cannot_disconnect_a_later_eligible_host",
24567 )
24568 .arg("--nocapture")
24569 .env(SENTINEL, "1")
24570 .env("CAR_NO_INFERENCE_WORKER", "1")
24571 .env("CAR_TEST_PAUSE_CREDENTIAL_FANOUT", "1")
24572 .status()
24573 .expect("spawn isolated pre-auth credential-event contract");
24574 assert!(status.success(), "isolated pre-auth contract failed");
24575 return;
24576 }
24577
24578 let temp = tempfile::TempDir::new().unwrap();
24579 let secrets = tempfile::TempDir::new().unwrap();
24580 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24581 std::env::set_var(car_home::ENV_VAR, temp.path());
24582 car_auth::commit_login(
24583 "https://api.example.test",
24584 &car_auth::TokenSet {
24585 access_token: "preauth-test-access".into(),
24586 refresh_token: "preauth-test-refresh".into(),
24587 expires_in: 3_600,
24588 token_type: "Bearer".into(),
24589 },
24590 r#"{"Account":{"Id":"preauth-account","Email":"preauth@example.test"}}"#,
24591 None,
24592 )
24593 .await
24594 .unwrap();
24595
24596 let state = Arc::new(crate::session::ServerState::standalone(
24597 temp.path().join("journal"),
24598 ));
24599 state
24600 .install_host_token(HOST_TOKEN.to_string())
24601 .expect("install host token");
24602
24603 let (inbound_tx, inbound_rx) =
24604 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24605 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24606 let write: crate::session::WsSink = Box::pin(
24607 outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
24608 );
24609 let dispatch_state = Arc::clone(&state);
24610 let mut dispatch = tokio::spawn(async move {
24611 run_dispatch(
24612 inbound_rx,
24613 write,
24614 "credential-preauth-test".to_string(),
24615 dispatch_state,
24616 )
24617 .await
24618 .map_err(|error| error.to_string())
24619 });
24620
24621 credential_fanout_test_gate().ready.notified().await;
24622 for _ in 0..3 {
24623 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24624 .await
24625 .expect("pre-auth credential generation should resolve");
24626 }
24627
24628 inbound_tx
24629 .unbounded_send(Ok(request(
24630 "auth",
24631 "session.auth",
24632 serde_json::json!({ "host_token": HOST_TOKEN }),
24633 )))
24634 .unwrap();
24635 let auth_response = outbound_rx.next().await.expect("host auth response");
24636 assert!(auth_response
24637 .into_text()
24638 .unwrap()
24639 .contains("\"role\":\"host\""));
24640
24641 inbound_tx
24642 .unbounded_send(Ok(request(
24643 "handshake",
24644 "server.handshake",
24645 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24646 )))
24647 .unwrap();
24648 let mut negotiated = false;
24649 for _ in 0..100 {
24650 negotiated = state.sessions.lock().await.values().any(|session| {
24651 session.negotiated_protocol_version.load(Ordering::Acquire)
24652 == car_proto::PROTOCOL_VERSION
24653 });
24654 if negotiated {
24655 break;
24656 }
24657 tokio::task::yield_now().await;
24658 }
24659 assert!(
24660 negotiated,
24661 "host protocol negotiation must complete while fanout is paused"
24662 );
24663
24664 credential_fanout_test_gate().release.notify_one();
24665 let mut saw_handshake = false;
24666 let mut saw_reconciled = false;
24667 for _ in 0..2 {
24668 let frame =
24669 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24670 .await
24671 .expect("eligible host should receive handshake and reconciled snapshot")
24672 .expect("eligible host connection must remain open")
24673 .into_text()
24674 .unwrap();
24675 saw_handshake |= frame.contains(&format!(
24676 "\"protocol_version\":{}",
24677 car_proto::PROTOCOL_VERSION
24678 ));
24679 saw_reconciled |= frame.contains("\"method\":\"auth.credential.event\"")
24680 && frame.contains("\"state\":\"configured\"");
24681 }
24682 assert!(saw_handshake, "host must receive handshake success");
24683 assert!(
24684 saw_reconciled,
24685 "host must receive the retained credential snapshot"
24686 );
24687
24688 inbound_tx
24689 .unbounded_send(Ok(request(
24690 "still-connected",
24691 "auth.authority_hint",
24692 serde_json::json!({}),
24693 )))
24694 .unwrap();
24695 let response =
24696 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24697 .await
24698 .expect("host should remain responsive after pre-auth activity")
24699 .expect("host connection should remain open")
24700 .into_text()
24701 .unwrap();
24702 assert!(response.contains("\"id\":\"still-connected\""));
24703 assert!(!dispatch.is_finished());
24704
24705 inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
24706 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24707 .await
24708 .expect("dispatcher should stop after close")
24709 .expect("dispatcher task should join");
24710 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24711 }
24712
24713 #[tokio::test]
24714 async fn handoff_cannot_send_terminal_before_queued_pending() {
24715 const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_HANDOFF_CHILD";
24716 if std::env::var_os(SENTINEL).is_none() {
24717 let status = std::process::Command::new(std::env::current_exe().unwrap())
24718 .arg("--exact")
24719 .arg(
24720 "handler::credential_event_lag_regression::handoff_cannot_send_terminal_before_queued_pending",
24721 )
24722 .arg("--nocapture")
24723 .env(SENTINEL, "1")
24724 .env("CAR_NO_INFERENCE_WORKER", "1")
24725 .env("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF", "1")
24726 .status()
24727 .expect("spawn isolated credential handoff contract");
24728 assert!(status.success(), "isolated credential handoff failed");
24729 return;
24730 }
24731
24732 let temp = tempfile::TempDir::new().unwrap();
24733 let secrets = tempfile::TempDir::new().unwrap();
24734 std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24735 std::env::set_var(car_home::ENV_VAR, temp.path());
24736 car_auth::commit_login(
24737 "https://api.example.test",
24738 &car_auth::TokenSet {
24739 access_token: "handoff-test-access".into(),
24740 refresh_token: "handoff-test-refresh".into(),
24741 expires_in: 3_600,
24742 token_type: "Bearer".into(),
24743 },
24744 r#"{"Account":{"Id":"handoff-account","Email":"handoff@example.test"}}"#,
24745 None,
24746 )
24747 .await
24748 .unwrap();
24749
24750 let state = Arc::new(crate::session::ServerState::standalone(
24751 temp.path().join("journal"),
24752 ));
24753 state
24754 .install_host_token(HOST_TOKEN.to_string())
24755 .expect("install host token");
24756
24757 let (inbound_tx, inbound_rx) =
24758 futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24759 let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24760 let write: crate::session::WsSink = Box::pin(
24761 outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
24762 );
24763 let dispatch_state = Arc::clone(&state);
24764 let mut dispatch = tokio::spawn(async move {
24765 run_dispatch(
24766 inbound_rx,
24767 write,
24768 "credential-handoff-test".to_string(),
24769 dispatch_state,
24770 )
24771 .await
24772 .map_err(|error| error.to_string())
24773 });
24774
24775 inbound_tx
24776 .unbounded_send(Ok(request(
24777 "auth",
24778 "session.auth",
24779 serde_json::json!({ "host_token": HOST_TOKEN }),
24780 )))
24781 .unwrap();
24782 let auth_response = outbound_rx.next().await.expect("host auth response");
24783 assert!(auth_response
24784 .into_text()
24785 .unwrap()
24786 .contains("\"role\":\"host\""));
24787
24788 inbound_tx
24789 .unbounded_send(Ok(request(
24790 "handshake",
24791 "server.handshake",
24792 serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24793 )))
24794 .unwrap();
24795 credential_handoff_test_gate().ready.notified().await;
24796
24797 car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24798 .await
24799 .expect("interleaved credential generation should resolve");
24800 credential_handoff_test_gate().release.notify_one();
24801
24802 let mut saw_handshake = false;
24803 let mut states = Vec::new();
24804 while !saw_handshake || states.len() < 2 {
24805 let frame =
24806 tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24807 .await
24808 .expect("handoff must deliver handshake and exact credential lifecycle")
24809 .expect("eligible host connection must remain open")
24810 .into_text()
24811 .unwrap();
24812 let value: serde_json::Value =
24813 serde_json::from_str(&frame).expect("credential handoff frame JSON");
24814 saw_handshake |= value["id"] == "handshake";
24815 if value["method"] == "auth.credential.event" {
24816 states.push(value["params"]["state"].as_str().unwrap().to_string());
24817 }
24818 }
24819 assert_eq!(states, ["pending", "configured"]);
24820
24821 inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
24822 let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24823 .await
24824 .expect("dispatcher should stop after close")
24825 .expect("dispatcher task should join");
24826 assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24827 }
24828}
24829
24830#[cfg(test)]
24831mod fd_leak_regression {
24832 use super::run_dispatch;
24839 use futures::SinkExt;
24840 use std::sync::Arc;
24841 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24842
24843 #[tokio::test]
24844 async fn abrupt_read_error_still_runs_session_cleanup() {
24845 let tmp = tempfile::TempDir::new().unwrap();
24846 let state = Arc::new(crate::session::ServerState::standalone(
24847 tmp.path().to_path_buf(),
24848 ));
24849
24850 let read = futures::stream::iter(vec![Err::<Message, WsError>(WsError::ConnectionClosed)]);
24854 let write: crate::session::WsSink =
24855 Box::pin(futures::sink::drain().sink_map_err(|_| WsError::ConnectionClosed));
24856
24857 let result = run_dispatch(read, write, "test-peer".to_string(), state.clone()).await;
24858 assert!(
24859 result.is_ok(),
24860 "run_dispatch must return Ok after cleanup, got {result:?}"
24861 );
24862
24863 assert!(
24866 state.sessions.lock().await.is_empty(),
24867 "state.sessions must be empty after an abrupt disconnect (car#209)"
24868 );
24869 }
24870}
24871
24872#[cfg(test)]
24873mod a2ui_action_delivery {
24874 use super::{handle_a2ui_action, JsonRpcMessage};
24879 use crate::session::{ServerState, WsChannel, WsSink};
24880 use futures::{SinkExt, StreamExt};
24881 use std::collections::HashMap;
24882 use std::sync::atomic::AtomicU64;
24883 use std::sync::Arc;
24884 use tokio::sync::Mutex;
24885 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24886
24887 #[tokio::test]
24888 async fn client_action_broadcasts_to_a2ui_subscribers() {
24889 let tmp = tempfile::TempDir::new().unwrap();
24890 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
24891
24892 let (tx, mut rx) = futures::channel::mpsc::unbounded::<Message>();
24895 let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
24896 let channel = Arc::new(WsChannel {
24897 write: Mutex::new(sink),
24898 pending: Mutex::new(HashMap::new()),
24899 active_actions: Mutex::new(HashMap::new()),
24900 next_id: AtomicU64::new(0),
24901 });
24902 state
24903 .a2ui_subscribers
24904 .lock()
24905 .await
24906 .insert("test-sub".to_string(), channel);
24907
24908 let req: JsonRpcMessage = serde_json::from_value(serde_json::json!({
24912 "jsonrpc": "2.0",
24913 "method": "a2ui.action",
24914 "id": 1,
24915 "params": {
24916 "action": "trader:pause",
24917 "surfaceId": "surf-1",
24918 "sourceComponentId": "b1",
24919 "timestamp": "2026-06-03T00:00:00Z"
24920 }
24921 }))
24922 .unwrap();
24923
24924 let out = handle_a2ui_action(&req, &state).await;
24925 assert!(out.is_ok(), "handle_a2ui_action failed: {out:?}");
24926
24927 let msg = rx.next().await.expect("subscriber received no frame");
24928 let text = match msg {
24929 Message::Text(t) => t.to_string(),
24930 other => panic!("expected text frame, got {other:?}"),
24931 };
24932 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
24933 assert_eq!(v["method"], "a2ui.event");
24934 assert_eq!(v["params"]["kind"], "a2ui.action");
24935 assert_eq!(
24937 v["params"]["result"]["action"]["name"], "trader:pause",
24938 "ClientAction.name should accept the `action` alias"
24939 );
24940 assert_eq!(v["params"]["result"]["surfaceId"], "surf-1");
24941 }
24942}
24943
24944#[cfg(test)]
24945mod a2a_chat_collector {
24946 use super::{try_forward_agent_chat_event, JsonRpcMessage};
24950 use crate::session::{ChatCollector, ServerState};
24951 use std::sync::Arc;
24952 use tokio::sync::mpsc;
24953
24954 fn event(session_id: &str, extra: serde_json::Value) -> JsonRpcMessage {
24955 let mut params = serde_json::Map::new();
24956 params.insert("session_id".into(), session_id.into());
24957 if let Some(obj) = extra.as_object() {
24958 for (k, v) in obj {
24959 params.insert(k.clone(), v.clone());
24960 }
24961 }
24962 serde_json::from_value(serde_json::json!({
24963 "jsonrpc": "2.0",
24964 "method": "agent.chat.event",
24965 "params": params,
24966 }))
24967 .unwrap()
24968 }
24969
24970 #[tokio::test]
24971 async fn collector_receives_normalized_chunks() {
24972 let tmp = tempfile::TempDir::new().unwrap();
24973 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
24974 let (tx, mut rx) = mpsc::unbounded_channel();
24975 state.chat_collectors.lock().await.insert(
24976 "a2a-chat-xyz".into(),
24977 ChatCollector {
24978 tx,
24979 host_client_id: "host-1".into(),
24980 },
24981 );
24982
24983 assert!(
24985 try_forward_agent_chat_event(
24986 &event(
24987 "a2a-chat-xyz",
24988 serde_json::json!({ "kind": "token", "delta": "Hello " })
24989 ),
24990 &state,
24991 )
24992 .await
24993 );
24994 try_forward_agent_chat_event(
24996 &event("a2a-chat-xyz", serde_json::json!({ "delta": "world" })),
24997 &state,
24998 )
24999 .await;
25000 try_forward_agent_chat_event(
25002 &event(
25003 "a2a-chat-xyz",
25004 serde_json::json!({ "finish_reason": "1 turn" }),
25005 ),
25006 &state,
25007 )
25008 .await;
25009
25010 let c1 = rx.recv().await.unwrap();
25011 assert_eq!(c1.kind, "token");
25012 assert_eq!(c1.delta.as_deref(), Some("Hello "));
25013 let c2 = rx.recv().await.unwrap();
25014 assert_eq!(c2.delta.as_deref(), Some("world"));
25015 let c3 = rx.recv().await.unwrap();
25016 assert_eq!(c3.kind, "done");
25017
25018 assert!(state
25020 .chat_collectors
25021 .lock()
25022 .await
25023 .contains_key("a2a-chat-xyz"));
25024 }
25025
25026 #[tokio::test]
25027 async fn error_event_carries_error_text() {
25028 let tmp = tempfile::TempDir::new().unwrap();
25029 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25030 let (tx, mut rx) = mpsc::unbounded_channel();
25031 state.chat_collectors.lock().await.insert(
25032 "a2a-chat-err".into(),
25033 ChatCollector {
25034 tx,
25035 host_client_id: "h".into(),
25036 },
25037 );
25038 try_forward_agent_chat_event(
25039 &event(
25040 "a2a-chat-err",
25041 serde_json::json!({ "error": "model exploded" }),
25042 ),
25043 &state,
25044 )
25045 .await;
25046 let c = rx.recv().await.unwrap();
25047 assert_eq!(c.kind, "error"); assert_eq!(c.error.as_deref(), Some("model exploded"));
25049 }
25050}
25051
25052#[cfg(test)]
25053mod scheduler_stateful_dedup {
25054 use super::run_scheduler_task_once;
25062 use car_multi::{AgentOutput, AgentRunner, AgentSpec, Mailbox, MultiError};
25063 use car_scheduler::{Task, TaskStore, TaskTrigger};
25064 use std::sync::atomic::{AtomicU32, Ordering};
25065 use std::sync::Arc;
25066
25067 struct CountingRunner {
25069 runs: Arc<AtomicU32>,
25070 }
25071
25072 #[async_trait::async_trait]
25073 impl AgentRunner for CountingRunner {
25074 async fn run(
25075 &self,
25076 spec: &AgentSpec,
25077 _task: &str,
25078 _runtime: &car_engine::Runtime,
25079 _mailbox: &Mailbox,
25080 ) -> Result<AgentOutput, MultiError> {
25081 self.runs.fetch_add(1, Ordering::SeqCst);
25082 Ok(AgentOutput {
25083 name: spec.name.clone(),
25084 answer: "done".to_string(),
25085 turns: 1,
25086 tool_calls: 0,
25087 duration_ms: 1.0,
25088 error: None,
25089 outcome: None,
25090 tokens: None,
25091 tools_used: Vec::new(),
25092 })
25093 }
25094 }
25095
25096 #[tokio::test]
25097 async fn second_run_dedups_across_the_jsonrpc_boundary() {
25098 let dir = tempfile::TempDir::new().unwrap();
25099 let store = TaskStore::new(dir.path());
25100 let runs = Arc::new(AtomicU32::new(0));
25101
25102 let task =
25106 Task::new("dedup_task", "do the thing").with_trigger(TaskTrigger::Interval, "1h");
25107 let task_json = serde_json::to_value(&task).unwrap();
25108
25109 let mut t1: Task = serde_json::from_value(task_json.clone()).unwrap();
25111 assert!(t1.executions.is_empty());
25112 let runner1: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25113 run_scheduler_task_once(&mut t1, runner1, &store).await;
25114
25115 let mut t2: Task = serde_json::from_value(task_json).unwrap();
25118 assert!(t2.executions.is_empty());
25119 let runner2: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25120 run_scheduler_task_once(&mut t2, runner2, &store).await;
25121
25122 assert_eq!(
25123 runs.load(Ordering::SeqCst),
25124 1,
25125 "the runner must be invoked exactly once across two stateless calls"
25126 );
25127
25128 let persisted = store.load(&task.id).expect("task should be persisted");
25130 assert_eq!(persisted.run_count, 1);
25131 assert_eq!(persisted.executions.len(), 1);
25132 }
25133}
25134
25135#[cfg(test)]
25136mod a2ui_apply_delivery {
25137 use super::apply_a2ui_envelope;
25143 use crate::session::{ServerState, WsChannel, WsSink};
25144 use futures::{SinkExt, StreamExt};
25145 use std::collections::HashMap;
25146 use std::sync::atomic::AtomicU64;
25147 use std::sync::Arc;
25148 use tokio::sync::Mutex;
25149 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25150
25151 fn fake_subscriber() -> (
25152 Arc<WsChannel>,
25153 futures::channel::mpsc::UnboundedReceiver<Message>,
25154 ) {
25155 let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
25156 let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25157 let channel = Arc::new(WsChannel {
25158 write: Mutex::new(sink),
25159 pending: Mutex::new(HashMap::new()),
25160 active_actions: Mutex::new(HashMap::new()),
25161 next_id: AtomicU64::new(0),
25162 });
25163 (channel, rx)
25164 }
25165
25166 fn envelope(value: serde_json::Value) -> car_a2ui::A2uiEnvelope {
25167 serde_json::from_value(value).expect("valid envelope")
25168 }
25169
25170 #[tokio::test]
25171 async fn apply_create_broadcasts_surface_updated() {
25172 let tmp = tempfile::TempDir::new().unwrap();
25173 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25174 let (channel, mut rx) = fake_subscriber();
25175 state
25176 .a2ui_subscribers
25177 .lock()
25178 .await
25179 .insert("test-sub".to_string(), channel);
25180
25181 apply_a2ui_envelope(
25182 &state,
25183 envelope(serde_json::json!({
25184 "version": "v0.9",
25185 "createSurface": { "surfaceId": "surf-a" }
25186 })),
25187 None,
25188 None,
25189 )
25190 .await
25191 .expect("apply must succeed");
25192
25193 let msg = rx.next().await.expect("subscriber received no frame");
25194 let text = match msg {
25195 Message::Text(t) => t.to_string(),
25196 other => panic!("expected text frame, got {other:?}"),
25197 };
25198 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25199 assert_eq!(v["method"], "a2ui.event");
25200 assert_eq!(v["params"]["kind"], "a2ui.surface_updated");
25201 assert_eq!(v["params"]["result"]["surfaceId"], "surf-a");
25202 }
25203
25204 #[tokio::test]
25205 async fn apply_delete_broadcasts_surface_deleted() {
25206 let tmp = tempfile::TempDir::new().unwrap();
25207 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25208 let (channel, mut rx) = fake_subscriber();
25209 state
25210 .a2ui_subscribers
25211 .lock()
25212 .await
25213 .insert("test-sub".to_string(), channel);
25214
25215 apply_a2ui_envelope(
25217 &state,
25218 envelope(serde_json::json!({
25219 "version": "v0.9",
25220 "createSurface": { "surfaceId": "surf-d" }
25221 })),
25222 None,
25223 None,
25224 )
25225 .await
25226 .expect("create must succeed");
25227 let _ = rx.next().await.expect("surface_updated frame");
25228
25229 apply_a2ui_envelope(
25230 &state,
25231 envelope(serde_json::json!({
25232 "version": "v0.9",
25233 "deleteSurface": { "surfaceId": "surf-d" }
25234 })),
25235 None,
25236 None,
25237 )
25238 .await
25239 .expect("delete must succeed");
25240
25241 let msg = rx
25242 .next()
25243 .await
25244 .expect("subscriber received no delete frame");
25245 let text = match msg {
25246 Message::Text(t) => t.to_string(),
25247 other => panic!("expected text frame, got {other:?}"),
25248 };
25249 let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25250 assert_eq!(v["method"], "a2ui.event");
25251 assert_eq!(v["params"]["kind"], "a2ui.surface_deleted");
25252 assert_eq!(v["params"]["result"]["surfaceId"], "surf-d");
25253 }
25254}
25255
25256#[cfg(test)]
25257mod agents_chat_attachments {
25258 use super::{
25263 build_agent_chat_params, ensure_chat_goal_running, extract_chat_attachments,
25264 extract_chat_goal, extract_chat_model, handle_agents_chat_cancel, handle_goal_clear,
25265 handle_goal_set, handle_goal_status, handle_goal_suggest, send_external_chat_frame,
25266 update_chat_goal_from_event, JsonRpcMessage,
25267 };
25268 use crate::session::{ChatSession, ServerState};
25269 use serde_json::{json, Value};
25270 use std::sync::atomic::AtomicBool;
25271 use std::sync::Arc;
25272
25273 fn req(params: Value) -> JsonRpcMessage {
25274 JsonRpcMessage {
25275 jsonrpc: "2.0".into(),
25276 method: None,
25277 params,
25278 id: json!(1),
25279 result: None,
25280 error: None,
25281 }
25282 }
25283
25284 #[test]
25285 fn extract_returns_none_when_absent_or_null() {
25286 assert_eq!(extract_chat_attachments(&json!({"prompt": "hi"})), Ok(None));
25287 assert_eq!(
25288 extract_chat_attachments(&json!({"attachments": null})),
25289 Ok(None)
25290 );
25291 }
25292
25293 #[test]
25294 fn extract_accepts_image_content_blocks() {
25295 let params = json!({
25296 "attachments": [
25297 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
25298 {"type": "image_url", "url": "https://example.com/a.jpg", "detail": "auto"}
25299 ]
25300 });
25301 let got = extract_chat_attachments(¶ms).unwrap().unwrap();
25302 assert_eq!(got.as_array().map(|a| a.len()), Some(2));
25303 }
25304
25305 #[test]
25306 fn extract_rejects_non_array() {
25307 assert!(extract_chat_attachments(&json!({"attachments": "nope"})).is_err());
25308 }
25309
25310 #[test]
25311 fn extract_rejects_non_image_entry() {
25312 let params = json!({
25313 "attachments": [
25314 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
25315 {"type": "text", "text": "not an image"}
25316 ]
25317 });
25318 assert!(extract_chat_attachments(¶ms).is_err());
25319 }
25320
25321 #[test]
25322 fn extract_rejects_base64_missing_data() {
25323 let params = json!({"attachments": [{"type": "image_base64", "media_type": "image/png"}]});
25324 assert!(extract_chat_attachments(¶ms).is_err());
25325 }
25326
25327 #[test]
25328 fn extract_rejects_disallowed_media_type() {
25329 let params = json!({
25330 "attachments": [{"type": "image_base64", "data": "AAAA", "media_type": "image/svg+xml"}]
25331 });
25332 assert!(extract_chat_attachments(¶ms).is_err());
25333 }
25334
25335 #[test]
25336 fn extract_rejects_non_http_image_url() {
25337 let params = json!({"attachments": [{"type": "image_url", "url": "file:///etc/passwd"}]});
25338 assert!(extract_chat_attachments(¶ms).is_err());
25339 }
25340
25341 #[test]
25342 fn build_params_omits_attachments_when_none() {
25343 let params =
25344 build_agent_chat_params("sess-1", "hello", true, "host-1", false, None, None, None);
25345 assert_eq!(params["session_id"], "sess-1");
25346 assert_eq!(params["prompt"], "hello");
25347 assert_eq!(params["context"]["host_client_id"], "host-1");
25348 assert!(
25349 params.get("attachments").is_none(),
25350 "no `attachments` key when there are none — agents that ignore it see the legacy shape"
25351 );
25352 }
25353
25354 #[test]
25355 fn build_params_carries_attachments_when_present() {
25356 let blocks = json!([
25357 {"type": "image_base64", "data": "AAAA", "media_type": "image/png"}
25358 ]);
25359 let params = build_agent_chat_params(
25360 "sess-2",
25361 "describe",
25362 true,
25363 "host-2",
25364 false,
25365 None,
25366 Some(blocks),
25367 None,
25368 );
25369 let att = params["attachments"]
25370 .as_array()
25371 .expect("attachments present");
25372 assert_eq!(att.len(), 1);
25373 assert_eq!(att[0]["type"], "image_base64");
25374 assert_eq!(att[0]["media_type"], "image/png");
25375 }
25376
25377 #[test]
25378 fn extract_goal_returns_none_when_absent_or_null() {
25379 assert_eq!(extract_chat_goal(&json!({"prompt": "hi"})), Ok(None));
25380 assert_eq!(extract_chat_goal(&json!({"goal": null})), Ok(None));
25381 }
25382
25383 #[test]
25384 fn extract_goal_normalizes_check_and_default_iterations() {
25385 let got = extract_chat_goal(&json!({"goal": {"check": " cargo test -q "}}))
25386 .unwrap()
25387 .unwrap();
25388 assert_eq!(got["check"], "cargo test -q");
25389 assert_eq!(got["max_iterations"], 8);
25390 }
25391
25392 #[test]
25393 fn extract_goal_clamps_iteration_budget() {
25394 let low = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 0}}))
25395 .unwrap()
25396 .unwrap();
25397 let high = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 500}}))
25398 .unwrap()
25399 .unwrap();
25400 assert_eq!(low["max_iterations"], 1);
25401 assert_eq!(high["max_iterations"], 50);
25402 }
25403
25404 #[test]
25405 fn extract_goal_rejects_bad_shape() {
25406 assert!(extract_chat_goal(&json!({"goal": "nope"})).is_err());
25407 assert!(extract_chat_goal(&json!({"goal": {"check": ""}})).is_err());
25408 }
25409
25410 #[test]
25411 fn selected_chat_model_is_forwarded_and_unset_preserves_agent_default() {
25412 assert_eq!(
25413 extract_chat_model(&json!({
25414 "model": " openrouter/deepseek/deepseek-v3.2 "
25415 }))
25416 .unwrap()
25417 .as_deref(),
25418 Some("openrouter/deepseek/deepseek-v3.2")
25419 );
25420 assert_eq!(extract_chat_model(&json!({})), Ok(None));
25421 assert_eq!(extract_chat_model(&json!({"model": " "})), Ok(None));
25422 assert!(extract_chat_model(&json!({"model": 42})).is_err());
25423
25424 let selected = build_agent_chat_params(
25425 "sess-model",
25426 "hello",
25427 true,
25428 "host-model",
25429 false,
25430 Some("openrouter/deepseek/deepseek-v3.2".into()),
25431 None,
25432 None,
25433 );
25434 assert_eq!(
25435 selected["model"], "openrouter/deepseek/deepseek-v3.2",
25436 "the actual daemon-to-agent chat request must carry the selected CAR model"
25437 );
25438
25439 let adaptive = build_agent_chat_params(
25440 "sess-adaptive",
25441 "hello",
25442 true,
25443 "host-adaptive",
25444 false,
25445 None,
25446 None,
25447 None,
25448 );
25449 assert!(
25450 adaptive.get("model").is_none(),
25451 "omission preserves the supervised agent or adaptive-router default"
25452 );
25453 }
25454
25455 #[test]
25456 fn build_params_carries_goal_when_present() {
25457 let goal = json!({"check": "cargo test -q", "max_iterations": 4});
25458 let params = build_agent_chat_params(
25459 "sess-3",
25460 "fix tests",
25461 true,
25462 "host-3",
25463 false,
25464 None,
25465 None,
25466 Some(goal),
25467 );
25468 assert_eq!(params["goal"]["check"], "cargo test -q");
25469 assert_eq!(params["goal"]["max_iterations"], 4);
25470 assert!(params.get("attachments").is_none());
25471 }
25472
25473 #[tokio::test]
25474 async fn goal_set_status_and_clear_roundtrip() {
25475 let tmp = tempfile::TempDir::new().unwrap();
25476 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25477
25478 let set = handle_goal_set(
25479 &req(json!({
25480 "session_id": "chat-1",
25481 "goal": {"check": " test -f done ", "max_iterations": 500}
25482 })),
25483 &state,
25484 )
25485 .await
25486 .unwrap();
25487 assert_eq!(set["set"], true);
25488 assert_eq!(set["goal"]["check"], "test -f done");
25489 assert_eq!(set["goal"]["max_iterations"], 50);
25490 assert_eq!(set["goal"]["status"], "active");
25491
25492 let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
25493 .await
25494 .unwrap();
25495 assert_eq!(status["goal"]["session_id"], "chat-1");
25496 assert_eq!(status["goal"]["check"], "test -f done");
25497
25498 let clear = handle_goal_clear(&req(json!({"session_id": "chat-1"})), &state)
25499 .await
25500 .unwrap();
25501 assert_eq!(clear["cleared"], true);
25502 let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
25503 .await
25504 .unwrap();
25505 assert!(status["goal"].is_null());
25506 }
25507
25508 #[tokio::test]
25509 async fn goal_status_tracks_evaluator_and_terminal_events() {
25510 let tmp = tempfile::TempDir::new().unwrap();
25511 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25512 handle_goal_set(
25513 &req(json!({"session_id": "chat-2", "check": "test -f done", "max_iterations": 4})),
25514 &state,
25515 )
25516 .await
25517 .unwrap();
25518
25519 update_chat_goal_from_event(
25520 &state,
25521 "chat-2",
25522 &json!({
25523 "kind": "goal_evaluated",
25524 "iteration": 1,
25525 "met": false,
25526 "grounded": false,
25527 "reason": "file missing"
25528 }),
25529 )
25530 .await;
25531 let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
25532 .await
25533 .unwrap();
25534 assert_eq!(status["goal"]["status"], "running");
25535 assert_eq!(status["goal"]["last_iteration"], 1);
25536 assert_eq!(status["goal"]["last_reason"], "file missing");
25537
25538 update_chat_goal_from_event(
25539 &state,
25540 "chat-2",
25541 &json!({
25542 "kind": "goal_evaluated",
25543 "iteration": 2,
25544 "met": true,
25545 "grounded": true,
25546 "reason": "file exists"
25547 }),
25548 )
25549 .await;
25550 update_chat_goal_from_event(
25551 &state,
25552 "chat-2",
25553 &json!({"kind": "done", "finish_reason": "goal reached"}),
25554 )
25555 .await;
25556 let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
25557 .await
25558 .unwrap();
25559 assert_eq!(status["goal"]["status"], "met");
25560 assert_eq!(status["goal"]["last_met"], true);
25561 assert_eq!(status["goal"]["terminal_kind"], "done");
25562 assert_eq!(status["goal"]["terminal_message"], "goal reached");
25563 }
25564
25565 #[tokio::test]
25573 async fn a_fail_open_done_is_recorded_as_unevaluated_not_met() {
25574 let tmp = tempfile::TempDir::new().unwrap();
25575 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25576 handle_goal_set(
25577 &req(json!({"session_id": "chat-timeout", "check": "test -f done"})),
25578 &state,
25579 )
25580 .await
25581 .unwrap();
25582
25583 update_chat_goal_from_event(
25588 &state,
25589 "chat-timeout",
25590 &json!({
25591 "kind": "done",
25592 "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
25593 "goal_unevaluated": true,
25594 }),
25595 )
25596 .await;
25597
25598 let status = handle_goal_status(&req(json!({"session_id": "chat-timeout"})), &state)
25599 .await
25600 .unwrap();
25601 assert_eq!(
25602 status["goal"]["status"], "unevaluated",
25603 "a goal check that never ran must not be recorded as met: {status}"
25604 );
25605 assert_eq!(status["goal"]["terminal_kind"], "done");
25606 assert_eq!(
25607 status["goal"]["terminal_message"],
25608 "goal check unevaluated (timed out) — reply delivered unverified"
25609 );
25610
25611 handle_goal_set(
25614 &req(json!({"session_id": "chat-achieved", "check": "test -f done"})),
25615 &state,
25616 )
25617 .await
25618 .unwrap();
25619 update_chat_goal_from_event(
25620 &state,
25621 "chat-achieved",
25622 &json!({"kind": "done", "finish_reason": "goal reached"}),
25623 )
25624 .await;
25625 let status = handle_goal_status(&req(json!({"session_id": "chat-achieved"})), &state)
25626 .await
25627 .unwrap();
25628 assert_eq!(status["goal"]["status"], "met");
25629 }
25630
25631 #[tokio::test]
25632 async fn inline_goal_creates_durable_running_status() {
25633 let tmp = tempfile::TempDir::new().unwrap();
25634 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25635
25636 ensure_chat_goal_running(
25637 &state,
25638 "chat-inline",
25639 &json!({"check": "test -f done", "max_iterations": 99}),
25640 )
25641 .await
25642 .unwrap();
25643
25644 let status = handle_goal_status(&req(json!({"session_id": "chat-inline"})), &state)
25645 .await
25646 .unwrap();
25647 assert_eq!(status["goal"]["session_id"], "chat-inline");
25648 assert_eq!(status["goal"]["check"], "test -f done");
25649 assert_eq!(status["goal"]["max_iterations"], 50);
25650 assert_eq!(status["goal"]["status"], "running");
25651 }
25652
25653 #[tokio::test]
25654 async fn rerun_goal_clears_prior_terminal_status() {
25655 let tmp = tempfile::TempDir::new().unwrap();
25656 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25657 handle_goal_set(
25658 &req(json!({"session_id": "chat-rerun", "check": "test -f done"})),
25659 &state,
25660 )
25661 .await
25662 .unwrap();
25663 update_chat_goal_from_event(
25664 &state,
25665 "chat-rerun",
25666 &json!({"kind": "error", "error": "cancelled"}),
25667 )
25668 .await;
25669
25670 ensure_chat_goal_running(
25671 &state,
25672 "chat-rerun",
25673 &json!({"check": "test -f done", "max_iterations": 4}),
25674 )
25675 .await
25676 .unwrap();
25677
25678 let status = handle_goal_status(&req(json!({"session_id": "chat-rerun"})), &state)
25679 .await
25680 .unwrap();
25681 assert_eq!(status["goal"]["status"], "running");
25682 assert!(status["goal"]["terminal_kind"].is_null());
25683 assert!(status["goal"]["terminal_message"].is_null());
25684 }
25685
25686 #[tokio::test]
25687 async fn daemon_owned_chat_frames_update_goal_status_before_host_delivery() {
25688 let tmp = tempfile::TempDir::new().unwrap();
25689 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25690 handle_goal_set(
25691 &req(json!({"session_id": "chat-local", "check": "test -f done"})),
25692 &state,
25693 )
25694 .await
25695 .unwrap();
25696
25697 send_external_chat_frame(
25701 &state,
25702 "chat-local",
25703 json!({
25704 "session_id": "chat-local",
25705 "agent_id": "local",
25706 "kind": "goal_evaluated",
25707 "iteration": 1,
25708 "met": true,
25709 "grounded": true,
25710 "reason": "file exists"
25711 }),
25712 )
25713 .await;
25714
25715 let status = handle_goal_status(&req(json!({"session_id": "chat-local"})), &state)
25716 .await
25717 .unwrap();
25718 assert_eq!(status["goal"]["status"], "met");
25719 assert_eq!(status["goal"]["last_iteration"], 1);
25720 assert_eq!(status["goal"]["last_reason"], "file exists");
25721 }
25722
25723 #[tokio::test]
25724 async fn cancelling_goal_chat_marks_standing_goal_terminal() {
25725 let tmp = tempfile::TempDir::new().unwrap();
25726 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25727 handle_goal_set(
25728 &req(json!({"session_id": "chat-cancel", "check": "test -f done"})),
25729 &state,
25730 )
25731 .await
25732 .unwrap();
25733 update_chat_goal_from_event(
25734 &state,
25735 "chat-cancel",
25736 &json!({"kind": "goal_evaluated", "iteration": 1, "met": false, "grounded": false, "reason": "not yet"}),
25737 )
25738 .await;
25739 let cancel_flag = Arc::new(AtomicBool::new(false));
25740 state.chat_sessions.lock().await.insert(
25741 "chat-cancel".to_string(),
25742 ChatSession {
25743 agent_id: "local".to_string(),
25744 host_client_id: "host".to_string(),
25745 created_at: 0,
25746 local_cancel: Some(cancel_flag.clone()),
25747 },
25748 );
25749
25750 let stolen = handle_agents_chat_cancel(
25755 &req(json!({"session_id": "chat-cancel"})),
25756 &state,
25757 "someone-else",
25758 false,
25759 )
25760 .await;
25761 assert!(
25762 stolen.is_err(),
25763 "a foreign session must not cancel this turn: {stolen:?}"
25764 );
25765 assert!(
25766 state.chat_sessions.lock().await.contains_key("chat-cancel"),
25767 "a refused cancel must not destroy the routing entry on its way out"
25768 );
25769 assert!(
25770 !cancel_flag.load(std::sync::atomic::Ordering::SeqCst),
25771 "and must not have signalled cancellation"
25772 );
25773
25774 let cancel = handle_agents_chat_cancel(
25775 &req(json!({"session_id": "chat-cancel"})),
25776 &state,
25777 "host",
25778 true,
25779 )
25780 .await
25781 .unwrap();
25782 assert_eq!(cancel["cancelled"], true);
25783 let status = handle_goal_status(&req(json!({"session_id": "chat-cancel"})), &state)
25784 .await
25785 .unwrap();
25786 assert_eq!(status["goal"]["status"], "error");
25787 assert_eq!(status["goal"]["terminal_kind"], "error");
25788 assert_eq!(status["goal"]["terminal_message"], "cancelled");
25789 assert!(cancel_flag.load(std::sync::atomic::Ordering::SeqCst));
25790 }
25791
25792 #[tokio::test]
25793 async fn goal_status_persists_across_server_state_restart() {
25794 let tmp = tempfile::TempDir::new().unwrap();
25795 let journal_dir = tmp.path().join(".car").join("journals");
25796 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
25797
25798 handle_goal_set(
25799 &req(json!({"session_id": "chat-durable", "check": "test -f done"})),
25800 &state,
25801 )
25802 .await
25803 .unwrap();
25804 update_chat_goal_from_event(
25805 &state,
25806 "chat-durable",
25807 &json!({
25808 "kind": "goal_evaluated",
25809 "iteration": 1,
25810 "met": false,
25811 "grounded": false,
25812 "reason": "not yet"
25813 }),
25814 )
25815 .await;
25816
25817 let restarted = Arc::new(ServerState::standalone(journal_dir.clone()));
25818 let status = handle_goal_status(&req(json!({"session_id": "chat-durable"})), &restarted)
25819 .await
25820 .unwrap();
25821 assert_eq!(status["goal"]["check"], "test -f done");
25822 assert_eq!(status["goal"]["status"], "active");
25823 assert_eq!(status["goal"]["last_iteration"], 1);
25824 assert_eq!(status["goal"]["last_reason"], "not yet");
25825
25826 update_chat_goal_from_event(
25827 &restarted,
25828 "chat-durable",
25829 &json!({
25830 "kind": "goal_evaluated",
25831 "iteration": 2,
25832 "met": true,
25833 "grounded": true,
25834 "reason": "done exists"
25835 }),
25836 )
25837 .await;
25838 update_chat_goal_from_event(
25839 &restarted,
25840 "chat-durable",
25841 &json!({"kind": "done", "finish_reason": "goal reached"}),
25842 )
25843 .await;
25844
25845 let restarted_again = Arc::new(ServerState::standalone(journal_dir));
25846 let status = handle_goal_status(
25847 &req(json!({"session_id": "chat-durable"})),
25848 &restarted_again,
25849 )
25850 .await
25851 .unwrap();
25852 assert_eq!(status["goal"]["status"], "met");
25853 assert_eq!(status["goal"]["last_iteration"], 2);
25854 assert_eq!(status["goal"]["terminal_kind"], "done");
25855 }
25856
25857 #[tokio::test]
25858 async fn restart_downgrades_stale_running_goal_to_active() {
25859 let tmp = tempfile::TempDir::new().unwrap();
25860 let journal_dir = tmp.path().join(".car").join("journals");
25861 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
25862
25863 handle_goal_set(
25864 &req(json!({"session_id": "chat-running", "check": "true"})),
25865 &state,
25866 )
25867 .await
25868 .unwrap();
25869 {
25870 let mut goals = state.chat_goals.lock().await;
25871 goals.get_mut("chat-running").unwrap().status = "running".to_string();
25872 }
25873 state.persist_chat_goals().await.unwrap();
25874
25875 let restarted = Arc::new(ServerState::standalone(journal_dir));
25876 let status = handle_goal_status(&req(json!({"session_id": "chat-running"})), &restarted)
25877 .await
25878 .unwrap();
25879 assert_eq!(status["goal"]["status"], "active");
25880 }
25881
25882 #[tokio::test]
25883 async fn goal_suggest_detects_cargo_project() {
25884 let tmp = tempfile::TempDir::new().unwrap();
25885 std::fs::write(
25886 tmp.path().join("Cargo.toml"),
25887 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25888 )
25889 .unwrap();
25890 let state = Arc::new(ServerState::standalone(
25891 tmp.path().join(".car").join("journals"),
25892 ));
25893
25894 let got = handle_goal_suggest(
25895 &req(json!({
25896 "prompt": "make the tests pass",
25897 "working_dir": tmp.path().to_string_lossy()
25898 })),
25899 &state,
25900 )
25901 .await
25902 .unwrap();
25903 assert_eq!(got["suggested"], true);
25904 assert_eq!(got["confidence"], "high");
25905 assert!(got["goal"]["check"]
25906 .as_str()
25907 .unwrap()
25908 .ends_with(" && cargo test -q"));
25909 }
25910
25911 #[tokio::test]
25912 async fn goal_suggest_prefers_project_check_over_existing_path_mentions() {
25913 let tmp = tempfile::TempDir::new().unwrap();
25914 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
25915 std::fs::write(
25916 tmp.path().join("Cargo.toml"),
25917 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25918 )
25919 .unwrap();
25920 std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
25921 let state = Arc::new(ServerState::standalone(
25922 tmp.path().join(".car").join("journals"),
25923 ));
25924
25925 let got = handle_goal_suggest(
25926 &req(json!({
25927 "prompt": "fix src/lib.rs",
25928 "working_dir": tmp.path().to_string_lossy()
25929 })),
25930 &state,
25931 )
25932 .await
25933 .unwrap();
25934 assert!(got["goal"]["check"]
25935 .as_str()
25936 .unwrap()
25937 .ends_with(" && cargo test -q"));
25938 }
25939
25940 #[tokio::test]
25941 async fn goal_suggest_combines_created_file_with_project_check() {
25942 let tmp = tempfile::TempDir::new().unwrap();
25943 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
25944 std::fs::write(
25945 tmp.path().join("Cargo.toml"),
25946 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25947 )
25948 .unwrap();
25949 let state = Arc::new(ServerState::standalone(
25950 tmp.path().join(".car").join("journals"),
25951 ));
25952
25953 let got = handle_goal_suggest(
25954 &req(json!({
25955 "prompt": "create src/lib.rs",
25956 "working_dir": tmp.path().to_string_lossy()
25957 })),
25958 &state,
25959 )
25960 .await
25961 .unwrap();
25962 assert_eq!(got["suggested"], true);
25963 assert_eq!(got["confidence"], "high");
25964 let check = got["goal"]["check"].as_str().unwrap();
25965 assert!(check.contains("test -f 'src/lib.rs'"), "{check}");
25966 assert!(check.ends_with(" && cargo test -q"), "{check}");
25967 let signals = got["signals"].as_array().unwrap();
25968 assert!(signals.contains(&json!("prompt_mentions_path:src/lib.rs")));
25969 assert!(signals.contains(&json!("Cargo.toml")));
25970 }
25971
25972 #[tokio::test]
25973 async fn goal_suggest_chooses_package_script_from_prompt() {
25974 let tmp = tempfile::TempDir::new().unwrap();
25975 std::fs::write(
25976 tmp.path().join("package.json"),
25977 r#"{"scripts":{"test":"vitest run","typecheck":"tsc --noEmit","check":"biome check","build":"vite build","lint":"eslint ."}}"#,
25978 )
25979 .unwrap();
25980 std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
25981 let state = Arc::new(ServerState::standalone(
25982 tmp.path().join(".car").join("journals"),
25983 ));
25984
25985 let got = handle_goal_suggest(
25986 &req(json!({
25987 "prompt": "fix the TypeScript errors",
25988 "working_dir": tmp.path().to_string_lossy()
25989 })),
25990 &state,
25991 )
25992 .await
25993 .unwrap();
25994 assert_eq!(got["suggested"], true);
25995 assert!(got["goal"]["check"]
25996 .as_str()
25997 .unwrap()
25998 .ends_with(" && pnpm typecheck"));
25999 }
26000
26001 #[tokio::test]
26002 async fn goal_suggest_chooses_check_script_from_prompt() {
26003 let tmp = tempfile::TempDir::new().unwrap();
26004 std::fs::write(
26005 tmp.path().join("package.json"),
26006 r#"{"scripts":{"test":"vitest run","check":"biome check","build":"vite build"}}"#,
26007 )
26008 .unwrap();
26009 std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26010 let state = Arc::new(ServerState::standalone(
26011 tmp.path().join(".car").join("journals"),
26012 ));
26013
26014 let got = handle_goal_suggest(
26015 &req(json!({
26016 "prompt": "fix the CI check failures",
26017 "working_dir": tmp.path().to_string_lossy()
26018 })),
26019 &state,
26020 )
26021 .await
26022 .unwrap();
26023 assert_eq!(got["suggested"], true);
26024 assert!(got["goal"]["check"]
26025 .as_str()
26026 .unwrap()
26027 .ends_with(" && pnpm check"));
26028 let signals = got["signals"].as_array().unwrap();
26029 assert!(signals.contains(&json!("package_script:check")));
26030 }
26031
26032 #[tokio::test]
26033 async fn goal_suggest_chooses_build_script_from_nested_directory_prompt() {
26034 let tmp = tempfile::TempDir::new().unwrap();
26035 std::fs::create_dir_all(tmp.path().join("apps/web")).unwrap();
26036 std::fs::write(
26037 tmp.path().join("apps/web/package.json"),
26038 r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"eslint ."}}"#,
26039 )
26040 .unwrap();
26041 let state = Arc::new(ServerState::standalone(
26042 tmp.path().join(".car").join("journals"),
26043 ));
26044
26045 let got = handle_goal_suggest(
26046 &req(json!({
26047 "prompt": "fix the production build in apps/web",
26048 "working_dir": tmp.path().to_string_lossy()
26049 })),
26050 &state,
26051 )
26052 .await
26053 .unwrap();
26054 assert_eq!(got["suggested"], true);
26055 let check = got["goal"]["check"].as_str().unwrap();
26056 assert!(check.contains("apps/web"), "{check}");
26057 assert!(check.ends_with(" && npm run build"), "{check}");
26058 let signals = got["signals"].as_array().unwrap();
26059 assert!(signals.contains(&json!("package_script:build")));
26060 }
26061
26062 #[tokio::test]
26063 async fn goal_suggest_chooses_lint_script_from_prompt() {
26064 let tmp = tempfile::TempDir::new().unwrap();
26065 std::fs::write(
26066 tmp.path().join("package.json"),
26067 r#"{"scripts":{"test":"vitest run","lint":"eslint .","build":"vite build"}}"#,
26068 )
26069 .unwrap();
26070 let state = Arc::new(ServerState::standalone(
26071 tmp.path().join(".car").join("journals"),
26072 ));
26073
26074 let got = handle_goal_suggest(
26075 &req(json!({
26076 "prompt": "fix lint errors",
26077 "working_dir": tmp.path().to_string_lossy()
26078 })),
26079 &state,
26080 )
26081 .await
26082 .unwrap();
26083 assert_eq!(got["suggested"], true);
26084 assert!(got["goal"]["check"]
26085 .as_str()
26086 .unwrap()
26087 .ends_with(" && npm run lint"));
26088 let signals = got["signals"].as_array().unwrap();
26089 assert!(signals.contains(&json!("package_script:lint")));
26090 }
26091
26092 #[tokio::test]
26093 async fn goal_suggest_uses_nearest_nested_project_for_created_file() {
26094 let tmp = tempfile::TempDir::new().unwrap();
26095 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26096 std::fs::write(
26097 tmp.path().join("apps/web/package.json"),
26098 r#"{"scripts":{"test":"vitest run"}}"#,
26099 )
26100 .unwrap();
26101 let state = Arc::new(ServerState::standalone(
26102 tmp.path().join(".car").join("journals"),
26103 ));
26104
26105 let got = handle_goal_suggest(
26106 &req(json!({
26107 "prompt": "create apps/web/src/App.ts",
26108 "working_dir": tmp.path().to_string_lossy()
26109 })),
26110 &state,
26111 )
26112 .await
26113 .unwrap();
26114 assert_eq!(got["suggested"], true);
26115 assert_eq!(got["confidence"], "high");
26116 let check = got["goal"]["check"].as_str().unwrap();
26117 assert!(check.contains("apps/web"), "{check}");
26118 assert!(check.contains("test -f 'src/App.ts'"), "{check}");
26119 assert!(check.ends_with(" && npm test"), "{check}");
26120 let signals = got["signals"].as_array().unwrap();
26121 assert!(signals.contains(&json!("project_dir:apps/web")));
26122 assert!(signals.contains(&json!("package.json")));
26123 }
26124
26125 #[tokio::test]
26126 async fn goal_suggest_uses_nearest_nested_project_for_existing_file_prompt() {
26127 let tmp = tempfile::TempDir::new().unwrap();
26128 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26129 std::fs::write(
26130 tmp.path().join("apps/web/package.json"),
26131 r#"{"scripts":{"typecheck":"tsc --noEmit","test":"vitest run"}}"#,
26132 )
26133 .unwrap();
26134 std::fs::write(tmp.path().join("apps/web/src/App.ts"), "export {}\n").unwrap();
26135 let state = Arc::new(ServerState::standalone(
26136 tmp.path().join(".car").join("journals"),
26137 ));
26138
26139 let got = handle_goal_suggest(
26140 &req(json!({
26141 "prompt": "fix TypeScript errors in apps/web/src/App.ts",
26142 "working_dir": tmp.path().to_string_lossy()
26143 })),
26144 &state,
26145 )
26146 .await
26147 .unwrap();
26148 assert_eq!(got["suggested"], true);
26149 assert_eq!(got["confidence"], "high");
26150 let check = got["goal"]["check"].as_str().unwrap();
26151 assert!(check.contains("apps/web"), "{check}");
26152 assert!(!check.contains("test -f"), "{check}");
26153 assert!(check.ends_with(" && npm run typecheck"), "{check}");
26154 let signals = got["signals"].as_array().unwrap();
26155 assert!(signals.contains(&json!("prompt_mentions_path:apps/web/src/App.ts")));
26156 assert!(signals.contains(&json!("project_dir:apps/web")));
26157 assert!(signals.contains(&json!("package_script:typecheck")));
26158 }
26159
26160 #[tokio::test]
26161 async fn goal_suggest_uses_nearest_nested_project_for_directory_prompt() {
26162 let tmp = tempfile::TempDir::new().unwrap();
26163 std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26164 std::fs::write(
26165 tmp.path().join("apps/web/package.json"),
26166 r#"{"scripts":{"test":"vitest run"}}"#,
26167 )
26168 .unwrap();
26169 let state = Arc::new(ServerState::standalone(
26170 tmp.path().join(".car").join("journals"),
26171 ));
26172
26173 let got = handle_goal_suggest(
26174 &req(json!({
26175 "prompt": "fix apps/web",
26176 "working_dir": tmp.path().to_string_lossy()
26177 })),
26178 &state,
26179 )
26180 .await
26181 .unwrap();
26182 assert_eq!(got["suggested"], true);
26183 assert_eq!(got["confidence"], "high");
26184 let check = got["goal"]["check"].as_str().unwrap();
26185 assert!(check.contains("apps/web"), "{check}");
26186 assert!(!check.contains("test -f"), "{check}");
26187 assert!(check.ends_with(" && npm test"), "{check}");
26188 let signals = got["signals"].as_array().unwrap();
26189 assert!(signals.contains(&json!("prompt_mentions_path:apps/web")));
26190 assert!(signals.contains(&json!("project_dir:apps/web")));
26191 }
26192
26193 #[tokio::test]
26194 async fn goal_suggest_can_store_the_suggested_goal() {
26195 let tmp = tempfile::TempDir::new().unwrap();
26196 std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
26197 let journal_dir = tmp.path().join(".car").join("journals");
26198 let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26199
26200 let got = handle_goal_suggest(
26201 &req(json!({
26202 "prompt": "make it work",
26203 "working_dir": tmp.path().to_string_lossy(),
26204 "session_id": "chat-suggest",
26205 "set": true
26206 })),
26207 &state,
26208 )
26209 .await
26210 .unwrap();
26211 assert_eq!(got["set"], true);
26212 assert_eq!(got["stored_goal"]["session_id"], "chat-suggest");
26213
26214 let restarted = Arc::new(ServerState::standalone(journal_dir));
26215 let status = handle_goal_status(&req(json!({"session_id": "chat-suggest"})), &restarted)
26216 .await
26217 .unwrap();
26218 assert!(status["goal"]["check"]
26219 .as_str()
26220 .unwrap()
26221 .ends_with(" && go test ./..."));
26222 }
26223
26224 #[tokio::test]
26225 async fn goal_suggest_detects_swift_package() {
26226 let tmp = tempfile::TempDir::new().unwrap();
26227 std::fs::write(
26228 tmp.path().join("Package.swift"),
26229 "// swift-tools-version: 6.0\nimport PackageDescription\n",
26230 )
26231 .unwrap();
26232 let state = Arc::new(ServerState::standalone(
26233 tmp.path().join(".car").join("journals"),
26234 ));
26235
26236 let got = handle_goal_suggest(
26237 &req(json!({
26238 "prompt": "make the package tests pass",
26239 "working_dir": tmp.path().to_string_lossy()
26240 })),
26241 &state,
26242 )
26243 .await
26244 .unwrap();
26245 assert_eq!(got["suggested"], true);
26246 assert_eq!(got["confidence"], "high");
26247 assert!(got["goal"]["check"]
26248 .as_str()
26249 .unwrap()
26250 .ends_with(" && swift test"));
26251 let signals = got["signals"].as_array().unwrap();
26252 assert!(signals.contains(&json!("Package.swift")));
26253 }
26254
26255 #[tokio::test]
26256 async fn goal_suggest_combines_created_swift_file_with_package_check() {
26257 let tmp = tempfile::TempDir::new().unwrap();
26258 std::fs::create_dir_all(tmp.path().join("Sources/App")).unwrap();
26259 std::fs::write(
26260 tmp.path().join("Package.swift"),
26261 "// swift-tools-version: 6.0\nimport PackageDescription\n",
26262 )
26263 .unwrap();
26264 let state = Arc::new(ServerState::standalone(
26265 tmp.path().join(".car").join("journals"),
26266 ));
26267
26268 let got = handle_goal_suggest(
26269 &req(json!({
26270 "prompt": "create Sources/App/Main.swift",
26271 "working_dir": tmp.path().to_string_lossy()
26272 })),
26273 &state,
26274 )
26275 .await
26276 .unwrap();
26277 assert_eq!(got["suggested"], true);
26278 let check = got["goal"]["check"].as_str().unwrap();
26279 assert!(
26280 check.contains("test -f 'Sources/App/Main.swift'"),
26281 "{check}"
26282 );
26283 assert!(check.ends_with(" && swift test"), "{check}");
26284 }
26285
26286 #[tokio::test]
26287 async fn goal_suggest_detects_cmake_project() {
26288 let tmp = tempfile::TempDir::new().unwrap();
26289 std::fs::write(
26290 tmp.path().join("CMakeLists.txt"),
26291 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26292 )
26293 .unwrap();
26294 let state = Arc::new(ServerState::standalone(
26295 tmp.path().join(".car").join("journals"),
26296 ));
26297
26298 let got = handle_goal_suggest(
26299 &req(json!({
26300 "prompt": "fix the C++ build",
26301 "working_dir": tmp.path().to_string_lossy()
26302 })),
26303 &state,
26304 )
26305 .await
26306 .unwrap();
26307 assert_eq!(got["suggested"], true);
26308 assert_eq!(got["confidence"], "high");
26309 assert!(got["goal"]["check"]
26310 .as_str()
26311 .unwrap()
26312 .ends_with(" && cmake -S . -B build && cmake --build build"));
26313 let signals = got["signals"].as_array().unwrap();
26314 assert!(signals.contains(&json!("CMakeLists.txt")));
26315 }
26316
26317 #[tokio::test]
26318 async fn goal_suggest_combines_created_cpp_file_with_cmake_check() {
26319 let tmp = tempfile::TempDir::new().unwrap();
26320 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26321 std::fs::write(
26322 tmp.path().join("CMakeLists.txt"),
26323 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26324 )
26325 .unwrap();
26326 let state = Arc::new(ServerState::standalone(
26327 tmp.path().join(".car").join("journals"),
26328 ));
26329
26330 let got = handle_goal_suggest(
26331 &req(json!({
26332 "prompt": "create src/widget.cpp",
26333 "working_dir": tmp.path().to_string_lossy()
26334 })),
26335 &state,
26336 )
26337 .await
26338 .unwrap();
26339 assert_eq!(got["suggested"], true);
26340 assert_eq!(got["confidence"], "high");
26341 let check = got["goal"]["check"].as_str().unwrap();
26342 assert!(check.contains("test -f 'src/widget.cpp'"), "{check}");
26343 assert!(
26344 check.ends_with(" && cmake -S . -B build && cmake --build build"),
26345 "{check}"
26346 );
26347 let signals = got["signals"].as_array().unwrap();
26348 assert!(signals.contains(&json!("prompt_mentions_path:src/widget.cpp")));
26349 assert!(signals.contains(&json!("CMakeLists.txt")));
26350 }
26351
26352 #[tokio::test]
26353 async fn goal_suggest_uses_ctest_for_cmake_test_prompts() {
26354 let tmp = tempfile::TempDir::new().unwrap();
26355 std::fs::write(
26356 tmp.path().join("CMakeLists.txt"),
26357 "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26358 )
26359 .unwrap();
26360 let state = Arc::new(ServerState::standalone(
26361 tmp.path().join(".car").join("journals"),
26362 ));
26363
26364 let got = handle_goal_suggest(
26365 &req(json!({
26366 "prompt": "make the cmake unit tests pass",
26367 "working_dir": tmp.path().to_string_lossy()
26368 })),
26369 &state,
26370 )
26371 .await
26372 .unwrap();
26373 assert_eq!(got["suggested"], true);
26374 let check = got["goal"]["check"].as_str().unwrap();
26375 assert!(check.ends_with(
26376 " && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure"
26377 ), "{check}");
26378 }
26379
26380 #[tokio::test]
26381 async fn goal_suggest_detects_gradle_project() {
26382 let tmp = tempfile::TempDir::new().unwrap();
26383 std::fs::write(
26384 tmp.path().join("build.gradle.kts"),
26385 "plugins { kotlin(\"jvm\") }\n",
26386 )
26387 .unwrap();
26388 std::fs::write(tmp.path().join("gradlew"), "#!/bin/sh\n").unwrap();
26389 let state = Arc::new(ServerState::standalone(
26390 tmp.path().join(".car").join("journals"),
26391 ));
26392
26393 let got = handle_goal_suggest(
26394 &req(json!({
26395 "prompt": "make the kotlin tests pass",
26396 "working_dir": tmp.path().to_string_lossy()
26397 })),
26398 &state,
26399 )
26400 .await
26401 .unwrap();
26402 assert_eq!(got["suggested"], true);
26403 assert_eq!(got["confidence"], "high");
26404 assert!(got["goal"]["check"]
26405 .as_str()
26406 .unwrap()
26407 .ends_with(" && ./gradlew test"));
26408 let signals = got["signals"].as_array().unwrap();
26409 assert!(signals.contains(&json!("build.gradle.kts")));
26410 assert!(signals.contains(&json!("gradlew")));
26411 }
26412
26413 #[tokio::test]
26414 async fn goal_suggest_combines_created_kotlin_file_with_gradle_check() {
26415 let tmp = tempfile::TempDir::new().unwrap();
26416 std::fs::create_dir_all(tmp.path().join("app/src/main/java/com/demo")).unwrap();
26417 std::fs::write(tmp.path().join("build.gradle"), "plugins { id 'java' }\n").unwrap();
26418 let state = Arc::new(ServerState::standalone(
26419 tmp.path().join(".car").join("journals"),
26420 ));
26421
26422 let got = handle_goal_suggest(
26423 &req(json!({
26424 "prompt": "create app/src/main/java/com/demo/Main.kt",
26425 "working_dir": tmp.path().to_string_lossy()
26426 })),
26427 &state,
26428 )
26429 .await
26430 .unwrap();
26431 assert_eq!(got["suggested"], true);
26432 assert_eq!(got["confidence"], "high");
26433 let check = got["goal"]["check"].as_str().unwrap();
26434 assert!(
26435 check.contains("test -f 'app/src/main/java/com/demo/Main.kt'"),
26436 "{check}"
26437 );
26438 assert!(check.ends_with(" && gradle test"), "{check}");
26439 }
26440
26441 #[tokio::test]
26442 async fn goal_suggest_detects_maven_project() {
26443 let tmp = tempfile::TempDir::new().unwrap();
26444 std::fs::write(
26445 tmp.path().join("pom.xml"),
26446 "<project><modelVersion>4.0.0</modelVersion></project>\n",
26447 )
26448 .unwrap();
26449 std::fs::write(tmp.path().join("mvnw"), "#!/bin/sh\n").unwrap();
26450 let state = Arc::new(ServerState::standalone(
26451 tmp.path().join(".car").join("journals"),
26452 ));
26453
26454 let got = handle_goal_suggest(
26455 &req(json!({
26456 "prompt": "make the java tests pass",
26457 "working_dir": tmp.path().to_string_lossy()
26458 })),
26459 &state,
26460 )
26461 .await
26462 .unwrap();
26463 assert_eq!(got["suggested"], true);
26464 assert_eq!(got["confidence"], "high");
26465 assert!(got["goal"]["check"]
26466 .as_str()
26467 .unwrap()
26468 .ends_with(" && ./mvnw test"));
26469 let signals = got["signals"].as_array().unwrap();
26470 assert!(signals.contains(&json!("pom.xml")));
26471 assert!(signals.contains(&json!("mvnw")));
26472 }
26473
26474 #[tokio::test]
26475 async fn goal_suggest_combines_created_java_file_with_maven_check() {
26476 let tmp = tempfile::TempDir::new().unwrap();
26477 std::fs::create_dir_all(tmp.path().join("src/main/java/com/demo")).unwrap();
26478 std::fs::write(
26479 tmp.path().join("pom.xml"),
26480 "<project><modelVersion>4.0.0</modelVersion></project>\n",
26481 )
26482 .unwrap();
26483 let state = Arc::new(ServerState::standalone(
26484 tmp.path().join(".car").join("journals"),
26485 ));
26486
26487 let got = handle_goal_suggest(
26488 &req(json!({
26489 "prompt": "create src/main/java/com/demo/Main.java",
26490 "working_dir": tmp.path().to_string_lossy()
26491 })),
26492 &state,
26493 )
26494 .await
26495 .unwrap();
26496 assert_eq!(got["suggested"], true);
26497 assert_eq!(got["confidence"], "high");
26498 let check = got["goal"]["check"].as_str().unwrap();
26499 assert!(
26500 check.contains("test -f 'src/main/java/com/demo/Main.java'"),
26501 "{check}"
26502 );
26503 assert!(check.ends_with(" && mvn test"), "{check}");
26504 }
26505
26506 #[tokio::test]
26507 async fn goal_suggest_detects_dotnet_solution() {
26508 let tmp = tempfile::TempDir::new().unwrap();
26509 std::fs::write(tmp.path().join("Demo.sln"), "").unwrap();
26510 let state = Arc::new(ServerState::standalone(
26511 tmp.path().join(".car").join("journals"),
26512 ));
26513
26514 let got = handle_goal_suggest(
26515 &req(json!({
26516 "prompt": "make the csharp tests pass",
26517 "working_dir": tmp.path().to_string_lossy()
26518 })),
26519 &state,
26520 )
26521 .await
26522 .unwrap();
26523 assert_eq!(got["suggested"], true);
26524 assert_eq!(got["confidence"], "high");
26525 assert!(got["goal"]["check"]
26526 .as_str()
26527 .unwrap()
26528 .ends_with(" && dotnet test"));
26529 let signals = got["signals"].as_array().unwrap();
26530 assert!(signals.contains(&json!("Demo.sln")));
26531 }
26532
26533 #[tokio::test]
26534 async fn goal_suggest_combines_created_csharp_file_with_dotnet_check() {
26535 let tmp = tempfile::TempDir::new().unwrap();
26536 std::fs::create_dir_all(tmp.path().join("src/Demo")).unwrap();
26537 std::fs::write(
26538 tmp.path().join("src").join("Demo").join("Demo.csproj"),
26539 "<Project Sdk=\"Microsoft.NET.Sdk\" />\n",
26540 )
26541 .unwrap();
26542 let state = Arc::new(ServerState::standalone(
26543 tmp.path().join(".car").join("journals"),
26544 ));
26545
26546 let got = handle_goal_suggest(
26547 &req(json!({
26548 "prompt": "create Program.cs",
26549 "working_dir": tmp.path().join("src").join("Demo").to_string_lossy()
26550 })),
26551 &state,
26552 )
26553 .await
26554 .unwrap();
26555 assert_eq!(got["suggested"], true);
26556 assert_eq!(got["confidence"], "high");
26557 let check = got["goal"]["check"].as_str().unwrap();
26558 assert!(check.contains("test -f 'Program.cs'"), "{check}");
26559 assert!(check.ends_with(" && dotnet test"), "{check}");
26560 }
26561
26562 #[tokio::test]
26563 async fn goal_suggest_detects_php_composer_project() {
26564 let tmp = tempfile::TempDir::new().unwrap();
26565 std::fs::create_dir_all(tmp.path().join("vendor/bin")).unwrap();
26566 std::fs::write(tmp.path().join("composer.json"), r#"{"require-dev":{}}"#).unwrap();
26567 std::fs::write(
26568 tmp.path().join("vendor/bin/phpunit"),
26569 "#!/usr/bin/env php\n",
26570 )
26571 .unwrap();
26572 let state = Arc::new(ServerState::standalone(
26573 tmp.path().join(".car").join("journals"),
26574 ));
26575
26576 let got = handle_goal_suggest(
26577 &req(json!({
26578 "prompt": "make the php tests pass",
26579 "working_dir": tmp.path().to_string_lossy()
26580 })),
26581 &state,
26582 )
26583 .await
26584 .unwrap();
26585 assert_eq!(got["suggested"], true);
26586 assert_eq!(got["confidence"], "high");
26587 assert!(got["goal"]["check"]
26588 .as_str()
26589 .unwrap()
26590 .ends_with(" && vendor/bin/phpunit"));
26591 let signals = got["signals"].as_array().unwrap();
26592 assert!(signals.contains(&json!("composer.json")));
26593 assert!(signals.contains(&json!("phpunit")));
26594 }
26595
26596 #[tokio::test]
26597 async fn goal_suggest_combines_created_php_file_with_composer_script() {
26598 let tmp = tempfile::TempDir::new().unwrap();
26599 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26600 std::fs::write(
26601 tmp.path().join("composer.json"),
26602 r#"{"scripts":{"test":"phpunit"}}"#,
26603 )
26604 .unwrap();
26605 let state = Arc::new(ServerState::standalone(
26606 tmp.path().join(".car").join("journals"),
26607 ));
26608
26609 let got = handle_goal_suggest(
26610 &req(json!({
26611 "prompt": "create src/App.php",
26612 "working_dir": tmp.path().to_string_lossy()
26613 })),
26614 &state,
26615 )
26616 .await
26617 .unwrap();
26618 assert_eq!(got["suggested"], true);
26619 assert_eq!(got["confidence"], "high");
26620 let check = got["goal"]["check"].as_str().unwrap();
26621 assert!(check.contains("test -f 'src/App.php'"), "{check}");
26622 assert!(check.ends_with(" && composer test"), "{check}");
26623 let signals = got["signals"].as_array().unwrap();
26624 assert!(signals.contains(&json!("composer_script:test")));
26625 }
26626
26627 #[tokio::test]
26628 async fn goal_suggest_uses_php_lint_when_composer_has_no_test_signal() {
26629 let tmp = tempfile::TempDir::new().unwrap();
26630 std::fs::write(tmp.path().join("composer.json"), r#"{"scripts":{}}"#).unwrap();
26631 let state = Arc::new(ServerState::standalone(
26632 tmp.path().join(".car").join("journals"),
26633 ));
26634
26635 let got = handle_goal_suggest(
26636 &req(json!({
26637 "prompt": "make the php project valid",
26638 "working_dir": tmp.path().to_string_lossy()
26639 })),
26640 &state,
26641 )
26642 .await
26643 .unwrap();
26644 assert_eq!(got["suggested"], true);
26645 assert_eq!(got["confidence"], "medium");
26646 let check = got["goal"]["check"].as_str().unwrap();
26647 assert!(
26648 check.ends_with(
26649 " && find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l"
26650 ),
26651 "{check}"
26652 );
26653 let signals = got["signals"].as_array().unwrap();
26654 assert!(!signals.contains(&json!("composer_script:test")));
26655 }
26656
26657 #[tokio::test]
26658 async fn goal_suggest_detects_ruby_bundler_project() {
26659 let tmp = tempfile::TempDir::new().unwrap();
26660 std::fs::write(
26661 tmp.path().join("Gemfile"),
26662 "source 'https://rubygems.org'\n",
26663 )
26664 .unwrap();
26665 std::fs::write(tmp.path().join("Rakefile"), "task :test\n").unwrap();
26666 let state = Arc::new(ServerState::standalone(
26667 tmp.path().join(".car").join("journals"),
26668 ));
26669
26670 let got = handle_goal_suggest(
26671 &req(json!({
26672 "prompt": "make the ruby tests pass",
26673 "working_dir": tmp.path().to_string_lossy()
26674 })),
26675 &state,
26676 )
26677 .await
26678 .unwrap();
26679 assert_eq!(got["suggested"], true);
26680 assert_eq!(got["confidence"], "high");
26681 assert!(got["goal"]["check"]
26682 .as_str()
26683 .unwrap()
26684 .ends_with(" && bundle exec rake test"));
26685 let signals = got["signals"].as_array().unwrap();
26686 assert!(signals.contains(&json!("Gemfile")));
26687 assert!(signals.contains(&json!("Rakefile")));
26688 }
26689
26690 #[tokio::test]
26691 async fn goal_suggest_uses_ruby_syntax_check_without_test_runner_signal() {
26692 let tmp = tempfile::TempDir::new().unwrap();
26693 std::fs::write(
26694 tmp.path().join("Gemfile"),
26695 "source 'https://rubygems.org'\n",
26696 )
26697 .unwrap();
26698 let state = Arc::new(ServerState::standalone(
26699 tmp.path().join(".car").join("journals"),
26700 ));
26701
26702 let got = handle_goal_suggest(
26703 &req(json!({
26704 "prompt": "make the ruby project valid",
26705 "working_dir": tmp.path().to_string_lossy()
26706 })),
26707 &state,
26708 )
26709 .await
26710 .unwrap();
26711 assert_eq!(got["suggested"], true);
26712 assert_eq!(got["confidence"], "medium");
26713 let check = got["goal"]["check"].as_str().unwrap();
26714 assert!(
26715 check.ends_with(
26716 " && find . -name '*.rb' -not -path './vendor/*' -print0 | xargs -0 -n1 ruby -c"
26717 ),
26718 "{check}"
26719 );
26720 let signals = got["signals"].as_array().unwrap();
26721 assert!(signals.contains(&json!("Gemfile")));
26722 assert!(!signals.contains(&json!("Rakefile")));
26723 }
26724
26725 #[tokio::test]
26726 async fn goal_suggest_combines_created_elixir_file_with_mix_check() {
26727 let tmp = tempfile::TempDir::new().unwrap();
26728 std::fs::create_dir_all(tmp.path().join("lib/demo")).unwrap();
26729 std::fs::write(
26730 tmp.path().join("mix.exs"),
26731 "defmodule Demo.MixProject do\nend\n",
26732 )
26733 .unwrap();
26734 let state = Arc::new(ServerState::standalone(
26735 tmp.path().join(".car").join("journals"),
26736 ));
26737
26738 let got = handle_goal_suggest(
26739 &req(json!({
26740 "prompt": "create lib/demo/worker.ex",
26741 "working_dir": tmp.path().to_string_lossy()
26742 })),
26743 &state,
26744 )
26745 .await
26746 .unwrap();
26747 assert_eq!(got["suggested"], true);
26748 assert_eq!(got["confidence"], "high");
26749 let check = got["goal"]["check"].as_str().unwrap();
26750 assert!(check.contains("test -f 'lib/demo/worker.ex'"), "{check}");
26751 assert!(check.ends_with(" && mix test"), "{check}");
26752 }
26753
26754 #[tokio::test]
26755 async fn goal_suggest_falls_through_when_package_has_no_scripts() {
26756 let tmp = tempfile::TempDir::new().unwrap();
26757 std::fs::write(tmp.path().join("package.json"), r#"{"scripts":{}}"#).unwrap();
26758 std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
26759 let state = Arc::new(ServerState::standalone(
26760 tmp.path().join(".car").join("journals"),
26761 ));
26762
26763 let got = handle_goal_suggest(
26764 &req(json!({
26765 "prompt": "make it work",
26766 "working_dir": tmp.path().to_string_lossy()
26767 })),
26768 &state,
26769 )
26770 .await
26771 .unwrap();
26772 assert_eq!(got["suggested"], true);
26773 assert!(got["goal"]["check"]
26774 .as_str()
26775 .unwrap()
26776 .ends_with(" && go test ./..."));
26777 let warnings = got["warnings"].as_array().unwrap();
26778 assert!(warnings.contains(&json!(
26779 "package.json has no test/typecheck/check/build/lint script"
26780 )));
26781 }
26782
26783 #[tokio::test]
26784 async fn goal_suggest_refuses_to_invent_a_weak_check() {
26785 let tmp = tempfile::TempDir::new().unwrap();
26786 let state = Arc::new(ServerState::standalone(
26787 tmp.path().join(".car").join("journals"),
26788 ));
26789 let got = handle_goal_suggest(
26790 &req(json!({
26791 "prompt": "make it better",
26792 "working_dir": tmp.path().to_string_lossy()
26793 })),
26794 &state,
26795 )
26796 .await
26797 .unwrap();
26798 assert_eq!(got["suggested"], false);
26799 assert!(got["goal"].is_null());
26800 }
26801}
26802
26803#[cfg(test)]
26804mod metrics_alert_surface {
26805 use super::{handle_metrics_alerts, JsonRpcMessage};
26806 use crate::session::{ServerState, WsChannel};
26807 use car_verify::goal::GoalCondition;
26808 use serde_json::{json, Value};
26809 use std::sync::Arc;
26810
26811 fn req(params: Value) -> JsonRpcMessage {
26812 JsonRpcMessage {
26813 jsonrpc: "2.0".into(),
26814 method: None,
26815 params,
26816 id: json!(1),
26817 result: None,
26818 error: None,
26819 }
26820 }
26821
26822 #[tokio::test]
26823 async fn metrics_alerts_returns_goal_ungrounded() {
26824 let tmp = tempfile::TempDir::new().unwrap();
26825 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26826 let session = state
26827 .create_session("metrics-alerts", Arc::new(WsChannel::test_stub()))
26828 .await
26829 .unwrap();
26830 let condition = GoalCondition::Command {
26831 id: "tests".to_string(),
26832 expect_exit: 0,
26833 };
26834 session
26835 .runtime
26836 .record_goal_evaluated(
26837 "make tests pass",
26838 &condition,
26839 1,
26840 true,
26841 false,
26842 "ungrounded assistant summary claim(s): tests were run/passed",
26843 "mlx/qwen3-8b:4bit",
26844 )
26845 .await;
26846
26847 let got = handle_metrics_alerts(&req(json!({"max_goals_ungrounded": 0})), &session)
26848 .await
26849 .unwrap();
26850 assert_eq!(got["summary"]["goals_ungrounded"], json!(1));
26851 assert_eq!(got["alerts"][0]["kind"], json!("goal_ungrounded"));
26852 assert_eq!(got["alerts"][0]["observed"], json!(1.0));
26853 assert_eq!(got["alerts"][0]["threshold"], json!(0.0));
26854 }
26855}
26856
26857#[cfg(test)]
26858mod tool_registry_surface {
26859 use super::{
26879 handle_tools_list, handle_tools_register, handle_tools_unregister, JsonRpcMessage,
26880 };
26881 use crate::session::{ServerState, WsChannel};
26882 use serde_json::{json, Value};
26883 use std::sync::Arc;
26884
26885 fn req(params: Value) -> JsonRpcMessage {
26886 JsonRpcMessage {
26887 jsonrpc: "2.0".into(),
26888 method: None,
26889 params,
26890 id: json!(1),
26891 result: None,
26892 error: None,
26893 }
26894 }
26895
26896 async fn session(client_id: &str) -> Arc<crate::session::ClientSession> {
26897 let tmp = tempfile::TempDir::new().unwrap();
26898 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26899 state
26900 .create_session(client_id, Arc::new(WsChannel::test_stub()))
26901 .await
26902 .unwrap()
26903 }
26904
26905 fn names(listed: &Value) -> Vec<String> {
26907 listed["tools"]
26908 .as_array()
26909 .expect("tools must be an array")
26910 .iter()
26911 .map(|t| {
26912 t["name"]
26913 .as_str()
26914 .expect("name must be a string")
26915 .to_string()
26916 })
26917 .collect()
26918 }
26919
26920 #[tokio::test]
26925 async fn fresh_session_lists_only_the_built_in_messaging_send() {
26926 let session = session("c-tools-list-empty").await;
26927 let listed = handle_tools_list(&session).await.unwrap();
26928 assert_eq!(listed["count"], json!(1));
26929 assert_eq!(names(&listed), vec!["messaging.send"]);
26930 assert_eq!(listed["tools"][0]["source"], json!("builtin"));
26931 }
26932
26933 #[tokio::test]
26938 async fn list_returns_exactly_the_two_registered_tools_over_the_baseline_in_sorted_order() {
26939 let session = session("c-tools-list-two").await;
26940
26941 let registered = handle_tools_register(
26947 &req(json!([
26948 { "name": "write_file" },
26949 { "name": "read_file" },
26950 ])),
26951 &session,
26952 )
26953 .await
26954 .unwrap();
26955 assert_eq!(registered, json!(2));
26956
26957 let listed = handle_tools_list(&session).await.unwrap();
26958 assert_eq!(listed["count"], json!(3));
26959 assert_eq!(
26960 names(&listed),
26961 vec!["messaging.send", "read_file", "write_file"]
26962 );
26963 }
26964
26965 #[tokio::test]
26966 async fn register_rejects_a_server_owned_tool_name_without_changing_its_source() {
26967 let session = session("c-tools-register-shadow").await;
26968 let error = handle_tools_register(
26969 &req(json!([{ "name": "messaging.send", "description": "shadow" }])),
26970 &session,
26971 )
26972 .await
26973 .expect_err("callback registration must not shadow a built-in");
26974 assert_eq!(
26975 error,
26976 "tool 'messaging.send' is server-owned and cannot be shadowed by tools.register"
26977 );
26978
26979 let listed = handle_tools_list(&session).await.unwrap();
26980 assert_eq!(listed["count"], json!(1));
26981 assert_eq!(names(&listed), vec!["messaging.send"]);
26982 assert_eq!(listed["tools"][0]["source"], json!("builtin"));
26983 }
26984
26985 #[tokio::test]
26986 async fn unregister_drops_one_tool_and_list_reports_the_remainder() {
26987 let session = session("c-tools-unregister").await;
26988 handle_tools_register(
26989 &req(json!([{ "name": "read_file" }, { "name": "write_file" }])),
26990 &session,
26991 )
26992 .await
26993 .unwrap();
26994
26995 let dropped = handle_tools_unregister(&req(json!({ "name": "write_file" })), &session)
26996 .await
26997 .unwrap();
26998 assert_eq!(dropped["unregistered"], json!("write_file"));
26999 assert_eq!(dropped["removed"], json!(1));
27000
27001 let listed = handle_tools_list(&session).await.unwrap();
27002 assert_eq!(listed["count"], json!(2));
27003 assert_eq!(names(&listed), vec!["messaging.send", "read_file"]);
27004 }
27005
27006 #[tokio::test]
27010 async fn unregistering_an_unknown_tool_reports_zero_rather_than_erroring() {
27011 let session = session("c-tools-unregister-unknown").await;
27012
27013 let dropped =
27014 handle_tools_unregister(&req(json!({ "name": "never_registered" })), &session)
27015 .await
27016 .expect("unknown tool must not be an error");
27017 assert_eq!(dropped["unregistered"], json!("never_registered"));
27018 assert_eq!(dropped["removed"], json!(0));
27019
27020 assert_eq!(
27021 handle_tools_unregister(&req(json!({})), &session)
27022 .await
27023 .unwrap_err(),
27024 "missing 'name'"
27025 );
27026 }
27027
27028 #[tokio::test]
27031 async fn list_round_trips_the_full_tool_schema() {
27032 let session = session("c-tools-list-schema").await;
27033 handle_tools_register(
27034 &req(json!([{
27035 "name": "read_file",
27036 "description": "Read a UTF-8 file from disk",
27037 "parameters": {
27038 "type": "object",
27039 "properties": { "path": { "type": "string" } },
27040 "required": ["path"],
27041 },
27042 "idempotent": true,
27043 }])),
27044 &session,
27045 )
27046 .await
27047 .unwrap();
27048
27049 let listed = handle_tools_list(&session).await.unwrap();
27050 let tool = listed["tools"]
27051 .as_array()
27052 .unwrap()
27053 .iter()
27054 .find(|t| t["name"] == json!("read_file"))
27055 .expect("read_file must be listed");
27056 assert_eq!(tool["name"], json!("read_file"));
27057 assert_eq!(tool["source"], json!("user_defined"));
27058 assert_eq!(tool["description"], json!("Read a UTF-8 file from disk"));
27059 assert_eq!(tool["idempotent"], json!(true));
27060 assert_eq!(
27061 tool["parameters"],
27062 json!({
27063 "type": "object",
27064 "properties": { "path": { "type": "string" } },
27065 "required": ["path"],
27066 })
27067 );
27068 }
27069
27070 #[tokio::test]
27076 async fn registered_tool_cannot_claim_a_source_it_did_not_earn() {
27077 let session = session("c-tools-spoofed-source").await;
27078 handle_tools_register(
27079 &req(json!([{
27080 "name": "read_file",
27081 "description": "Claims to be a builtin",
27082 "source": "builtin",
27083 "parameters": {},
27084 }])),
27085 &session,
27086 )
27087 .await
27088 .expect("an unknown field must not fail registration");
27089
27090 let listed = handle_tools_list(&session).await.unwrap();
27091 let tool = listed["tools"]
27092 .as_array()
27093 .unwrap()
27094 .iter()
27095 .find(|t| t["name"] == json!("read_file"))
27096 .expect("read_file must be listed");
27097 assert_eq!(
27098 tool["source"],
27099 json!("user_defined"),
27100 "the runtime assigns user_defined; a client cannot claim builtin"
27101 );
27102 }
27103}
27104
27105#[cfg(test)]
27106mod tool_stream_surface {
27107 use super::{handle_tools_cancel, handle_tools_poll, JsonRpcMessage};
27111 use crate::session::{ServerState, WsChannel};
27112 use serde_json::{json, Value};
27113 use std::sync::Arc;
27114
27115 fn req(params: Value) -> JsonRpcMessage {
27116 JsonRpcMessage {
27117 jsonrpc: "2.0".into(),
27118 method: None,
27119 params,
27120 id: json!(1),
27121 result: None,
27122 error: None,
27123 }
27124 }
27125
27126 #[tokio::test]
27127 async fn poll_and_cancel_roundtrip() {
27128 let tmp = tempfile::TempDir::new().unwrap();
27129 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27130 let channel = Arc::new(WsChannel::test_stub());
27131 let session = state.create_session("c-tools-c2", channel).await.unwrap();
27132
27133 let poll = handle_tools_poll(&req(json!({"handle": "nope"})), &session)
27135 .await
27136 .unwrap();
27137 assert!(poll.is_null(), "unknown handle must poll to null");
27138
27139 assert!(handle_tools_poll(&req(json!({})), &session).await.is_err());
27141
27142 let (h, _tok) = session
27145 .runtime
27146 .tool_handles
27147 .register("tail_log", "a1")
27148 .await;
27149 session
27150 .runtime
27151 .tool_handles
27152 .push_chunk(
27153 &h.id,
27154 car_ir::ToolStreamChunk::Text {
27155 text: "line".into(),
27156 },
27157 )
27158 .await;
27159
27160 let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
27161 .await
27162 .unwrap();
27163 assert_eq!(poll["status"], "running");
27164 assert_eq!(poll["chunks"][0]["kind"], "text");
27165 assert_eq!(poll["chunks"][0]["text"], "line");
27166 assert_eq!(poll["tool"], "tail_log");
27167
27168 let cancel = handle_tools_cancel(&req(json!({"handle": h.id})), &session)
27170 .await
27171 .unwrap();
27172 assert_eq!(cancel["cancelled"], true);
27173 let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
27174 .await
27175 .unwrap();
27176 assert_eq!(poll["status"], "cancelled");
27177
27178 let cancel = handle_tools_cancel(&req(json!({"handle": "nope"})), &session)
27180 .await
27181 .unwrap();
27182 assert_eq!(cancel["cancelled"], false);
27183 }
27184}
27185
27186#[cfg(test)]
27187mod chat_cancel_surface {
27188 use super::{handle_agents_chat_cancel, remove_owned_external_chat_session, JsonRpcMessage};
27189 use crate::session::{ChatSession, ServerState};
27190 use serde_json::{json, Value};
27191 use std::sync::atomic::{AtomicBool, Ordering};
27192 use std::sync::Arc;
27193
27194 fn req(params: Value) -> JsonRpcMessage {
27195 JsonRpcMessage {
27196 jsonrpc: "2.0".into(),
27197 method: None,
27198 params,
27199 id: json!(1),
27200 result: None,
27201 error: None,
27202 }
27203 }
27204
27205 #[tokio::test]
27206 async fn cancel_sets_local_declarative_flag_and_drops_session() {
27207 let tmp = tempfile::TempDir::new().unwrap();
27208 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27209 let flag = Arc::new(AtomicBool::new(false));
27210 state.chat_sessions.lock().await.insert(
27211 "decl-chat".to_string(),
27212 ChatSession {
27213 agent_id: "writer".to_string(),
27214 host_client_id: "host-1".to_string(),
27215 created_at: 0,
27216 local_cancel: Some(flag.clone()),
27217 },
27218 );
27219
27220 let cancel = handle_agents_chat_cancel(
27221 &req(json!({"session_id": "decl-chat"})),
27222 &state,
27223 "host",
27224 true,
27225 )
27226 .await
27227 .unwrap();
27228 assert_eq!(cancel["cancelled"], true);
27229 assert!(flag.load(Ordering::SeqCst));
27230 assert!(!state.chat_sessions.lock().await.contains_key("decl-chat"));
27231
27232 let again = handle_agents_chat_cancel(
27233 &req(json!({"session_id": "decl-chat"})),
27234 &state,
27235 "host",
27236 true,
27237 )
27238 .await
27239 .unwrap();
27240 assert_eq!(again["cancelled"], false);
27241 }
27242
27243 #[tokio::test]
27244 async fn external_cleanup_preserves_proxied_chat_route() {
27245 let tmp = tempfile::TempDir::new().unwrap();
27246 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27247 let flag = Arc::new(AtomicBool::new(false));
27248 state.chat_sessions.lock().await.insert(
27249 "proxied-chat".to_string(),
27250 ChatSession {
27251 agent_id: "car-assistant".to_string(),
27252 host_client_id: "host-1".to_string(),
27253 created_at: 0,
27254 local_cancel: Some(flag),
27255 },
27256 );
27257
27258 remove_owned_external_chat_session(&state, "proxied-chat", false).await;
27259 assert!(state
27260 .chat_sessions
27261 .lock()
27262 .await
27263 .contains_key("proxied-chat"));
27264
27265 remove_owned_external_chat_session(&state, "proxied-chat", true).await;
27266 assert!(!state
27267 .chat_sessions
27268 .lock()
27269 .await
27270 .contains_key("proxied-chat"));
27271 }
27272}
27273
27274#[cfg(test)]
27275mod assistant_agent_alias_surface {
27276 use super::resolve_assistant_agent_alias;
27281 use crate::session::ServerState;
27282 use std::sync::Arc;
27283
27284 fn state() -> Arc<ServerState> {
27285 let tmp = tempfile::TempDir::new().unwrap();
27286 Arc::new(ServerState::standalone(tmp.path().to_path_buf()))
27287 }
27288
27289 #[tokio::test]
27290 async fn legacy_id_resolves_to_canonical_when_only_canonical_attached() {
27291 let state = state();
27292 state
27293 .attached_agents
27294 .lock()
27295 .await
27296 .insert("parslee-core".to_string(), "client-1".to_string());
27297
27298 assert_eq!(
27299 resolve_assistant_agent_alias("car-assistant".to_string(), &state).await,
27300 "parslee-core"
27301 );
27302 }
27303
27304 #[tokio::test]
27305 async fn canonical_id_resolves_to_legacy_when_only_legacy_attached() {
27306 let state = state();
27307 state
27308 .attached_agents
27309 .lock()
27310 .await
27311 .insert("car-assistant".to_string(), "client-1".to_string());
27312
27313 assert_eq!(
27314 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27315 "car-assistant"
27316 );
27317 }
27318
27319 #[tokio::test]
27320 async fn exact_match_is_never_rewritten() {
27321 let state = state();
27322 state
27323 .attached_agents
27324 .lock()
27325 .await
27326 .insert("parslee-core".to_string(), "client-1".to_string());
27327
27328 assert_eq!(
27329 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27330 "parslee-core"
27331 );
27332 }
27333
27334 #[tokio::test]
27335 async fn non_assistant_id_is_left_untouched_even_if_unattached() {
27336 let state = state();
27337 assert_eq!(
27338 resolve_assistant_agent_alias("some-other-agent".to_string(), &state).await,
27339 "some-other-agent"
27340 );
27341 }
27342
27343 #[tokio::test]
27344 async fn assistant_id_is_left_untouched_when_neither_spelling_attached() {
27345 let state = state();
27346 assert_eq!(
27347 resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27348 "parslee-core"
27349 );
27350 }
27351}
27352
27353#[cfg(test)]
27354mod chat_approve_surface {
27355 use super::{handle_agents_chat_approve, JsonRpcMessage};
27362 use crate::session::{ChatSession, ServerState};
27363 use serde_json::{json, Value};
27364 use std::sync::Arc;
27365
27366 fn req(params: Value) -> JsonRpcMessage {
27367 JsonRpcMessage {
27368 jsonrpc: "2.0".into(),
27369 method: None,
27370 params,
27371 id: json!(1),
27372 result: None,
27373 error: None,
27374 }
27375 }
27376
27377 #[tokio::test]
27378 async fn requires_session_and_approval_ids() {
27379 let tmp = tempfile::TempDir::new().unwrap();
27380 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27381
27382 let err =
27384 handle_agents_chat_approve(&req(json!({"approval_id": "a1"})), &state, "host-1", false)
27385 .await
27386 .unwrap_err();
27387 assert!(err.contains("session_id"), "{err}");
27388
27389 let err =
27391 handle_agents_chat_approve(&req(json!({"session_id": "s1"})), &state, "host-1", false)
27392 .await
27393 .unwrap_err();
27394 assert!(err.contains("approval_id"), "{err}");
27395 }
27396
27397 #[tokio::test]
27398 async fn unknown_session_is_rejected() {
27399 let tmp = tempfile::TempDir::new().unwrap();
27400 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27401 let err = handle_agents_chat_approve(
27402 &req(json!({"session_id": "ghost", "approval_id": "a1", "decision": true})),
27403 &state,
27404 "host-1",
27405 false,
27406 )
27407 .await
27408 .unwrap_err();
27409 assert!(err.contains("unknown or already-finished"), "{err}");
27410 }
27411
27412 #[test]
27416 fn a_bound_session_may_act_only_as_itself() {
27417 assert!(super::require_own_agent(Some("a"), "m", "a").is_ok());
27418 assert!(super::require_own_agent(Some("a"), "m", "b").is_err());
27419 assert!(super::require_own_agent(Some("a"), "m", "A").is_err());
27422 assert!(super::require_own_agent(Some("a"), "m", "a ").is_err());
27423 }
27424
27425 #[test]
27428 fn an_unbound_session_is_not_restricted_by_this_rule() {
27429 assert!(super::require_own_agent(None, "m", "a").is_ok());
27430 assert!(super::require_own_agent(None, "m", "anything-at-all").is_ok());
27431 }
27432
27433 #[test]
27437 fn the_refusal_says_which_call_was_refused_without_revealing_anything() {
27438 let err = super::require_own_agent(Some("a"), "agents.wait", "b").unwrap_err();
27439 assert!(err.contains("agents.wait"), "{err}");
27440 assert!(err.contains("may only act on itself"), "{err}");
27441 assert!(!err.contains(" "), "collapsed continuation: {err:?}");
27442 assert!(super::require_own_agent(Some("a"), "agents.wait", "a").is_ok());
27443 assert!(super::require_own_agent(None, "agents.wait", "b").is_ok());
27444 }
27445
27446 #[tokio::test]
27447 async fn caller_must_be_originating_host_or_host_management() {
27448 let tmp = tempfile::TempDir::new().unwrap();
27449 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27450 state.chat_sessions.lock().await.insert(
27451 "s1".to_string(),
27452 ChatSession {
27453 agent_id: "car-assistant".to_string(),
27454 host_client_id: "host-1".to_string(),
27455 created_at: 0,
27456 local_cancel: None,
27457 },
27458 );
27459
27460 let err = handle_agents_chat_approve(
27461 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27462 &state,
27463 "agent-client",
27464 false,
27465 )
27466 .await
27467 .unwrap_err();
27468 assert!(
27469 err.contains("originating host session or host-management role"),
27470 "{err}"
27471 );
27472 assert!(state.chat_sessions.lock().await.contains_key("s1"));
27473 }
27474
27475 #[tokio::test]
27476 async fn known_session_but_detached_agent_is_rejected() {
27477 let tmp = tempfile::TempDir::new().unwrap();
27478 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27479 state.chat_sessions.lock().await.insert(
27482 "s1".to_string(),
27483 ChatSession {
27484 agent_id: "car-assistant".to_string(),
27485 host_client_id: "host-1".to_string(),
27486 created_at: 0,
27487 local_cancel: None,
27488 },
27489 );
27490 let err = handle_agents_chat_approve(
27491 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27492 &state,
27493 "host-1",
27494 false,
27495 )
27496 .await
27497 .unwrap_err();
27498 assert!(err.contains("not attached"), "{err}");
27499 assert!(state.chat_sessions.lock().await.contains_key("s1"));
27501 }
27502
27503 #[tokio::test]
27504 async fn host_management_may_resolve_any_chat_approval() {
27505 let tmp = tempfile::TempDir::new().unwrap();
27506 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27507 state.chat_sessions.lock().await.insert(
27508 "s1".to_string(),
27509 ChatSession {
27510 agent_id: "car-assistant".to_string(),
27511 host_client_id: "host-1".to_string(),
27512 created_at: 0,
27513 local_cancel: None,
27514 },
27515 );
27516
27517 let err = handle_agents_chat_approve(
27518 &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27519 &state,
27520 "host-admin",
27521 true,
27522 )
27523 .await
27524 .unwrap_err();
27525 assert!(
27526 err.contains("not attached"),
27527 "host-management should pass authorization and fail only at routing: {err}"
27528 );
27529 }
27530}
27531
27532#[cfg(test)]
27533mod sync_lease_surface {
27534 use super::{
27541 acquire_lease, check_sync_fence, handle_lease_status, handle_sync_append,
27542 handle_sync_record_turn, handle_sync_resume, handle_sync_status, record_sync_intent,
27543 release_lease, JsonRpcMessage,
27544 };
27545 use crate::session::ServerState;
27546 use serde_json::{json, Value};
27547 use std::sync::Arc;
27548
27549 fn req(params: Value) -> JsonRpcMessage {
27550 JsonRpcMessage {
27551 jsonrpc: "2.0".into(),
27552 method: None,
27553 params,
27554 id: json!(1),
27555 result: None,
27556 error: None,
27557 }
27558 }
27559
27560 #[tokio::test]
27561 async fn sync_and_lease_dispatch_roundtrip() {
27562 let tmp = tempfile::TempDir::new().unwrap();
27563 let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27564
27565 let status = handle_sync_status(&state).await.unwrap();
27567 assert!(status["device_id"].as_str().unwrap().starts_with("device-"));
27568
27569 handle_sync_append(
27571 &req(json!({"surface": "knowledge", "payload": {"id": "f1", "body": "sky"}})),
27572 &state,
27573 )
27574 .await
27575 .unwrap();
27576 handle_sync_record_turn(
27577 &req(json!({"conversation_id": "c1", "role": "user", "content": "hi", "timestamp": 1})),
27578 &state,
27579 )
27580 .await
27581 .unwrap();
27582
27583 let resume = handle_sync_resume(&req(json!({"conversation_id": "c1"})), &state)
27585 .await
27586 .unwrap();
27587 let msgs = resume.as_array().unwrap();
27588 assert_eq!(msgs.len(), 1);
27589 assert_eq!(msgs[0]["role"], json!("user"));
27590
27591 let lease = acquire_lease(
27596 &req(json!({"agent_id": "milo", "ttl_ms": 1_000_000})),
27597 &state,
27598 "milo",
27599 )
27600 .await
27601 .unwrap();
27602 assert_eq!(lease["epoch"], json!(1));
27603 let ls = handle_lease_status(&req(json!({"agent_id": "milo"})), &state, Some("milo"))
27604 .await
27605 .unwrap();
27606 assert_eq!(ls["lease"]["holder"], lease["holder"]);
27607
27608 let fence = check_sync_fence(
27610 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
27611 &state,
27612 "milo",
27613 )
27614 .await
27615 .unwrap();
27616 assert_eq!(fence["may_dispatch"], json!(true));
27617
27618 record_sync_intent(
27620 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1, "status": "committed"})),
27621 &state,
27622 "milo",
27623 )
27624 .await
27625 .unwrap();
27626 let fence = check_sync_fence(
27627 &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
27628 &state,
27629 "milo",
27630 )
27631 .await
27632 .unwrap();
27633 assert_eq!(fence["decision"]["decision"], json!("already_committed"));
27634 assert_eq!(fence["may_dispatch"], json!(false));
27635
27636 release_lease(
27638 &req(json!({"agent_id": "milo", "epoch": 1})),
27639 &state,
27640 "milo",
27641 )
27642 .await
27643 .unwrap();
27644 assert!(release_lease(
27645 &req(json!({"agent_id": "milo", "epoch": 1})),
27646 &state,
27647 "milo"
27648 )
27649 .await
27650 .is_err());
27651 }
27652}
27653
27654#[cfg(test)]
27655mod merged_knowledge_tests {
27656 use super::merge_knowledge;
27657 use serde_json::json;
27658
27659 #[test]
27660 fn org_none_leaves_personal_knowledge_unchanged() {
27661 let personal = vec![json!({"subject": "a", "body": "1"})];
27663 assert_eq!(merge_knowledge(personal.clone(), None), personal);
27664 }
27665
27666 fn reduce_last_wins(
27671 entries: &[serde_json::Value],
27672 ) -> std::collections::HashMap<String, String> {
27673 let mut out = std::collections::HashMap::new();
27674 for e in entries {
27675 let subject = e["subject"].as_str().unwrap().to_string();
27676 let body = e["body"].as_str().unwrap().to_string();
27677 out.insert(subject, body); }
27679 out
27680 }
27681
27682 #[test]
27683 fn shared_subject_resolves_to_personal_not_org() {
27684 let personal = vec![json!({"subject": "shared", "body": "personal-newer"})];
27689 let org = vec![
27690 json!({"subject": "org-only", "body": "from-org"}),
27691 json!({"subject": "shared", "body": "org-staler"}),
27692 ];
27693 let reduced = reduce_last_wins(&merge_knowledge(personal, Some(org)));
27694 assert_eq!(
27695 reduced.get("shared").map(String::as_str),
27696 Some("personal-newer"),
27697 "personal must win a shared subject — a staler org fact must NOT shadow it"
27698 );
27699 assert_eq!(
27700 reduced.get("org-only").map(String::as_str),
27701 Some("from-org"),
27702 "an org-only subject is still surfaced"
27703 );
27704 }
27705
27706 #[test]
27707 fn within_scope_newest_is_preserved() {
27708 let personal = vec![
27710 json!({"subject": "a", "body": "old"}),
27711 json!({"subject": "a", "body": "new"}),
27712 ];
27713 let reduced = reduce_last_wins(&merge_knowledge(personal, None));
27714 assert_eq!(reduced.get("a").map(String::as_str), Some("new"));
27715 }
27716}
27717
27718#[cfg(test)]
27724mod skill_adopt_pack_tests {
27725 use super::resolve_adopt_provenance;
27726 use car_bundle::{sign_manifest, AgentIdentity, AgentManifest, PublisherInfo, TransportSpec};
27727 use ed25519_dalek::SigningKey;
27728 use serde_json::json;
27729
27730 fn signed_manifest(id: &str) -> (AgentManifest, String) {
27735 let mut m = AgentManifest {
27736 agent: AgentIdentity {
27737 id: id.into(),
27738 name: id.into(),
27739 namespace: Some("parslee".into()),
27740 version: Some("1.0.0".into()),
27741 description: None,
27742 license: None,
27743 homepage: None,
27744 },
27745 publisher: None,
27746 runtime: None,
27747 lifecycle: None,
27748 transport: TransportSpec::PureData,
27749 capabilities: None,
27750 };
27751 let key = SigningKey::from_bytes(&[7u8; 32]);
27752 sign_manifest(&mut m, &key).expect("sign");
27753 let key_id = m
27754 .publisher
27755 .as_ref()
27756 .and_then(|p: &PublisherInfo| p.key_id.clone())
27757 .expect("key_id present after signing");
27758 (m, key_id)
27759 }
27760
27761 #[test]
27762 fn no_manifest_no_provenance_defaults_to_unsigned_and_untrusted() {
27763 let params = json!({ "scanned": true, "vulnerabilities": 2, "source": "community" });
27764 let p = resolve_adopt_provenance(¶ms, &[]).expect("resolves");
27765 assert!(!p.signed, "nothing signed it, so it is not signed");
27766 assert!(!p.signer_trusted);
27767 assert!(p.scanned);
27770 assert_eq!(p.vulnerabilities, 2);
27771 assert_eq!(p.source, car_policy::skill_trust::SkillSource::Community);
27772 }
27773
27774 #[test]
27775 fn manifest_signed_by_a_keyring_signer_is_signed_and_trusted() {
27776 let (m, key_id) = signed_manifest("pack-agent");
27777 let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
27778 let p = resolve_adopt_provenance(¶ms, &[key_id]).expect("resolves");
27779 assert!(p.signed);
27780 assert!(p.signer_trusted, "key_id is in the operator keyring");
27781 }
27782
27783 #[test]
27784 fn manifest_signed_by_an_unknown_signer_is_signed_but_not_trusted() {
27785 let (m, _key_id) = signed_manifest("pack-agent");
27786 let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
27787 let p = resolve_adopt_provenance(¶ms, &[]).expect("resolves");
27788 assert!(p.signed, "the signature itself still verifies");
27789 assert!(
27790 !p.signer_trusted,
27791 "an empty keyring trusts no signer, which costs this pack Official \
27792 (and only Official) — signed + scanned still reaches Verified"
27793 );
27794 }
27795
27796 #[test]
27797 fn manifest_and_provenance_together_is_an_error_not_a_precedence_choice() {
27798 let (m, _) = signed_manifest("pack-agent");
27799 let params = json!({
27800 "manifest": serde_json::to_value(&m).unwrap(),
27801 "provenance": { "signed": true, "signer_trusted": true },
27802 });
27803 let err = resolve_adopt_provenance(¶ms, &[]).expect_err("must refuse");
27804 assert!(err.contains("not both"), "unexpected message: {err}");
27805 }
27806
27807 #[test]
27808 fn unrecognised_source_is_rejected_rather_than_silently_defaulted() {
27809 let params = json!({ "source": "bogus" });
27810 let err = resolve_adopt_provenance(¶ms, &[]).expect_err("must refuse");
27811 assert!(err.contains("invalid source"), "unexpected message: {err}");
27812 }
27813}
27814
27815#[cfg(test)]
27816mod approval_requester_tests {
27817 use super::stamped_requester;
27818
27819 #[test]
27823 fn a_bound_session_cannot_claim_to_be_another_agent() {
27824 assert_eq!(
27825 stamped_requester(Some("agent-7".into()), Some("agent-9".into())),
27826 Some("agent-7".into())
27827 );
27828 }
27829
27830 #[test]
27834 fn a_bound_session_cannot_launder_by_omitting_the_field() {
27835 assert_eq!(
27836 stamped_requester(Some("agent-7".into()), None),
27837 Some("agent-7".into())
27838 );
27839 }
27840
27841 #[test]
27844 fn an_unbound_host_client_keeps_its_claim() {
27845 assert_eq!(
27846 stamped_requester(None, Some("agent-7".into())),
27847 Some("agent-7".into())
27848 );
27849 assert_eq!(stamped_requester(None, None), None);
27850 }
27851}