1use car_engine::{builtin_tool_labels, format_tool_result, tool_output_is_external, Runtime};
14use car_inference::tasks::generate::{ContentBlock, Message, Provenance, ToolCall};
15use car_inference::{GenerateParams, GenerateRequest, InferenceResult};
16use car_ir::{ActionProposal, ActionStatus};
17use serde_json::{json, Value};
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22use super::memory::MemoryTools;
23use super::tool_memory::{approach_from_call, FailureSignature, ToolMemory, RECOVERY_WINDOW_TURNS};
24use crate::coder::native_loop::{AssistantGenerateError, TurnGenerator};
25
26pub const OBSERVATION_CAP: usize = 16 * 1024;
37
38pub const VALUE_STORE_PREVIEWS_DEFAULT: bool = false;
76
77pub enum AssistantEvent {
81 InferenceStarted {
84 model: String,
85 attempt: u32,
86 turn: u32,
87 },
88 InferenceRetry {
90 model: String,
91 attempt: u32,
92 reason: String,
93 backoff_ms: u64,
94 },
95 ModelServed {
99 model_id: String,
100 local_last_resort: bool,
101 },
102 Text(String),
104 ToolCall {
108 call_id: String,
109 sequence: u32,
110 name: String,
111 params: Value,
112 },
113 ToolResult {
115 call_id: String,
116 sequence: u32,
117 name: String,
118 ok: bool,
119 content: String,
123 },
124 Done { text: String },
126 Error(String),
128 AuthRequired {
138 reason: AuthRequiredReason,
139 message: String,
141 },
142 GoalEvaluated {
146 iteration: u32,
147 met: bool,
148 grounded: bool,
149 reason: String,
150 },
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum AuthRequiredReason {
159 SignedOut,
161 Expired,
168 NoWorkspace,
170}
171
172impl AuthRequiredReason {
173 pub fn as_str(self) -> &'static str {
175 match self {
176 Self::SignedOut => "signed_out",
177 Self::Expired => "expired",
178 Self::NoWorkspace => "no_workspace",
179 }
180 }
181
182 pub fn remedy(self) -> &'static str {
189 match self {
190 Self::SignedOut => AUTH_REQUIRED_SIGNED_OUT_MESSAGE,
191 Self::Expired => AUTH_REQUIRED_EXPIRED_MESSAGE,
192 Self::NoWorkspace => AUTH_REQUIRED_NO_WORKSPACE_MESSAGE,
193 }
194 }
195}
196
197pub const AUTH_REQUIRED_SIGNED_OUT_MESSAGE: &str =
206 "Parslee Core runs on your Parslee account. Sign in to continue. New to Parslee? \
207 Create your account at parslee.ai first, then come back and sign in.";
208
209pub const AUTH_REQUIRED_EXPIRED_MESSAGE: &str =
212 "Your Parslee sign-in has expired. Sign in again to continue.";
213
214pub const AUTH_REQUIRED_NO_WORKSPACE_MESSAGE: &str =
216 "Your Parslee account has no workspace yet. Finish setting up at parslee.ai, then try again.";
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238#[serde(tag = "cause", rename_all = "snake_case")]
239pub enum AssistantFailureCause {
240 TransientInference { status: Option<u16> },
242 Inference,
244}
245
246impl AssistantFailureCause {
247 pub fn label(self) -> &'static str {
249 match self {
250 Self::TransientInference { .. } => "transient inference failure",
251 Self::Inference => "inference failure",
252 }
253 }
254}
255
256fn generation_failure_cause(error: &AssistantGenerateError) -> AssistantFailureCause {
257 match error {
258 AssistantGenerateError::Transient { status, .. } => {
259 AssistantFailureCause::TransientInference { status: *status }
260 }
261 AssistantGenerateError::CredentialUnavailable { .. }
262 | AssistantGenerateError::WorkspaceRequired { .. }
263 | AssistantGenerateError::Other(_) => AssistantFailureCause::Inference,
264 }
265}
266
267fn auth_required_reason(error: &AssistantGenerateError) -> Option<AuthRequiredReason> {
268 fn is_parslee(provider: &str) -> bool {
269 provider.eq_ignore_ascii_case("parslee")
270 }
271 match error {
272 AssistantGenerateError::CredentialUnavailable {
273 provider, reason, ..
274 } if is_parslee(provider) => match reason {
275 car_inference::CredentialFailure::SignedOut => Some(AuthRequiredReason::SignedOut),
276 car_inference::CredentialFailure::Expired { .. } => Some(AuthRequiredReason::Expired),
277 car_inference::CredentialFailure::StoreUnreadable
278 | car_inference::CredentialFailure::EnvVarMissing { .. }
279 | car_inference::CredentialFailure::RaceRetryable => None,
280 },
281 AssistantGenerateError::WorkspaceRequired { provider, .. } if is_parslee(provider) => {
282 Some(AuthRequiredReason::NoWorkspace)
283 }
284 _ => None,
285 }
286}
287
288#[derive(Clone)]
290pub struct AssistantConfig {
291 pub model: Option<String>,
294 pub strict_model: bool,
297 pub max_turns: u32,
299 pub tools: Vec<Value>,
301 pub gated_tools: Vec<String>,
305 pub approval_policy: Option<ApprovalPolicyFn>,
311 pub proactive_memory: Option<Arc<MemoryTools>>,
316 pub tool_memory: Option<Arc<ToolMemory>>,
326 pub tool_labels: Option<HashMap<String, car_verify::infoflow::ToolLabels>>,
337 pub todos: Option<Arc<tokio::sync::Mutex<super::todo::TodoList>>>,
340 pub value_store_previews: bool,
360 pub response_format: Option<car_inference::ResponseFormat>,
377 pub context_window_override: Option<usize>,
386 pub refuse_unadvertised_tools: bool,
396 pub response_format_validator: Option<ResponseFormatValidator>,
404 pub delegate_budget: Option<DelegateBudget>,
409}
410
411pub type ResponseFormatValidator = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub struct DelegateBudget {
420 pub max_delegations: u32,
422 pub max_child_turns: u32,
424}
425
426impl Default for DelegateBudget {
427 fn default() -> Self {
428 Self {
429 max_delegations: 20,
430 max_child_turns: 300,
431 }
432 }
433}
434
435pub fn resolve_context_window(
444 override_tokens: Option<usize>,
445 registry_window: usize,
446) -> (usize, Option<String>) {
447 match override_tokens {
448 None => (registry_window, None),
449 Some(requested) if registry_window > 0 && requested > registry_window => (
450 registry_window,
451 Some(format!(
452 "context window override {requested} exceeds the model's known window \
453 {registry_window}; using {registry_window} (a larger value would let the \
454 history overflow the real window and truncate the task provider-side, \
455 which is what compaction exists to prevent)"
456 )),
457 ),
458 Some(requested) => (requested, None),
459 }
460}
461
462pub fn final_text_matches_format(
471 text: &str,
472 format: &car_inference::ResponseFormat,
473 validator: Option<&ResponseFormatValidator>,
474) -> bool {
475 let payload = extract_json_payload(text);
476 match format {
477 car_inference::ResponseFormat::JsonObject => {
478 serde_json::from_str::<Value>(payload).is_ok_and(|v| v.is_object())
479 }
480 car_inference::ResponseFormat::JsonSchema { .. } => {
481 match serde_json::from_str::<Value>(payload) {
482 Ok(v) => validator.is_none_or(|is_valid| is_valid(&v)),
485 Err(_) => false,
486 }
487 }
488 }
489}
490
491pub fn extract_json_payload(text: &str) -> &str {
494 let t = text.trim();
495 let Some(rest) = t.strip_prefix("```") else {
496 return t;
497 };
498 let Some(rest) = rest.strip_suffix("```") else {
499 return t;
500 };
501 let rest = match rest.split_once('\n') {
503 Some((tag, body)) if tag.trim().chars().all(|c| c.is_ascii_alphanumeric()) => body,
504 _ => rest,
505 };
506 rest.trim()
507}
508
509const FORMAT_REPAIR_NUDGE: &str =
514 "Your previous answer was not the requested JSON. Return only the JSON object — \
515 no prose, no code fence, no tool calls.";
516const FORMAT_REPAIR_NUDGE_SCHEMA: &str =
519 "Your previous answer did not match the required JSON Schema. Return only JSON that \
520 conforms to the schema — no prose, no code fence, no tool calls.";
521
522fn format_repair_nudge(format: &car_inference::ResponseFormat) -> &'static str {
524 match format {
525 car_inference::ResponseFormat::JsonObject => FORMAT_REPAIR_NUDGE,
526 car_inference::ResponseFormat::JsonSchema { .. } => FORMAT_REPAIR_NUDGE_SCHEMA,
527 }
528}
529
530pub const FORMAT_REPAIR_NOTICE: &str =
534 "[format repair: final answer was not the requested JSON; re-asked the model once with no tools]";
535pub const FORMAT_REPAIR_STILL_INVALID: &str =
538 "[format repair: the repaired answer still does not match the requested format; returning it as-is]";
539pub const FORMAT_REPAIR_FAILED_PREFIX: &str = "[format repair failed:";
544
545pub const DELEGATE_TOOL: &str = "delegate";
551pub const DELEGATE_DEFAULT_MAX_TURNS: u32 = 25;
553pub const DELEGATE_MAX_TURNS_CAP: u32 = 60;
555
556fn delegable_tool_names(tools: &[Value]) -> Vec<String> {
559 tools
560 .iter()
561 .filter_map(|d| d.get("name").and_then(Value::as_str))
562 .filter(|n| *n != DELEGATE_TOOL)
563 .map(str::to_string)
564 .collect()
565}
566
567pub fn delegate_tool_def(parent_tools: &[Value]) -> Value {
578 let names = delegable_tool_names(parent_tools);
579 json!({
580 "name": DELEGATE_TOOL,
581 "tier": "read_only",
582 "mutating": true,
583 "description": "Hand one self-contained sub-task to a fresh sub-agent that shares \
584 your model, permissions, and working directory but starts with an EMPTY \
585 transcript: it sees only the goal you write, not this conversation. Use it \
586 to keep a long exploration or a noisy batch of tool output out of your own \
587 context. It runs to completion before this call returns and you receive \
588 ONLY its final written answer, so put everything it needs in `goal` and \
589 ask it to report exactly what you need back. It cannot delegate further.",
590 "parameters": {
591 "type": "object",
592 "properties": {
593 "goal": {
594 "type": "string",
595 "description": "The single, self-contained task, with all the context the sub-agent needs and what to report back."
596 },
597 "tools": {
598 "type": "array",
599 "items": { "type": "string", "enum": names },
600 "description": "Tools to grant the sub-agent. Must be a subset of your own; omit for all of them."
601 },
602 "max_turns": {
603 "type": "integer",
604 "minimum": 1,
605 "maximum": DELEGATE_MAX_TURNS_CAP,
606 "description": "Turn budget for the sub-agent (default 25). It reports an error if it runs out."
607 }
608 },
609 "required": ["goal"]
610 }
611 })
612}
613
614#[derive(Debug, Clone, PartialEq)]
616pub struct DelegateRequest {
617 pub goal: String,
618 pub tools: Option<Vec<String>>,
620 pub max_turns: u32,
621}
622
623pub fn parse_delegate_params(params: &Value) -> Result<DelegateRequest, String> {
626 let goal = params
627 .get("goal")
628 .and_then(Value::as_str)
629 .map(str::trim)
630 .filter(|g| !g.is_empty())
631 .ok_or("delegate needs a non-empty `goal` string")?
632 .to_string();
633 let tools = match params.get("tools") {
634 None | Some(Value::Null) => None,
635 Some(Value::Array(items)) => Some(
636 items
637 .iter()
638 .map(|v| {
639 v.as_str().map(str::to_string).ok_or_else(|| {
640 "delegate `tools` must be an array of tool names".to_string()
641 })
642 })
643 .collect::<Result<Vec<_>, _>>()?,
644 ),
645 Some(_) => return Err("delegate `tools` must be an array of tool names".into()),
646 };
647 let max_turns = match params.get("max_turns") {
648 None | Some(Value::Null) => DELEGATE_DEFAULT_MAX_TURNS,
649 Some(v) => {
650 let n = v
651 .as_u64()
652 .filter(|n| *n >= 1)
653 .ok_or("delegate `max_turns` must be a positive integer")?;
654 (n.min(DELEGATE_MAX_TURNS_CAP as u64)) as u32
655 }
656 };
657 Ok(DelegateRequest {
658 goal,
659 tools,
660 max_turns,
661 })
662}
663
664pub fn delegate_child_config(
679 parent: &AssistantConfig,
680 req: &DelegateRequest,
681) -> Result<AssistantConfig, String> {
682 let delegable = delegable_tool_names(&parent.tools);
683 let requested: Vec<String> = match &req.tools {
684 Some(list) => list.clone(),
685 None => delegable.clone(),
686 };
687 let escalations: Vec<&String> = requested
688 .iter()
689 .filter(|t| !delegable.iter().any(|d| d == *t))
690 .collect();
691 if !escalations.is_empty() {
692 let nested = escalations.iter().any(|t| *t == DELEGATE_TOOL);
693 return Err(format!(
694 "privilege escalation rejected: sub-agent tools {escalations:?} are not a subset of \
695 your own tools{}",
696 if nested {
697 " (a sub-agent cannot delegate further)"
698 } else {
699 ""
700 }
701 ));
702 }
703 let tools: Vec<Value> = parent
704 .tools
705 .iter()
706 .filter(|d| {
707 d.get("name")
708 .and_then(Value::as_str)
709 .is_some_and(|n| requested.iter().any(|r| r == n))
710 })
711 .cloned()
712 .collect();
713 Ok(AssistantConfig {
714 tools,
715 refuse_unadvertised_tools: true,
716 response_format_validator: None,
717 delegate_budget: None,
718 max_turns: req.max_turns,
719 todos: None,
720 response_format: None,
721 ..parent.clone()
722 })
723}
724
725fn delegate_child_history(parent_messages: &[Message], goal: &str) -> Vec<Message> {
728 let mut history: Vec<Message> = parent_messages
729 .iter()
730 .take_while(|m| matches!(m, Message::System { .. }))
731 .cloned()
732 .collect();
733 history.push(Message::User {
734 content: goal.to_string(),
735 });
736 history
737}
738
739struct DelegateOutcome {
741 ok: bool,
742 content: String,
745 turns: u32,
746 external: bool,
749 receipts: Vec<AssistantToolReceipt>,
751 spawned: bool,
754}
755
756#[allow(clippy::too_many_arguments)]
764fn run_delegate<'a>(
765 generator: &'a dyn TurnGenerator,
766 runtime: &'a Runtime,
767 parent: &'a AssistantConfig,
768 parent_messages: &'a [Message],
769 params: &'a Value,
770 cancel: &'a std::sync::atomic::AtomicBool,
771 approval: Option<&'a dyn ApprovalGate>,
772 runtime_session_id: Option<&'a str>,
773 redrive_ungrounded_summary: bool,
774 tool_labels: &'a HashMap<String, car_verify::infoflow::ToolLabels>,
775) -> std::pin::Pin<Box<dyn std::future::Future<Output = DelegateOutcome> + Send + 'a>> {
776 Box::pin(async move {
777 let req = match parse_delegate_params(params) {
778 Ok(r) => r,
779 Err(e) => {
780 return DelegateOutcome {
781 ok: false,
782 content: cap(json!({ "error": e }).to_string()),
783 turns: 0,
784 external: false,
785 receipts: Vec::new(),
786 spawned: false,
787 }
788 }
789 };
790 let child_cfg = match delegate_child_config(parent, &req) {
791 Ok(c) => c,
792 Err(e) => {
793 return DelegateOutcome {
794 ok: false,
795 content: cap(json!({ "error": e }).to_string()),
796 turns: 0,
797 external: false,
798 receipts: Vec::new(),
799 spawned: false,
800 }
801 }
802 };
803 let mut child_messages = delegate_child_history(parent_messages, &req.goal);
804 let mut child_emit: &mut (dyn FnMut(AssistantEvent) + Send) = &mut |_| {};
810 let child = run_assistant_loop_cancellable_in_session_durable(
811 generator,
812 runtime,
813 &child_cfg,
814 &mut child_messages,
815 cancel,
816 approval,
817 None,
818 runtime_session_id,
819 None,
820 None,
821 redrive_ungrounded_summary,
822 &mut child_emit,
823 )
824 .await;
825 let external = child
826 .tool_receipts
827 .iter()
828 .any(|r| tool_output_is_external(&r.tool, tool_labels));
829 if child.status == "success" {
830 DelegateOutcome {
831 ok: true,
832 content: cap(child.summary),
833 turns: child.turns,
834 external,
835 receipts: child.tool_receipts,
836 spawned: true,
837 }
838 } else {
839 DelegateOutcome {
843 ok: false,
844 content: cap(json!({
845 "error": format!(
846 "delegate did not finish (status: {}) after {} turns: {}",
847 child.status, child.turns, child.summary
848 )
849 })
850 .to_string()),
851 turns: child.turns,
852 external,
853 receipts: child.tool_receipts,
854 spawned: true,
855 }
856 }
857 })
858}
859
860pub type ApprovalPolicyFn =
864 std::sync::Arc<dyn Fn(&str, &Value) -> ToolApprovalDecision + Send + Sync>;
865
866pub enum ToolApprovalDecision {
868 Allow,
870 RequireApproval,
872 Deny(String),
874}
875
876pub enum ApprovalDecision {
878 Approved,
879 Denied(String),
880}
881
882#[async_trait::async_trait]
887pub trait ApprovalGate: Send + Sync {
888 async fn request(&self, tool: &str, params: &Value) -> ApprovalDecision;
889
890 async fn request_action(&self, _call_id: &str, tool: &str, params: &Value) -> ApprovalDecision {
891 self.request(tool, params).await
892 }
893
894 async fn before_dispatch(
897 &self,
898 _call_id: &str,
899 _tool: &str,
900 _params: &Value,
901 ) -> Result<(), String> {
902 Ok(())
903 }
904
905 async fn after_dispatch(
908 &self,
909 _call_id: &str,
910 _tool: &str,
911 _params: &Value,
912 _ok: bool,
913 _receipt: &Value,
914 ) -> Result<(), String> {
915 Ok(())
916 }
917}
918
919#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
921pub struct AssistantModelAttribution {
922 pub model_id: String,
924 pub local_last_resort: bool,
926}
927
928pub struct AssistantOutcome {
929 pub status: &'static str,
942 pub summary: String,
944 pub turns: u32,
946 pub turns_completed: u32,
949 pub tools_called: Vec<String>,
951 pub tool_receipts: Vec<AssistantToolReceipt>,
957 pub prior_receipts: usize,
967 pub models_served: Vec<AssistantModelAttribution>,
971 pub model_used: String,
976 pub auth_required: Option<AuthRequiredReason>,
980 pub failure_cause: Option<AssistantFailureCause>,
984}
985
986impl AssistantOutcome {
987 pub fn run_receipts(&self) -> &[AssistantToolReceipt] {
990 let start = self.prior_receipts.min(self.tool_receipts.len());
991 &self.tool_receipts[start..]
992 }
993}
994
995#[derive(Clone, Debug)]
996pub struct AssistantToolReceipt {
997 pub tool: String,
998 pub call_id: Option<String>,
1004 pub sequence: Option<u32>,
1007 pub ok: bool,
1008 pub params: Value,
1009 pub result: Option<String>,
1013 pub via: Option<String>,
1018}
1019
1020fn transcript_tool_receipts(messages: &[Message]) -> Vec<AssistantToolReceipt> {
1021 let mut calls: std::collections::HashMap<
1022 String,
1023 std::collections::VecDeque<(String, Value, String, u32)>,
1024 > = std::collections::HashMap::new();
1025 let mut receipts = Vec::new();
1026 let mut turn = 0u32;
1027 for message in messages {
1028 match message {
1029 Message::Assistant { tool_calls, .. } => {
1030 turn = turn.saturating_add(1);
1031 for (index, call) in tool_calls.iter().enumerate() {
1032 if let Some(id) = call.id.as_deref() {
1033 let sequence = u32::try_from(index + 1).unwrap_or(u32::MAX);
1034 calls.entry(id.to_string()).or_default().push_back((
1035 call.name.clone(),
1036 serde_json::to_value(&call.arguments)
1037 .unwrap_or_else(|_| Value::Object(Default::default())),
1038 format!("turn_{turn}_call_{sequence}"),
1039 sequence,
1040 ));
1041 }
1042 }
1043 }
1044 Message::ToolResult {
1045 tool_use_id,
1046 content,
1047 ..
1048 } => {
1049 let Some((tool, params, call_id, sequence)) = calls
1050 .get_mut(tool_use_id)
1051 .and_then(std::collections::VecDeque::pop_front)
1052 else {
1053 continue;
1054 };
1055 let parsed = serde_json::from_str::<Value>(content).ok();
1056 let ok = parsed
1057 .as_ref()
1058 .map(|value| {
1059 value.get("error").is_none()
1060 && value.get("ok").and_then(Value::as_bool) != Some(false)
1061 && value.get("status").and_then(Value::as_str) != Some("Failed")
1062 })
1063 .unwrap_or_else(|| {
1064 let lower = content.to_ascii_lowercase();
1065 !lower.contains("declined by user")
1066 && !lower.contains("tool call denied")
1067 && !lower.starts_with("error:")
1068 });
1069 receipts.push(AssistantToolReceipt {
1070 tool,
1071 call_id: Some(call_id),
1072 sequence: Some(sequence),
1073 ok,
1074 params,
1075 result: Some(content.clone()),
1076 via: None,
1077 });
1078 }
1079 _ => {}
1080 }
1081 }
1082 receipts
1083}
1084
1085fn transcript_turn_offset(messages: &[Message]) -> u32 {
1101 let assistant_turns = messages
1102 .iter()
1103 .filter(|message| matches!(message, Message::Assistant { .. }))
1104 .count();
1105 let compacted_away: usize = messages
1108 .iter()
1109 .filter_map(parse_compaction_notice)
1110 .map(|(dropped, _tokens)| dropped)
1111 .sum();
1112 eprintln!(
1113 "DEBUG2 assistant={assistant_turns} compacted={compacted_away} len={}",
1114 messages.len()
1115 );
1116 u32::try_from(assistant_turns.saturating_add(compacted_away)).unwrap_or(u32::MAX)
1117}
1118
1119fn cap(mut s: String) -> String {
1133 let total = s.len();
1134 if total <= OBSERVATION_CAP {
1135 return s;
1136 }
1137 let mut end = OBSERVATION_CAP;
1138 while !s.is_char_boundary(end) {
1139 end -= 1;
1140 }
1141 let elided = total - end;
1142 s.truncate(end);
1143 s.push_str(&format!(
1146 "\n…[truncated: showing first {end} of {total} bytes; {elided} bytes elided \
1147 and NOT retained. To see the rest, re-run this tool with a narrower \
1148 query — the elided bytes cannot be recovered by asking for them.]…"
1149 ));
1150 s
1151}
1152
1153const STATE_BLOCK_OPEN: &str = "\n\n<runtime-state>\n";
1158const STATE_BLOCK_CLOSE: &str = "\n</runtime-state>";
1159
1160fn append_state_block(messages: &mut [Message], block: &str) {
1175 let Some(last) = messages.last_mut() else {
1176 return;
1177 };
1178 let fenced = format!("{STATE_BLOCK_OPEN}{block}{STATE_BLOCK_CLOSE}");
1179 match last {
1180 Message::System { content }
1181 | Message::User { content }
1182 | Message::Assistant { content, .. }
1183 | Message::ToolResult { content, .. } => content.push_str(&fenced),
1184 _ => {}
1187 }
1188}
1189
1190const STATE_BLOCK_MAX_FACTS: usize = 5;
1193
1194fn recent_fact_subjects(receipts: &[AssistantToolReceipt]) -> Vec<String> {
1202 let mut subjects: Vec<String> = Vec::new();
1203 for receipt in receipts.iter().filter(|r| r.ok && r.tool == "remember") {
1204 let Some(subject) = receipt.params.get("subject").and_then(Value::as_str) else {
1205 continue;
1206 };
1207 let subject = subject.trim();
1208 if subject.is_empty() {
1209 continue;
1210 }
1211 subjects.retain(|s| s != subject);
1215 subjects.push(subject.to_string());
1216 }
1217 subjects
1218}
1219
1220fn render_state_block(todo: Option<String>, facts: &[String]) -> Option<String> {
1230 let mut sections: Vec<String> = Vec::new();
1231 if let Some(todo) = todo {
1232 sections.push(todo);
1233 }
1234 if !facts.is_empty() {
1235 let hidden = facts.len().saturating_sub(STATE_BLOCK_MAX_FACTS);
1238 let listed = facts
1239 .iter()
1240 .skip(hidden)
1241 .map(String::as_str)
1242 .collect::<Vec<_>>()
1243 .join(", ");
1244 let mut line = format!("remembered this run: {listed}");
1245 if hidden > 0 {
1246 line.push_str(&format!(" (+{hidden} earlier)"));
1247 }
1248 line.push_str("\n subjects only — call `recall` for the content");
1249 sections.push(line);
1250 }
1251 (!sections.is_empty()).then(|| sections.join("\n"))
1252}
1253
1254const HISTORY_MIN_TAIL: usize = 6;
1257
1258pub(crate) const HISTORY_BUDGET_NUMERATOR: usize = 3;
1274pub(crate) const HISTORY_BUDGET_DENOMINATOR: usize = 4;
1276
1277pub(crate) fn history_budget(context_window: usize) -> usize {
1281 context_window / HISTORY_BUDGET_DENOMINATOR * HISTORY_BUDGET_NUMERATOR
1282}
1283
1284const COMPACTION_NOTICE_PREFIX: &str = "[history compacted:";
1290
1291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1302pub(crate) enum CompactionRecovery {
1303 #[default]
1306 EventsQuery,
1307 Unrecoverable,
1309}
1310
1311fn format_compaction_notice(turns: usize, tokens: usize, recovery: CompactionRecovery) -> String {
1320 match recovery {
1321 CompactionRecovery::EventsQuery => format!(
1322 "{COMPACTION_NOTICE_PREFIX} {turns} earlier turns removed to fit the context \
1323 window, ~{tokens} tokens. They are gone from this transcript but the run's \
1324 event log still has them — call `events_query` (e.g. {{\"kinds\": \
1325 [\"action_failed\"], \"limit\": 5}}) to see what was already tried, rather \
1326 than assuming you never tried it.]"
1327 ),
1328 CompactionRecovery::Unrecoverable => format!(
1329 "{COMPACTION_NOTICE_PREFIX} {turns} earlier turns dropped to fit the model's \
1330 context window, ~{tokens} tokens. They are not recoverable in this run — \
1331 work from what is still in this transcript, and do not assume something was \
1332 never tried just because you cannot see it.]"
1333 ),
1334 }
1335}
1336
1337fn parse_compaction_notice(message: &Message) -> Option<(usize, usize)> {
1345 let Message::System { content } = message else {
1346 return None;
1347 };
1348 let rest = content.strip_prefix(COMPACTION_NOTICE_PREFIX)?;
1349 let turns: usize = rest.split_whitespace().next()?.parse().ok()?;
1350 let tokens: usize = rest
1351 .split('~')
1352 .nth(1)?
1353 .split_whitespace()
1354 .next()?
1355 .parse()
1356 .ok()?;
1357 Some((turns, tokens))
1358}
1359
1360fn approx_message_tokens(m: &Message) -> usize {
1366 car_inference::media_tokens::messages_history_tokens(std::slice::from_ref(m))
1367}
1368
1369pub(crate) fn compact_history_to_window(messages: &mut Vec<Message>, context_window: usize) {
1382 compact_history_measured(messages, context_window, PromptMeasure::default())
1383}
1384
1385#[derive(Debug, Clone, Copy, Default)]
1398pub(crate) struct PromptMeasure {
1399 pub fixed_overhead: usize,
1402 pub reported: Option<(usize, usize)>,
1407}
1408
1409pub(crate) fn message_estimates(messages: &[Message]) -> Vec<usize> {
1417 messages.iter().map(approx_message_tokens).collect()
1418}
1419
1420pub(crate) fn measure_scale(estimates: &[usize], measure: PromptMeasure) -> f64 {
1436 let Some((reported, covered)) = measure.reported else {
1437 return 1.0;
1438 };
1439 let covered = covered.min(estimates.len());
1440 let covered_est: usize = estimates[..covered].iter().sum::<usize>() + measure.fixed_overhead;
1441 if covered_est > 0 && reported * 4 > covered_est * 5 {
1442 reported as f64 / covered_est as f64
1443 } else {
1444 1.0
1445 }
1446}
1447
1448pub(crate) fn scaled_prompt_tokens(
1456 messages: &[Message],
1457 fixed_overhead: usize,
1458 scale: f64,
1459) -> usize {
1460 let estimate =
1461 car_inference::media_tokens::request_prompt_tokens("", None, None, None, Some(messages))
1462 + fixed_overhead;
1463 (estimate as f64 * scale).round() as usize
1464}
1465
1466pub(crate) fn compact_history_measured(
1472 messages: &mut Vec<Message>,
1473 context_window: usize,
1474 measure: PromptMeasure,
1475) {
1476 compact_history_measured_with_recovery(
1477 messages,
1478 context_window,
1479 measure,
1480 CompactionRecovery::default(),
1481 )
1482}
1483
1484pub(crate) fn compact_history_measured_with_recovery(
1489 messages: &mut Vec<Message>,
1490 context_window: usize,
1491 measure: PromptMeasure,
1492 recovery: CompactionRecovery,
1493) {
1494 if context_window == 0 {
1495 return;
1496 }
1497 let budget = history_budget(context_window);
1498 let estimates: Vec<usize> = message_estimates(messages);
1499 let estimated: usize =
1505 car_inference::media_tokens::request_prompt_tokens("", None, None, None, Some(messages))
1506 + measure.fixed_overhead;
1507 let (total, scale, reported) = match measure.reported {
1509 Some((reported, covered)) => {
1510 let covered = covered.min(messages.len());
1511 let appended: usize = estimates[covered..].iter().sum();
1512 let scale = measure_scale(&estimates, measure);
1513 let appended_scaled = (appended as f64 * scale).round() as usize;
1514 (reported + appended_scaled, scale, Some(reported))
1515 }
1516 None => (estimated, 1.0, None),
1517 };
1518 let scaled = |tokens: usize| (tokens as f64 * scale).round() as usize;
1519 if total <= budget {
1520 return;
1521 }
1522 tracing::info!(
1523 reported_prompt_tokens = reported,
1524 estimated_prompt_tokens = estimated,
1525 measured_prompt_tokens = total,
1526 scale,
1527 budget,
1528 context_window,
1529 "history exceeds the compaction budget"
1530 );
1531
1532 let mut head_end = 0;
1534 while head_end < messages.len() && matches!(messages[head_end], Message::System { .. }) {
1535 head_end += 1;
1536 }
1537 if head_end < messages.len()
1538 && matches!(
1539 messages[head_end],
1540 Message::User { .. } | Message::UserMultimodal { .. }
1541 )
1542 {
1543 head_end += 1;
1544 }
1545 let existing_notice = messages
1550 .get(head_end)
1551 .and_then(parse_compaction_notice)
1552 .map(|totals| {
1553 let at = head_end;
1554 head_end += 1;
1555 (at, totals)
1556 });
1557
1558 if messages.len().saturating_sub(head_end) <= HISTORY_MIN_TAIL {
1560 return;
1561 }
1562 let max_drop = messages.len() - HISTORY_MIN_TAIL;
1563
1564 let mut drop_end = head_end;
1566 let mut running = total;
1567 while running > budget && drop_end < max_drop {
1568 running = running.saturating_sub(scaled(estimates[drop_end]));
1569 drop_end += 1;
1570 }
1571 if drop_end > head_end
1576 && drop_end < messages.len()
1577 && matches!(messages[drop_end - 1], Message::ProviderOutputItems { .. })
1578 && matches!(messages[drop_end], Message::Assistant { .. })
1579 {
1580 drop_end += 1;
1581 }
1582 while drop_end < messages.len() && matches!(messages[drop_end], Message::ToolResult { .. }) {
1583 drop_end += 1;
1584 }
1585 if drop_end <= head_end {
1586 return;
1587 }
1588 let dropped = drop_end - head_end;
1589 let dropped_tokens: usize = estimates[head_end..drop_end]
1592 .iter()
1593 .map(|t| scaled(*t))
1594 .sum();
1595 messages.drain(head_end..drop_end);
1596 match existing_notice {
1609 Some((at, (prior_turns, prior_tokens))) => {
1610 messages[at] = Message::System {
1611 content: format_compaction_notice(
1612 prior_turns + dropped,
1613 prior_tokens + dropped_tokens,
1614 recovery,
1615 ),
1616 };
1617 }
1618 None => messages.insert(
1619 head_end,
1620 Message::System {
1621 content: format_compaction_notice(dropped, dropped_tokens, recovery),
1622 },
1623 ),
1624 }
1625 tracing::debug!(
1626 dropped_messages = dropped,
1627 kept = messages.len(),
1628 context_window,
1629 budget,
1630 "compacted assistant history to fit the model context window"
1631 );
1632}
1633
1634fn message_memory_text(message: &Message) -> Option<String> {
1635 match message {
1636 Message::System { content }
1637 | Message::User { content }
1638 | Message::Assistant { content, .. }
1639 | Message::ToolResult { content, .. } => {
1640 let trimmed = content.trim();
1641 (!trimmed.is_empty()).then(|| trimmed.to_string())
1642 }
1643 Message::UserMultimodal { content } => {
1644 let text = content
1645 .iter()
1646 .filter_map(|block| match block {
1647 ContentBlock::Text { text } => Some(text.trim()),
1648 _ => None,
1649 })
1650 .filter(|s| !s.is_empty())
1651 .collect::<Vec<_>>()
1652 .join("\n");
1653 (!text.is_empty()).then_some(text)
1654 }
1655 _ => None,
1656 }
1657}
1658
1659fn proactive_query_from_messages(messages: &[Message]) -> String {
1660 messages
1661 .iter()
1662 .rev()
1663 .find_map(|m| match m {
1664 Message::User { content } => {
1665 let trimmed = content.trim();
1666 (!trimmed.is_empty()).then(|| trimmed.to_string())
1667 }
1668 Message::UserMultimodal { .. } => message_memory_text(m),
1669 _ => None,
1670 })
1671 .unwrap_or_default()
1672}
1673
1674fn append_context_block(req: &mut GenerateRequest, title: &str, body: &str) {
1675 let block = format!("## {title}\n{body}");
1676 req.context = Some(match req.context.take() {
1677 Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
1678 _ => block,
1679 });
1680}
1681
1682fn proactive_maintenance_event_data(
1683 report: &car_memgine::ProactiveMaintenanceReport,
1684) -> std::collections::HashMap<String, Value> {
1685 let mut data = proactive_trigger_event_data(&report.trigger);
1686 data.insert(
1687 "saved_count".to_string(),
1688 Value::from(report.saved.len() as u64),
1689 );
1690 data.insert(
1691 "skipped_existing".to_string(),
1692 Value::from(report.skipped_existing as u64),
1693 );
1694 data.insert(
1695 "status_updated".to_string(),
1696 Value::from(report.status.is_some()),
1697 );
1698 data
1699}
1700
1701fn proactive_intervention_event_data(
1702 decision: &car_memgine::ProactiveMemoryDecision,
1703) -> std::collections::HashMap<String, Value> {
1704 let mut data = std::collections::HashMap::new();
1705 match decision {
1706 car_memgine::ProactiveMemoryDecision::Inject {
1707 selected,
1708 candidates,
1709 bank,
1710 ..
1711 } => {
1712 data.insert("decision".to_string(), Value::from("inject"));
1713 data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
1714 data.insert(
1715 "selected_kind".to_string(),
1716 Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
1717 );
1718 data.insert(
1719 "candidate_count".to_string(),
1720 Value::from(candidates.len() as u64),
1721 );
1722 data.insert(
1723 "bank_knowledge".to_string(),
1724 Value::from(bank.knowledge as u64),
1725 );
1726 data.insert(
1727 "bank_procedural".to_string(),
1728 Value::from(bank.procedural as u64),
1729 );
1730 data.insert(
1731 "bank_open_subgoals".to_string(),
1732 Value::from(bank.open_subgoals as u64),
1733 );
1734 }
1735 car_memgine::ProactiveMemoryDecision::Silent {
1736 reason,
1737 candidates,
1738 bank,
1739 } => {
1740 data.insert("decision".to_string(), Value::from("silent"));
1741 data.insert("reason".to_string(), Value::from(reason.clone()));
1742 data.insert(
1743 "candidate_count".to_string(),
1744 Value::from(candidates.len() as u64),
1745 );
1746 data.insert(
1747 "bank_knowledge".to_string(),
1748 Value::from(bank.knowledge as u64),
1749 );
1750 data.insert(
1751 "bank_procedural".to_string(),
1752 Value::from(bank.procedural as u64),
1753 );
1754 data.insert(
1755 "bank_open_subgoals".to_string(),
1756 Value::from(bank.open_subgoals as u64),
1757 );
1758 }
1759 }
1760 data
1761}
1762
1763fn proactive_trigger_event_data(
1764 trigger: &car_memgine::ProactiveMemoryTrigger,
1765) -> std::collections::HashMap<String, Value> {
1766 std::collections::HashMap::from([
1767 (
1768 "repeated_failures".to_string(),
1769 Value::from(trigger.repeated_failures as u64),
1770 ),
1771 ("tool_error".to_string(), Value::from(trigger.tool_error)),
1772 (
1773 "explicit_uncertainty".to_string(),
1774 Value::from(trigger.explicit_uncertainty),
1775 ),
1776 (
1777 "high_risk_action".to_string(),
1778 Value::from(trigger.high_risk_action),
1779 ),
1780 (
1781 "context_shift".to_string(),
1782 Value::from(trigger.context_shift),
1783 ),
1784 ])
1785}
1786
1787async fn record_inference_metered(runtime: &Runtime, result: &car_inference::InferenceResult) {
1815 let mut data: HashMap<String, Value> = HashMap::new();
1816 data.insert(
1817 "model_id".to_string(),
1818 Value::from(result.served_model_id().to_string()),
1819 );
1820 data.insert(
1823 "usage_measured".to_string(),
1824 Value::from(result.usage.is_some()),
1825 );
1826
1827 let metrics = match &result.usage {
1828 Some(u) => car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None)
1829 .with_duration(result.latency_ms as f64),
1830 None => car_eventlog::Metrics::latency(result.latency_ms as f64),
1831 };
1832
1833 runtime.log.lock().await.append_metered(
1834 car_eventlog::EventKind::InferenceMetered,
1835 None,
1836 None,
1837 data,
1838 metrics,
1839 );
1840}
1841
1842async fn maybe_apply_assistant_proactive_memory(
1843 cfg: &AssistantConfig,
1844 runtime: &Runtime,
1845 req: &mut GenerateRequest,
1846 messages: &[Message],
1847) {
1848 let Some(memory) = &cfg.proactive_memory else {
1849 return;
1850 };
1851 let query = proactive_query_from_messages(messages);
1852 if query.trim().is_empty() {
1853 return;
1854 }
1855 let mut recent = messages
1856 .iter()
1857 .rev()
1858 .filter_map(message_memory_text)
1859 .take(6)
1860 .collect::<Vec<_>>();
1861 recent.reverse();
1862 let events = {
1863 let log = runtime.log.lock().await;
1864 log.events().to_vec()
1865 };
1866 let (maintenance, decision) = match memory.proactive_intervention(&query, recent, &events).await
1867 {
1868 Ok(out) => out,
1869 Err(e) => {
1870 tracing::debug!(error = %e, "assistant proactive memory pass failed");
1871 return;
1872 }
1873 };
1874 {
1875 let mut log = runtime.log.lock().await;
1876 log.append(
1877 car_eventlog::EventKind::ProactiveMemoryMaintained,
1878 None,
1879 None,
1880 proactive_maintenance_event_data(&maintenance),
1881 );
1882 log.append(
1883 car_eventlog::EventKind::ProactiveMemoryIntervention,
1884 None,
1885 None,
1886 proactive_intervention_event_data(&decision),
1887 );
1888 }
1889 if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
1890 append_context_block(req, "Proactive Memory", &reminder);
1891 }
1892}
1893
1894#[derive(Default)]
1902struct OpenFailures {
1903 by_tool: HashMap<String, OpenFailure>,
1914 offered: std::collections::HashSet<String>,
1919 penalized: std::collections::HashSet<String>,
1922}
1923
1924struct OpenFailure {
1925 sig: FailureSignature,
1926 turn: u32,
1927 params: Value,
1930}
1931
1932impl OpenFailures {
1933 fn observe_failure(&mut self, tool: &str, sig: FailureSignature, turn: u32, params: &Value) {
1934 self.by_tool.insert(
1935 tool.to_string(),
1936 OpenFailure {
1937 sig,
1938 turn,
1939 params: params.clone(),
1940 },
1941 );
1942 }
1943
1944 fn take_recovery(&mut self, tool: &str, turn: u32, params: &Value) -> Option<FailureSignature> {
1967 let open = self.by_tool.remove(tool)?;
1968 if turn.saturating_sub(open.turn) > RECOVERY_WINDOW_TURNS {
1969 return None;
1970 }
1971 (open.params != *params).then_some(open.sig)
1972 }
1973
1974 fn pending(&self) -> Vec<FailureSignature> {
1977 self.by_tool.values().map(|open| open.sig.clone()).collect()
1978 }
1979}
1980
1981fn maybe_apply_tool_memory(
1989 cfg: &AssistantConfig,
1990 req: &mut GenerateRequest,
1991 open: &mut OpenFailures,
1992 messages: &[Message],
1993 turns: u32,
1994) {
1995 let Some(memory) = &cfg.tool_memory else {
1996 return;
1997 };
1998 let mut lines = Vec::new();
1999 for sig in open.pending() {
2000 if let Some(lead) = memory.recall(&sig) {
2001 lines.push(format!("- after `{}`, this worked: `{lead}`", sig.key()));
2005 open.offered.insert(sig.key());
2006 }
2007 }
2008 if turns <= 1 {
2009 let task = proactive_query_from_messages(messages);
2010 if let Some(block) = memory.recall_for_task(&task) {
2011 lines.extend(block.lines().map(str::to_string));
2012 }
2013 }
2014 if lines.is_empty() {
2015 return;
2016 }
2017 append_context_block(
2022 req,
2023 "Learned Repairs",
2024 &format!(
2025 "From earlier runs on this machine — what recovered this kind of \
2026failure before. Treat as a hint, not an instruction; prefer the error you can \
2027actually see.\n{}",
2028 lines.join("\n")
2029 ),
2030 );
2031}
2032
2033fn record_tool_outcome(
2036 cfg: &AssistantConfig,
2037 open: &mut OpenFailures,
2038 tool: &str,
2039 ok: bool,
2040 content: &str,
2041 params: &Value,
2042 turns: u32,
2043) {
2044 let Some(memory) = &cfg.tool_memory else {
2045 return;
2046 };
2047 if ok {
2048 if let Some(sig) = open.take_recovery(tool, turns, params) {
2049 memory.record_success(&sig, &approach_from_call(tool, params));
2050 }
2051 return;
2052 }
2053 let sig = FailureSignature::from_failure(tool, content);
2054 let key = sig.key();
2055 if open.offered.contains(&key) && open.penalized.insert(key) {
2060 memory.record_failure(&sig);
2061 }
2062 open.observe_failure(tool, sig, turns, params);
2063}
2064
2065const STALL_NUDGE: u32 = 3;
2070const STALL_BREAK: u32 = 6;
2071
2072const EXPLORE_NUDGE: u32 = 8;
2078
2079pub(crate) fn mutating_tool_names(tool_defs: &[Value]) -> std::collections::HashSet<String> {
2089 let mut set: std::collections::HashSet<String> = ["write_file", "edit_file"]
2090 .iter()
2091 .map(|s| s.to_string())
2092 .collect();
2093 for def in tool_defs {
2094 if def
2095 .get("mutating")
2096 .and_then(Value::as_bool)
2097 .unwrap_or(false)
2098 {
2099 if let Some(name) = def.get("name").and_then(Value::as_str) {
2100 set.insert(name.to_string());
2101 }
2102 }
2103 }
2104 set
2105}
2106
2107fn tool_calls_signature(calls: &[ToolCall]) -> String {
2111 let mut parts: Vec<String> = calls
2112 .iter()
2113 .map(|c| {
2114 format!(
2115 "{}({})",
2116 c.name,
2117 serde_json::to_string(&c.arguments).unwrap_or_default()
2118 )
2119 })
2120 .collect();
2121 parts.sort();
2122 parts.join("|")
2123}
2124
2125#[derive(Debug, PartialEq, Eq)]
2128enum GuardStep {
2129 Progress,
2131 Continue,
2133 Nudge,
2135 Break,
2137}
2138
2139#[derive(Default)]
2149struct NoProgressGuard {
2150 seen_sigs: std::collections::HashSet<String>,
2151 stall_repeats: u32,
2152 turns_since_mutation: u32,
2153 nudged: bool,
2154}
2155
2156impl NoProgressGuard {
2157 fn observe(&mut self, sig: &str, mutated_ok: bool) -> GuardStep {
2160 let sig_is_new = self.seen_sigs.insert(sig.to_string());
2161 if mutated_ok && sig_is_new {
2162 self.seen_sigs.clear();
2165 self.seen_sigs.insert(sig.to_string());
2166 self.stall_repeats = 0;
2167 self.turns_since_mutation = 0;
2168 self.nudged = false;
2169 return GuardStep::Progress;
2170 }
2171 self.turns_since_mutation += 1;
2173 if !sig_is_new {
2174 self.stall_repeats += 1;
2175 if self.stall_repeats >= STALL_BREAK {
2176 return GuardStep::Break;
2177 }
2178 if self.stall_repeats >= STALL_NUDGE && !self.nudged {
2179 self.nudged = true;
2180 return GuardStep::Nudge;
2181 }
2182 }
2183 if self.turns_since_mutation >= EXPLORE_NUDGE && !self.nudged {
2184 self.nudged = true;
2185 return GuardStep::Nudge;
2186 }
2187 GuardStep::Continue
2188 }
2189}
2190
2191fn build_proposal(
2201 source: &str,
2202 call: &ToolCall,
2203 action_id: &str,
2204 parameters: &Value,
2205) -> Result<ActionProposal, String> {
2206 serde_json::from_value(json!({
2207 "source": source,
2208 "actions": [{
2209 "id": action_id,
2210 "type": "tool_call",
2211 "tool": call.name,
2212 "parameters": parameters,
2213 }],
2214 }))
2215 .map_err(|e| format!("malformed proposal: {e}"))
2216}
2217
2218async fn generate_assistant_with_retry_progress(
2223 generator: &dyn TurnGenerator,
2224 request: GenerateRequest,
2225 mut on_retry: impl FnMut(car_inference::InferenceRetryProgress),
2226) -> Result<InferenceResult, AssistantGenerateError> {
2227 let (retry_tx, mut retry_rx) = tokio::sync::mpsc::unbounded_channel();
2228 let mut retry_observer = move |retry| {
2229 let _ = retry_tx.send(retry);
2230 };
2231 let generation = generator.generate_assistant_observed(request, &mut retry_observer);
2232 tokio::pin!(generation);
2233
2234 loop {
2235 tokio::select! {
2236 biased;
2237 Some(retry) = retry_rx.recv() => on_retry(retry),
2238 result = &mut generation => {
2239 while let Ok(retry) = retry_rx.try_recv() {
2240 on_retry(retry);
2241 }
2242 return result;
2243 }
2244 }
2245 }
2246}
2247
2248pub async fn run_assistant_loop(
2252 generator: &dyn TurnGenerator,
2253 runtime: &Runtime,
2254 cfg: &AssistantConfig,
2255 messages: &mut Vec<Message>,
2256 emit: impl FnMut(AssistantEvent),
2257) -> AssistantOutcome {
2258 let never = std::sync::atomic::AtomicBool::new(false);
2259 run_assistant_loop_cancellable(generator, runtime, cfg, messages, &never, None, None, emit)
2260 .await
2261}
2262
2263pub async fn run_assistant_loop_cancellable(
2267 generator: &dyn TurnGenerator,
2268 runtime: &Runtime,
2269 cfg: &AssistantConfig,
2270 messages: &mut Vec<Message>,
2271 cancel: &std::sync::atomic::AtomicBool,
2272 approval: Option<&dyn ApprovalGate>,
2273 images: Option<&[ContentBlock]>,
2274 emit: impl FnMut(AssistantEvent),
2275) -> AssistantOutcome {
2276 run_assistant_loop_cancellable_in_session(
2277 generator, runtime, cfg, messages, cancel, approval, images, None, emit,
2278 )
2279 .await
2280}
2281
2282pub async fn run_assistant_loop_cancellable_in_session(
2286 generator: &dyn TurnGenerator,
2287 runtime: &Runtime,
2288 cfg: &AssistantConfig,
2289 messages: &mut Vec<Message>,
2290 cancel: &std::sync::atomic::AtomicBool,
2291 approval: Option<&dyn ApprovalGate>,
2292 images: Option<&[ContentBlock]>,
2293 runtime_session_id: Option<&str>,
2294 emit: impl FnMut(AssistantEvent),
2295) -> AssistantOutcome {
2296 run_assistant_loop_cancellable_in_session_durable(
2297 generator,
2298 runtime,
2299 cfg,
2300 messages,
2301 cancel,
2302 approval,
2303 images,
2304 runtime_session_id,
2305 None,
2306 None,
2307 true,
2308 emit,
2309 )
2310 .await
2311}
2312
2313pub async fn run_assistant_loop_cancellable_in_session_durable(
2317 generator: &dyn TurnGenerator,
2318 runtime: &Runtime,
2319 cfg: &AssistantConfig,
2320 messages: &mut Vec<Message>,
2321 cancel: &std::sync::atomic::AtomicBool,
2322 approval: Option<&dyn ApprovalGate>,
2323 images: Option<&[ContentBlock]>,
2324 runtime_session_id: Option<&str>,
2325 durable_session_id: Option<&str>,
2326 durability: Option<&dyn super::governance::AssistantDurability>,
2327 redrive_ungrounded_summary: bool,
2328 mut emit: impl FnMut(AssistantEvent),
2329) -> AssistantOutcome {
2330 use std::sync::atomic::Ordering;
2331 let tools = if cfg.tools.is_empty() {
2332 None
2333 } else {
2334 Some(cfg.tools.clone())
2335 };
2336 let mut tools_called: Vec<String> = Vec::new();
2337 let mut tool_receipts: Vec<AssistantToolReceipt> = transcript_tool_receipts(messages);
2341 let prior_receipts = tool_receipts.len();
2344 let mut values = super::value_store::SessionValues::new();
2348 let mut last_text = String::new();
2349 let mut last_model = String::new();
2350 let mut models_served = Vec::new();
2351 let prior_assistant_turns = transcript_turn_offset(messages);
2356 let mut turns = 0u32;
2357 let mut turns_completed = 0u32;
2358 let mut claim_corrections = 0u8;
2359 let registry_window = cfg
2364 .model
2365 .as_deref()
2366 .map(|m| generator.context_window(m))
2367 .unwrap_or(0);
2368 let (context_window, window_advisory) =
2369 resolve_context_window(cfg.context_window_override, registry_window);
2370 if let Some(advisory) = window_advisory {
2371 tracing::warn!(
2372 requested = cfg.context_window_override,
2373 registry_window,
2374 "{advisory}"
2375 );
2376 emit(AssistantEvent::Text(format!(
2377 "[context window: {advisory}]"
2378 )));
2379 }
2380 let mut prompt_measure = PromptMeasure {
2385 fixed_overhead: car_inference::media_tokens::tool_defs_tokens(&cfg.tools),
2386 reported: None,
2387 };
2388 let mutating_tools = mutating_tool_names(&cfg.tools);
2393 let advertised_names: std::collections::HashSet<String> = cfg
2396 .tools
2397 .iter()
2398 .filter_map(|d| d.get("name").and_then(Value::as_str))
2399 .map(str::to_string)
2400 .collect();
2401 let delegate_advertised = advertised_names.contains(DELEGATE_TOOL);
2402 let delegate_budget = cfg.delegate_budget.unwrap_or_default();
2404 let mut delegations_spawned: u32 = 0;
2405 let mut child_turns_used: u32 = 0;
2406 let builtin_labels;
2411 let tool_labels = match &cfg.tool_labels {
2412 Some(m) => m,
2413 None => {
2414 builtin_labels = builtin_tool_labels();
2415 &builtin_labels
2416 }
2417 };
2418 let mut guard = NoProgressGuard::default();
2423 let mut open_failures = OpenFailures::default();
2426
2427 while turns < cfg.max_turns {
2428 if cancel.load(Ordering::Relaxed) {
2429 return AssistantOutcome {
2430 status: "cancelled",
2431 summary: "cancelled".to_string(),
2432 turns,
2433 turns_completed,
2434 tools_called,
2435 tool_receipts,
2436 prior_receipts,
2437 models_served: models_served.clone(),
2438 model_used: last_model.clone(),
2439 auth_required: None,
2440 failure_cause: None,
2441 };
2442 }
2443 turns += 1;
2444
2445 let before_compaction = messages.clone();
2449 compact_history_measured(messages, context_window, prompt_measure);
2450 if before_compaction != *messages {
2451 prompt_measure.reported = None;
2454 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2455 if let Err(e) = store
2456 .checkpoint(session_id, messages, "history_compacted", None)
2457 .await
2458 {
2459 let msg = format!("durable checkpoint failed after compaction: {e}");
2460 emit(AssistantEvent::Error(msg.clone()));
2461 return AssistantOutcome {
2462 status: "error",
2463 summary: msg,
2464 turns,
2465 turns_completed,
2466 tools_called,
2467 tool_receipts,
2468 prior_receipts,
2469 models_served: models_served.clone(),
2470 model_used: last_model,
2471 auth_required: None,
2472 failure_cause: None,
2473 };
2474 }
2475 }
2476 }
2477
2478 let mut req = GenerateRequest {
2479 prompt: String::new(),
2480 model: cfg.model.clone(),
2481 params: GenerateParams {
2482 temperature: 0.0,
2483 strict_model: cfg.strict_model,
2484 ..Default::default()
2485 },
2486 context: None,
2487 context_stable_prefix: None,
2488 tools: tools.clone(),
2489 images: if turns == 1 {
2492 images.map(|imgs| imgs.to_vec())
2493 } else {
2494 None
2495 },
2496 messages: Some(messages.clone()),
2497 cache_control: false,
2498 response_format: if tools.is_none() {
2503 cfg.response_format.clone()
2504 } else {
2505 None
2506 },
2507 intent: None,
2508 client_ref: None,
2509 expected_row_digest: None,
2510 expected_catalog_revision: None,
2511 caller: None,
2512 };
2513 maybe_apply_assistant_proactive_memory(cfg, runtime, &mut req, messages).await;
2514 maybe_apply_tool_memory(cfg, &mut req, &mut open_failures, messages, turns);
2515
2516 let todo_render = match &cfg.todos {
2532 Some(todos) => todos.lock().await.render(),
2533 None => None,
2534 };
2535 if let Some(block) = render_state_block(todo_render, &recent_fact_subjects(&tool_receipts))
2536 {
2537 if let Some(msgs) = req.messages.as_mut() {
2538 append_state_block(msgs, &block);
2539 }
2540 }
2541 let request_covers = messages.len();
2549
2550 let requested_model = req.model.clone().unwrap_or_else(|| "(router)".to_string());
2551 emit(AssistantEvent::InferenceStarted {
2552 model: requested_model,
2553 attempt: 1,
2554 turn: turns,
2555 });
2556 let mut result = match generate_assistant_with_retry_progress(generator, req, |retry| {
2557 emit(AssistantEvent::InferenceRetry {
2558 model: retry.model,
2559 attempt: retry.attempt,
2560 reason: retry.reason.to_string(),
2561 backoff_ms: retry.backoff_ms,
2562 });
2563 })
2564 .await
2565 {
2566 Ok(r) => {
2567 turns_completed += 1;
2568 r
2569 }
2570 Err(e) => {
2571 if let Some(reason) = auth_required_reason(&e) {
2577 let message = reason.remedy().to_string();
2578 emit(AssistantEvent::AuthRequired {
2579 reason,
2580 message: message.clone(),
2581 });
2582 return AssistantOutcome {
2583 status: "auth_required",
2584 summary: message,
2585 turns,
2586 turns_completed,
2587 tools_called,
2588 tool_receipts,
2589 prior_receipts,
2590 models_served: models_served.clone(),
2591 model_used: last_model.clone(),
2592 auth_required: Some(reason),
2593 failure_cause: None,
2594 };
2595 }
2596 let failure_cause = generation_failure_cause(&e);
2597 let msg = format!("inference failed: {e}");
2598 emit(AssistantEvent::Error(msg.clone()));
2599 return AssistantOutcome {
2600 status: "error",
2601 summary: msg,
2602 turns,
2603 turns_completed,
2604 tools_called,
2605 tool_receipts,
2606 prior_receipts,
2607 models_served: models_served.clone(),
2608 model_used: last_model.clone(),
2609 auth_required: None,
2610 failure_cause: Some(failure_cause),
2611 };
2612 }
2613 };
2614 record_inference_metered(runtime, &result).await;
2625 let attribution = AssistantModelAttribution {
2626 model_id: result.served_model_id().to_string(),
2627 local_last_resort: result.local_last_resort,
2628 };
2629 emit(AssistantEvent::ModelServed {
2630 model_id: attribution.model_id.clone(),
2631 local_last_resort: attribution.local_last_resort,
2632 });
2633 models_served.push(attribution);
2634 if let Some(u) = &result.usage {
2638 let input = u.prompt_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens;
2639 if input > 0 {
2640 prompt_measure.reported = Some((input as usize, request_covers));
2641 }
2642 }
2643 result.text = car_inference::tasks::generate::strip_leaked_reasoning(&result.text);
2649 last_model = result.served_model_id().to_string();
2650
2651 if result.tool_calls.is_empty() {
2653 last_text = result.text.clone();
2654 let ungrounded = ungrounded_summary_claims(&last_text, &tool_receipts);
2655 if redrive_ungrounded_summary && !ungrounded.is_empty() {
2656 result.append_assistant_history(messages, vec![]);
2657 if claim_corrections < 2 && turns < cfg.max_turns {
2658 claim_corrections += 1;
2659 messages.push(Message::User {
2660 content: format!(
2661 "Evidence check rejected the draft's unsupported operational claim(s): {}. \
2662 Rewrite the answer using only claims supported by successful transcript \
2663 tool receipts. Preserve useful source findings, explicitly mark missing \
2664 live evidence, and do not rerun completed actions merely to support prose.",
2665 ungrounded.join(", ")
2666 ),
2667 });
2668 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2669 if let Err(e) = store
2670 .checkpoint(session_id, messages, "ungrounded_summary_redrive", None)
2671 .await
2672 {
2673 let msg =
2674 format!("durable checkpoint failed before claim correction: {e}");
2675 emit(AssistantEvent::Error(msg.clone()));
2676 return AssistantOutcome {
2677 status: "error",
2678 summary: msg,
2679 turns,
2680 turns_completed,
2681 tools_called,
2682 tool_receipts,
2683 prior_receipts,
2684 models_served: models_served.clone(),
2685 model_used: last_model,
2686 auth_required: None,
2687 failure_cause: None,
2688 };
2689 }
2690 }
2691 continue;
2692 }
2693 let summary = annotate_summary_with_claim_note(&last_text, &ungrounded);
2694 emit(AssistantEvent::Error(summary.clone()));
2695 return AssistantOutcome {
2696 status: "error",
2697 summary,
2698 turns,
2699 turns_completed,
2700 tools_called,
2701 tool_receipts,
2702 prior_receipts,
2703 models_served: models_served.clone(),
2704 model_used: last_model,
2705 auth_required: None,
2706 failure_cause: None,
2707 };
2708 }
2709 let mut final_appended = false;
2722 if let Some(format) = cfg.response_format.as_ref().filter(|f| {
2723 !final_text_matches_format(&last_text, f, cfg.response_format_validator.as_ref())
2724 }) {
2725 emit(AssistantEvent::Text(FORMAT_REPAIR_NOTICE.to_string()));
2726 result.append_assistant_history(messages, vec![]);
2727 messages.push(Message::User {
2728 content: format_repair_nudge(format).to_string(),
2729 });
2730 let repair = GenerateRequest {
2731 prompt: String::new(),
2732 model: cfg.model.clone(),
2733 params: GenerateParams {
2734 temperature: 0.0,
2735 strict_model: cfg.strict_model,
2736 ..Default::default()
2737 },
2738 context: None,
2739 context_stable_prefix: None,
2740 tools: None,
2741 images: None,
2742 messages: Some(messages.clone()),
2743 cache_control: false,
2744 response_format: Some(format.clone()),
2745 intent: None,
2746 client_ref: None,
2747 expected_row_digest: None,
2748 expected_catalog_revision: None,
2749 caller: None,
2750 };
2751 let requested_model = repair
2760 .model
2761 .clone()
2762 .unwrap_or_else(|| "(router)".to_string());
2763 emit(AssistantEvent::InferenceStarted {
2764 model: requested_model,
2765 attempt: 1,
2766 turn: turns,
2767 });
2768 match generate_assistant_with_retry_progress(generator, repair, |retry| {
2769 emit(AssistantEvent::InferenceRetry {
2770 model: retry.model,
2771 attempt: retry.attempt,
2772 reason: retry.reason.to_string(),
2773 backoff_ms: retry.backoff_ms,
2774 });
2775 })
2776 .await
2777 {
2778 Ok(mut repaired) => {
2779 record_inference_metered(runtime, &repaired).await;
2780 let attribution = AssistantModelAttribution {
2781 model_id: repaired.served_model_id().to_string(),
2782 local_last_resort: repaired.local_last_resort,
2783 };
2784 emit(AssistantEvent::ModelServed {
2785 model_id: attribution.model_id.clone(),
2786 local_last_resort: attribution.local_last_resort,
2787 });
2788 models_served.push(attribution);
2789 repaired.text =
2790 car_inference::tasks::generate::strip_leaked_reasoning(&repaired.text);
2791 if !final_text_matches_format(
2792 &repaired.text,
2793 format,
2794 cfg.response_format_validator.as_ref(),
2795 ) {
2796 emit(AssistantEvent::Text(
2797 FORMAT_REPAIR_STILL_INVALID.to_string(),
2798 ));
2799 }
2800 last_model = repaired.served_model_id().to_string();
2801 last_text = repaired.text.clone();
2802 result = repaired;
2803 }
2804 Err(e) => {
2805 if matches!(messages.last(), Some(Message::User { content }) if content == format_repair_nudge(format))
2811 {
2812 messages.pop();
2813 }
2814 emit(AssistantEvent::Text(format!(
2815 "{FORMAT_REPAIR_FAILED_PREFIX} {e}; returning the draft answer as-is]"
2816 )));
2817 final_appended = true;
2818 }
2819 }
2820 }
2821 if !final_appended {
2822 result.append_assistant_history(messages, vec![]);
2823 }
2824 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2825 if let Err(e) = store
2826 .checkpoint(session_id, messages, "assistant_final", None)
2827 .await
2828 {
2829 let msg = format!("durable checkpoint failed after assistant response: {e}");
2830 emit(AssistantEvent::Error(msg.clone()));
2831 return AssistantOutcome {
2832 status: "error",
2833 summary: msg,
2834 turns,
2835 turns_completed,
2836 tools_called,
2837 tool_receipts,
2838 prior_receipts,
2839 models_served: models_served.clone(),
2840 model_used: last_model,
2841 auth_required: None,
2842 failure_cause: None,
2843 };
2844 }
2845 }
2846 runtime
2851 .record_turn_completed(
2852 "empty_tool_calls",
2853 result.stop_reason.as_deref(),
2854 result.was_truncated(),
2855 turns,
2856 &last_model,
2857 )
2858 .await;
2859 emit(AssistantEvent::Done {
2860 text: last_text.clone(),
2861 });
2862 return AssistantOutcome {
2863 status: "success",
2864 summary: last_text,
2865 turns,
2866 turns_completed,
2867 tools_called,
2868 tool_receipts,
2869 prior_receipts,
2870 models_served: models_served.clone(),
2871 model_used: last_model.clone(),
2872 auth_required: None,
2873 failure_cause: None,
2874 };
2875 }
2876
2877 if !result.text.trim().is_empty() {
2878 last_text = result.text.clone();
2879 emit(AssistantEvent::Text(result.text.clone()));
2880 }
2881
2882 let mut calls = result.tool_calls.clone();
2888 for (i, call) in calls.iter_mut().enumerate() {
2889 if call.id.is_none() {
2890 let sequence = u32::try_from(i + 1).unwrap_or(u32::MAX);
2891 let transcript_turn = prior_assistant_turns.saturating_add(turns);
2892 call.id = Some(format!("provider_turn_{transcript_turn}_call_{sequence}"));
2893 }
2894 }
2895
2896 result.append_assistant_history(messages, calls.clone());
2897 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2898 if let Err(e) = store
2899 .checkpoint(session_id, messages, "assistant_tool_calls", None)
2900 .await
2901 {
2902 let msg = format!("durable checkpoint failed before tool dispatch: {e}");
2903 emit(AssistantEvent::Error(msg.clone()));
2904 return AssistantOutcome {
2905 status: "error",
2906 summary: msg,
2907 turns,
2908 turns_completed,
2909 tools_called,
2910 tool_receipts,
2911 prior_receipts,
2912 models_served: models_served.clone(),
2913 model_used: last_model,
2914 auth_required: None,
2915 failure_cause: None,
2916 };
2917 }
2918 }
2919
2920 let mut mutated_ok = false;
2925
2926 for (index, call) in calls.iter().enumerate() {
2929 let provider_id = call.id.clone().expect("provider ids assigned above");
2930 let sequence = u32::try_from(index + 1).unwrap_or(u32::MAX);
2931 let transcript_turn = prior_assistant_turns.saturating_add(turns);
2932 let id = format!("turn_{transcript_turn}_call_{sequence}");
2933 emit(AssistantEvent::ToolCall {
2934 call_id: id.clone(),
2935 sequence,
2936 name: call.name.clone(),
2937 params: serde_json::to_value(&call.arguments).unwrap_or_default(),
2938 });
2939
2940 if cancel.load(Ordering::Relaxed) {
2944 let raw_content = json!({ "error": "cancelled before tool dispatch" }).to_string();
2945 let content = cap(raw_content.clone());
2946 tool_receipts.push(AssistantToolReceipt {
2947 tool: call.name.clone(),
2948 call_id: Some(id.clone()),
2949 sequence: Some(sequence),
2950 ok: false,
2951 params: serde_json::to_value(&call.arguments).unwrap_or_default(),
2952 result: Some(raw_content.clone()),
2953 via: None,
2954 });
2955 emit(AssistantEvent::ToolResult {
2956 call_id: id.clone(),
2957 sequence,
2958 name: call.name.clone(),
2959 ok: false,
2960 content: raw_content,
2961 });
2962 messages.push(Message::ToolResult {
2963 tool_use_id: provider_id.clone(),
2964 content,
2965 provenance: Provenance::Internal,
2966 });
2967 return AssistantOutcome {
2968 status: "cancelled",
2969 summary: "cancelled".to_string(),
2970 turns,
2971 turns_completed,
2972 tools_called,
2973 tool_receipts,
2974 prior_receipts,
2975 models_served: models_served.clone(),
2976 model_used: last_model,
2977 auth_required: None,
2978 failure_cause: None,
2979 };
2980 }
2981
2982 let mut params_val = serde_json::to_value(&call.arguments).unwrap_or_default();
2986 if cfg.value_store_previews {
2995 let resolved = values.resolve_refs(&mut params_val);
2996 if !resolved.is_empty() {
2997 tracing::debug!(
2998 tool = %call.name,
2999 handles = ?resolved,
3000 "resolved retained-value references in tool arguments"
3001 );
3002 }
3003 }
3004 let params_val = params_val;
3005 let posture = match &cfg.approval_policy {
3006 Some(policy) => policy(&call.name, ¶ms_val),
3007 None => {
3008 if cfg.gated_tools.iter().any(|t| t == &call.name) {
3009 ToolApprovalDecision::RequireApproval
3010 } else {
3011 ToolApprovalDecision::Allow
3012 }
3013 }
3014 };
3015 let posture = if cfg.refuse_unadvertised_tools && !advertised_names.contains(&call.name)
3019 {
3020 ToolApprovalDecision::Deny(format!(
3021 "tool '{}' is not granted to this delegate; use only: {}",
3022 call.name,
3023 advertised_names
3024 .iter()
3025 .cloned()
3026 .collect::<Vec<_>>()
3027 .join(", ")
3028 ))
3029 } else {
3030 posture
3031 };
3032 let needs_approval = matches!(&posture, ToolApprovalDecision::RequireApproval);
3033
3034 let refusal: Option<String> = match posture {
3035 ToolApprovalDecision::Allow => None,
3036 ToolApprovalDecision::Deny(reason) => Some(reason),
3037 ToolApprovalDecision::RequireApproval => {
3038 let decision = match approval {
3039 Some(gate) => gate.request_action(&id, &call.name, ¶ms_val).await,
3040 None => ApprovalDecision::Denied(format!(
3041 "'{}' needs approval: re-run with --full-access to allow it on this host, \
3042 or use the default sandbox where edits are isolated",
3043 call.name
3044 )),
3045 };
3046 match decision {
3047 ApprovalDecision::Approved => None,
3048 ApprovalDecision::Denied(reason) => Some(reason),
3049 }
3050 }
3051 };
3052 if let Some(reason) = refusal {
3053 let content = cap(json!({ "error": reason }).to_string());
3054 tool_receipts.push(AssistantToolReceipt {
3055 tool: call.name.clone(),
3056 call_id: Some(id.clone()),
3057 sequence: Some(sequence),
3058 ok: false,
3059 params: params_val.clone(),
3060 result: Some(content.clone()),
3061 via: None,
3062 });
3063 emit(AssistantEvent::ToolResult {
3064 call_id: id.clone(),
3065 sequence,
3066 name: call.name.clone(),
3067 ok: false,
3068 content: content.clone(),
3069 });
3070 messages.push(Message::ToolResult {
3071 tool_use_id: provider_id.clone(),
3072 content,
3073 provenance: Provenance::Internal,
3075 });
3076 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3077 if let Err(e) = store
3078 .checkpoint(session_id, messages, "tool_refused", None)
3079 .await
3080 {
3081 let msg = format!("durable checkpoint failed after refusal: {e}");
3082 emit(AssistantEvent::Error(msg.clone()));
3083 return AssistantOutcome {
3084 status: "error",
3085 summary: msg,
3086 turns,
3087 turns_completed,
3088 tools_called,
3089 tool_receipts,
3090 prior_receipts,
3091 models_served: models_served.clone(),
3092 model_used: last_model,
3093 auth_required: None,
3094 failure_cause: None,
3095 };
3096 }
3097 }
3098 continue;
3099 }
3100
3101 if delegate_advertised && call.name == DELEGATE_TOOL {
3105 let goal_brief = params_val
3106 .get("goal")
3107 .and_then(Value::as_str)
3108 .unwrap_or_default()
3109 .replace('\n', " ");
3110 let goal_brief: String = goal_brief.chars().take(80).collect();
3111 let over_budget = delegations_spawned >= delegate_budget.max_delegations
3112 || child_turns_used >= delegate_budget.max_child_turns;
3113 let done = if over_budget {
3114 DelegateOutcome {
3115 ok: false,
3116 content: cap(json!({
3117 "error": format!(
3118 "delegation budget exhausted ({delegations_spawned} delegations / \
3119 {child_turns_used} child turns used; limits {} / {}) — finish with \
3120 what you have",
3121 delegate_budget.max_delegations, delegate_budget.max_child_turns
3122 )
3123 })
3124 .to_string()),
3125 turns: 0,
3126 external: false,
3127 receipts: Vec::new(),
3128 spawned: false,
3129 }
3130 } else {
3131 run_delegate(
3132 generator,
3133 runtime,
3134 cfg,
3135 messages,
3136 ¶ms_val,
3137 cancel,
3138 approval,
3139 runtime_session_id,
3140 redrive_ungrounded_summary,
3141 tool_labels,
3142 )
3143 .await
3144 };
3145 if done.spawned {
3146 delegations_spawned += 1;
3147 child_turns_used = child_turns_used.saturating_add(done.turns);
3148 }
3149 emit(AssistantEvent::Text(format!(
3150 "[delegate: {goal_brief} — {} turns, {}]",
3151 done.turns,
3152 if done.ok { "ok" } else { "error" }
3153 )));
3154 if done.ok {
3155 tools_called.push(call.name.clone());
3156 if mutating_tools.contains(&call.name) {
3157 mutated_ok = true;
3158 }
3159 }
3160 tool_receipts.push(AssistantToolReceipt {
3161 tool: call.name.clone(),
3162 call_id: Some(id.clone()),
3163 sequence: Some(sequence),
3164 ok: done.ok,
3165 params: params_val.clone(),
3166 result: Some(done.content.clone()),
3167 via: None,
3168 });
3169 let via = format!("{DELEGATE_TOOL}:{id}");
3175 tool_receipts.extend(done.receipts.into_iter().map(|mut r| {
3176 r.call_id = Some(match r.call_id {
3184 Some(child) => format!("{id}/{child}"),
3185 None => format!("{id}/"),
3186 });
3187 r.via = Some(via.clone());
3188 r
3189 }));
3190 emit(AssistantEvent::ToolResult {
3191 call_id: id.clone(),
3192 sequence,
3193 name: call.name.clone(),
3194 ok: done.ok,
3195 content: done.content.clone(),
3196 });
3197 messages.push(Message::ToolResult {
3198 tool_use_id: provider_id.clone(),
3199 content: done.content,
3200 provenance: if done.external {
3204 Provenance::External
3205 } else {
3206 Provenance::Internal
3207 },
3208 });
3209 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3210 if let Err(e) = store
3211 .checkpoint(session_id, messages, "tool_result", None)
3212 .await
3213 {
3214 let msg = format!("durable checkpoint failed after delegate result: {e}");
3215 emit(AssistantEvent::Error(msg.clone()));
3216 return AssistantOutcome {
3217 status: "error",
3218 summary: msg,
3219 turns,
3220 turns_completed,
3221 tools_called,
3222 tool_receipts,
3223 prior_receipts,
3224 models_served: models_served.clone(),
3225 model_used: last_model,
3226 auth_required: None,
3227 failure_cause: None,
3228 };
3229 }
3230 }
3231 continue;
3232 }
3233
3234 let proposal = match build_proposal(&result.model_used, call, &id, ¶ms_val) {
3235 Ok(p) => p,
3236 Err(e) => {
3237 let content = cap(json!({ "error": e }).to_string());
3240 tool_receipts.push(AssistantToolReceipt {
3241 tool: call.name.clone(),
3242 call_id: Some(id.clone()),
3243 sequence: Some(sequence),
3244 ok: false,
3245 params: params_val.clone(),
3246 result: Some(content.clone()),
3247 via: None,
3248 });
3249 emit(AssistantEvent::ToolResult {
3250 call_id: id.clone(),
3251 sequence,
3252 name: call.name.clone(),
3253 ok: false,
3254 content: content.clone(),
3255 });
3256 messages.push(Message::ToolResult {
3257 tool_use_id: provider_id.clone(),
3258 content,
3259 provenance: Provenance::Internal,
3261 });
3262 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3263 if let Err(e) = store
3264 .checkpoint(session_id, messages, "malformed_tool_call", None)
3265 .await
3266 {
3267 let msg = format!("durable checkpoint failed after tool error: {e}");
3268 emit(AssistantEvent::Error(msg.clone()));
3269 return AssistantOutcome {
3270 status: "error",
3271 summary: msg,
3272 turns,
3273 turns_completed,
3274 tools_called,
3275 tool_receipts,
3276 prior_receipts,
3277 models_served: models_served.clone(),
3278 model_used: last_model,
3279 auth_required: None,
3280 failure_cause: None,
3281 };
3282 }
3283 }
3284 continue;
3285 }
3286 };
3287
3288 if needs_approval {
3289 let dispatch = match approval {
3290 Some(gate) => gate.before_dispatch(&id, &call.name, ¶ms_val).await,
3291 None => Err("approval gate disappeared before dispatch".into()),
3292 };
3293 if let Err(e) = dispatch {
3294 let content =
3295 cap(json!({ "error": format!("dispatch refused: {e}") }).to_string());
3296 tool_receipts.push(AssistantToolReceipt {
3297 tool: call.name.clone(),
3298 call_id: Some(id.clone()),
3299 sequence: Some(sequence),
3300 ok: false,
3301 params: params_val.clone(),
3302 result: Some(content.clone()),
3303 via: None,
3304 });
3305 emit(AssistantEvent::ToolResult {
3306 call_id: id.clone(),
3307 sequence,
3308 name: call.name.clone(),
3309 ok: false,
3310 content: content.clone(),
3311 });
3312 messages.push(Message::ToolResult {
3313 tool_use_id: provider_id.clone(),
3314 content,
3315 provenance: Provenance::Internal,
3316 });
3317 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3318 let _ = store
3319 .checkpoint(session_id, messages, "dispatch_refused", None)
3320 .await;
3321 }
3322 continue;
3323 }
3324 }
3325
3326 let exec = match runtime_session_id {
3327 Some(session_id) => runtime.execute_with_session(&proposal, session_id).await,
3328 None => runtime.execute(&proposal).await,
3329 };
3330 let action = exec.results.first();
3331 let runtime_succeeded = action
3332 .map(|r| matches!(r.status, ActionStatus::Succeeded))
3333 .unwrap_or(false);
3334 let ok = runtime_succeeded
3340 && (call.name != "shell"
3341 || action
3342 .and_then(|result| result.output.as_ref())
3343 .and_then(|output| output.get("exit_code"))
3344 .and_then(Value::as_i64)
3345 == Some(0));
3346 if needs_approval {
3347 let receipt = json!({
3348 "ok": ok,
3349 "action_id": action.map(|result| result.action_id.clone()),
3350 "status": action.map(|result| format!("{:?}", result.status)),
3351 });
3352 if let Some(gate) = approval {
3353 if let Err(e) = gate
3354 .after_dispatch(&id, &call.name, ¶ms_val, ok, &receipt)
3355 .await
3356 {
3357 let msg = format!(
3358 "action executed but its durable terminal receipt failed: {e}; action is indeterminate"
3359 );
3360 let raw_content = json!({ "error": msg }).to_string();
3361 tool_receipts.push(AssistantToolReceipt {
3362 tool: call.name.clone(),
3363 call_id: Some(id.clone()),
3364 sequence: Some(sequence),
3365 ok: false,
3366 params: params_val.clone(),
3367 result: Some(raw_content.clone()),
3368 via: None,
3369 });
3370 emit(AssistantEvent::ToolResult {
3371 call_id: id.clone(),
3372 sequence,
3373 name: call.name.clone(),
3374 ok: false,
3375 content: raw_content,
3376 });
3377 emit(AssistantEvent::Error(msg.clone()));
3378 return AssistantOutcome {
3379 status: "error",
3380 summary: msg,
3381 turns,
3382 turns_completed,
3383 tools_called,
3384 tool_receipts,
3385 prior_receipts,
3386 models_served: models_served.clone(),
3387 model_used: last_model,
3388 auth_required: None,
3389 failure_cause: None,
3390 };
3391 }
3392 }
3393 }
3394 let raw_content = action
3410 .map(format_tool_result)
3411 .unwrap_or_else(|| format!("tool '{}' produced no result", call.name));
3412 let content = match action {
3413 Some(r)
3414 if cfg.value_store_previews && matches!(r.status, ActionStatus::Succeeded) =>
3415 {
3416 match (&r.output, raw_content.len() > OBSERVATION_CAP) {
3417 (Some(v), true) => {
3418 let handle = values.put(v.clone());
3419 format!(
3420 "{}{}",
3421 super::value_store::render_preview(&handle, v),
3422 super::value_store::reference_hint(&handle)
3423 )
3424 }
3425 _ => cap(raw_content.clone()),
3428 }
3429 }
3430 _ => cap(raw_content.clone()),
3431 };
3432 if ok {
3433 tools_called.push(call.name.clone());
3434 if mutating_tools.contains(&call.name) {
3435 mutated_ok = true;
3436 }
3437 }
3438 tool_receipts.push(AssistantToolReceipt {
3439 tool: call.name.clone(),
3440 call_id: Some(id.clone()),
3441 sequence: Some(sequence),
3442 ok,
3443 params: params_val.clone(),
3444 result: Some(raw_content.clone()),
3445 via: None,
3446 });
3447 record_tool_outcome(
3454 cfg,
3455 &mut open_failures,
3456 &call.name,
3457 ok,
3458 &content,
3459 ¶ms_val,
3460 turns,
3461 );
3462 emit(AssistantEvent::ToolResult {
3463 call_id: id.clone(),
3464 sequence,
3465 name: call.name.clone(),
3466 ok,
3467 content: raw_content,
3468 });
3469 messages.push(Message::ToolResult {
3470 tool_use_id: provider_id,
3471 content,
3472 provenance: if tool_output_is_external(&call.name, tool_labels) {
3477 Provenance::External
3478 } else {
3479 Provenance::Internal
3480 },
3481 });
3482 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3483 if let Err(e) = store
3484 .checkpoint(session_id, messages, "tool_result", None)
3485 .await
3486 {
3487 let msg = format!("durable checkpoint failed after tool result: {e}");
3488 emit(AssistantEvent::Error(msg.clone()));
3489 return AssistantOutcome {
3490 status: "error",
3491 summary: msg,
3492 turns,
3493 turns_completed,
3494 tools_called,
3495 tool_receipts,
3496 prior_receipts,
3497 models_served: models_served.clone(),
3498 model_used: last_model,
3499 auth_required: None,
3500 failure_cause: None,
3501 };
3502 }
3503 }
3504 }
3505
3506 let mut inject_nudge = false;
3513 match guard.observe(&tool_calls_signature(&calls), mutated_ok) {
3514 GuardStep::Break => {
3515 let summary = format!(
3516 "Stopped: repeated the same action {} times without changing \
3517 anything — no progress was being made.",
3518 guard.stall_repeats
3519 );
3520 runtime
3521 .record_turn_completed("stalled", None, false, turns, &last_model)
3522 .await;
3523 emit(AssistantEvent::Done {
3524 text: summary.clone(),
3525 });
3526 return AssistantOutcome {
3527 status: "stalled",
3528 summary,
3529 turns,
3530 turns_completed,
3531 tools_called,
3532 tool_receipts,
3533 prior_receipts,
3534 models_served: models_served.clone(),
3535 model_used: last_model.clone(),
3536 auth_required: None,
3537 failure_cause: None,
3538 };
3539 }
3540 GuardStep::Nudge => inject_nudge = true,
3541 GuardStep::Progress | GuardStep::Continue => {}
3542 }
3543
3544 if inject_nudge {
3547 messages.push(Message::User {
3548 content: "You have repeated the same action several times without \
3549 changing anything or making progress. Stop re-reading and \
3550 either take a concrete action (write or edit a file, run a \
3551 command) or, if the task is genuinely complete, finish now \
3552 with your summary."
3553 .into(),
3554 });
3555 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3556 if let Err(e) = store
3557 .checkpoint(session_id, messages, "progress_nudge", None)
3558 .await
3559 {
3560 let msg = format!("durable checkpoint failed after progress nudge: {e}");
3561 emit(AssistantEvent::Error(msg.clone()));
3562 return AssistantOutcome {
3563 status: "error",
3564 summary: msg,
3565 turns,
3566 turns_completed,
3567 tools_called,
3568 tool_receipts,
3569 prior_receipts,
3570 models_served: models_served.clone(),
3571 model_used: last_model,
3572 auth_required: None,
3573 failure_cause: None,
3574 };
3575 }
3576 }
3577 }
3578 }
3579
3580 runtime
3581 .record_turn_completed("max_turns", None, false, turns, &last_model)
3582 .await;
3583 AssistantOutcome {
3584 status: "max_turns",
3585 summary: if last_text.is_empty() {
3586 format!("stopped after {} turns without finishing", cfg.max_turns)
3587 } else {
3588 last_text
3589 },
3590 turns,
3591 turns_completed,
3592 tools_called,
3593 tool_receipts,
3594 prior_receipts,
3595 models_served: models_served.clone(),
3596 model_used: last_model.clone(),
3597 auth_required: None,
3598 failure_cause: None,
3599 }
3600}
3601
3602#[derive(Debug, Clone)]
3603struct SummaryClaimRequirement {
3604 label: &'static str,
3605 tools: &'static [&'static str],
3606 require_ok: bool,
3607 shell_terms: &'static [&'static str],
3608 paths: Vec<String>,
3609}
3610
3611const TEST_TERMS: &[&str] = &[
3612 "test",
3613 "pytest",
3614 "cargo test",
3615 "cargo nextest",
3616 "npm test",
3617 "npm run test",
3618 "pnpm test",
3619 "pnpm run test",
3620 "yarn test",
3621 "bun test",
3622 "go test",
3623 "swift test",
3624 "dotnet test",
3625 "ctest",
3626 "cmake --build",
3627 "make test",
3628];
3629const BUILD_TERMS: &[&str] = &[
3630 "build",
3631 "cargo check",
3632 "cargo build",
3633 "npm run build",
3634 "pnpm build",
3635 "yarn build",
3636 "bun run build",
3637 "cmake --build",
3638 "go build",
3639 "swift build",
3640 "dotnet build",
3641 "mvn package",
3642 "gradle build",
3643 "./gradlew build",
3644];
3645const CHECK_TERMS: &[&str] = &[
3646 "cargo check",
3647 "git diff --check",
3648 "npm run lint",
3649 "npm run check",
3650 "pnpm check",
3651 "pnpm lint",
3652 "yarn check",
3653 "yarn lint",
3654 "bun run check",
3655 "eslint",
3656 "clippy",
3657 "swiftlint",
3658 "ruff",
3659 "mypy",
3660 "biome check",
3661];
3662const READ_TERMS: &[&str] = &[
3674 "cat ", "sed ", "rg ", "grep ", "ls ", "find ", "type ", "findstr ", ];
3677const WRITE_TERMS: &[&str] = &[
3678 "touch ",
3679 "cat >",
3680 "tee ",
3681 "python ",
3682 "node ",
3683 "perl ", "type nul >",
3685 "echo >", ];
3687const GIT_STATUS_TERMS: &[&str] = &["git status"];
3688const GIT_REVISION_TERMS: &[&str] = &["git rev-parse", "git log", "git show"];
3689const APP_INSIGHTS_TERMS: &[&str] = &["az monitor app-insights query"];
3690const DEPLOYMENT_EVIDENCE_TERMS: &[&str] = &[
3691 "az pipelines show",
3692 "az pipelines runs show",
3693 "az devops invoke",
3694];
3695const SUMMARY_PATH_EXTENSIONS: &[&str] = &[
3696 ".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".swift", ".java", ".kt", ".kts", ".c",
3697 ".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cs", ".fs", ".vb", ".php", ".rb", ".ex",
3698 ".exs", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".html", ".css", ".xml", ".sh",
3699 ".sql",
3700];
3701
3702fn normalize_summary_path_token(raw: &str) -> Option<String> {
3703 let token = raw.trim_matches(|c: char| {
3704 matches!(
3705 c,
3706 '"' | '\'' | '`' | ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}' | '.'
3707 )
3708 });
3709 if token.is_empty() || token.starts_with('-') || token.contains("://") || token.contains("..") {
3710 return None;
3711 }
3712 let looks_like_path = token.contains('/')
3713 || SUMMARY_PATH_EXTENSIONS
3714 .iter()
3715 .any(|ext| token.to_ascii_lowercase().ends_with(ext));
3716 if !looks_like_path {
3717 return None;
3718 }
3719 Some(
3720 token
3721 .trim_start_matches("./")
3722 .replace('\\', "/")
3723 .to_ascii_lowercase(),
3724 )
3725}
3726
3727fn summary_path_hints(summary: &str) -> Vec<String> {
3728 let mut paths = Vec::new();
3729 for raw in summary.split_whitespace() {
3730 if let Some(path) = normalize_summary_path_token(raw) {
3731 if !paths.contains(&path) {
3732 paths.push(path);
3733 }
3734 }
3735 }
3736 paths
3737}
3738
3739fn summary_claim_requirements(summary: &str) -> Vec<SummaryClaimRequirement> {
3740 let s = summary.to_ascii_lowercase();
3741 let units: Vec<&str> = s
3742 .split(['\n', '.'])
3743 .map(str::trim)
3744 .filter(|unit| !unit.is_empty())
3745 .collect();
3746 let path_hints = summary_path_hints(summary);
3747 let mut claims = Vec::new();
3748 if units.iter().any(|unit| {
3749 unit.contains("ran the test")
3750 || unit.contains("ran tests")
3751 || (unit.contains("verified with") && unit.contains("test"))
3752 || (unit.contains("test")
3753 && (unit.contains("passed")
3754 || unit.contains("green")
3755 || unit.contains("succeeded")
3756 || unit.contains("successful")))
3757 }) {
3758 claims.push(SummaryClaimRequirement {
3759 label: "tests were run/passed",
3760 tools: &["shell"],
3761 require_ok: true,
3762 shell_terms: TEST_TERMS,
3763 paths: Vec::new(),
3764 });
3765 }
3766 if units.iter().any(|unit| {
3767 (unit.contains("build") || unit.contains("cargo check"))
3768 && (unit.contains("passed")
3769 || unit.contains("succeeded")
3770 || unit.contains("successful")
3771 || unit.contains("built")
3772 || unit.contains("green")
3773 || unit.contains("ran the build")
3774 || unit.contains("ran cargo check"))
3775 }) {
3776 claims.push(SummaryClaimRequirement {
3777 label: "build succeeded",
3778 tools: &["shell"],
3779 require_ok: true,
3780 shell_terms: BUILD_TERMS,
3781 paths: Vec::new(),
3782 });
3783 }
3784 if units.iter().any(|unit| {
3785 (unit.contains("check") || unit.contains("lint"))
3786 && (unit.contains("passed")
3787 || unit.contains("green")
3788 || unit.contains("succeeded")
3789 || unit.contains("successful"))
3790 }) {
3791 claims.push(SummaryClaimRequirement {
3792 label: "checks were run/passed",
3793 tools: &["shell"],
3794 require_ok: true,
3795 shell_terms: CHECK_TERMS,
3796 paths: Vec::new(),
3797 });
3798 }
3799 if units.iter().any(|unit| {
3800 (unit.contains("read ") || unit.contains("inspected ") || unit.contains("looked at "))
3801 && (unit.contains("file") || unit.contains("source"))
3802 }) {
3803 claims.push(SummaryClaimRequirement {
3804 label: "files were read/inspected",
3805 tools: &["read_file", "list_dir", "find_files", "grep_files", "shell"],
3806 require_ok: true,
3807 shell_terms: READ_TERMS,
3808 paths: path_hints.clone(),
3809 });
3810 }
3811 if units.iter().any(|unit| {
3812 (unit.contains("created")
3813 || unit.contains("wrote")
3814 || unit.contains("updated")
3815 || unit.contains("edited"))
3816 && unit.contains("file")
3817 }) {
3818 claims.push(SummaryClaimRequirement {
3819 label: "files were created/updated",
3820 tools: &["write_file", "edit_file", "shell"],
3821 require_ok: true,
3822 shell_terms: WRITE_TERMS,
3823 paths: path_hints.clone(),
3824 });
3825 }
3826 if units.iter().any(|unit| {
3827 unit.contains("repository is clean")
3828 || unit.contains("repo is clean")
3829 || unit.contains("working tree is clean")
3830 || unit.contains("status: clean")
3831 }) {
3832 claims.push(SummaryClaimRequirement {
3833 label: "repository cleanliness was verified",
3834 tools: &["shell"],
3835 require_ok: true,
3836 shell_terms: GIT_STATUS_TERMS,
3837 paths: Vec::new(),
3838 });
3839 }
3840 if units.iter().any(|unit| {
3841 unit.contains("head matches origin")
3842 || unit.contains("head is aligned with origin")
3843 || unit.contains("head and origin are identical")
3844 }) {
3845 claims.push(SummaryClaimRequirement {
3846 label: "repository revision/remote relationship was verified",
3847 tools: &["shell"],
3848 require_ok: true,
3849 shell_terms: GIT_REVISION_TERMS,
3850 paths: Vec::new(),
3851 });
3852 }
3853 if units.iter().any(|unit| {
3854 (unit.contains("app insights")
3855 || unit.contains("application insights")
3856 || unit.contains("telemetry"))
3857 && (unit.contains("query showed")
3858 || unit.contains("query confirmed")
3859 || unit.contains("we observed")
3860 || unit.contains("live telemetry showed")
3861 || unit.contains("no recurrence")
3862 || unit.contains("recurred after"))
3863 && !unit.contains("not obtained")
3864 && !unit.contains("unable")
3865 }) {
3866 claims.push(SummaryClaimRequirement {
3867 label: "live Application Insights evidence was observed",
3868 tools: &["shell", "browse_observe"],
3869 require_ok: true,
3870 shell_terms: APP_INSIGHTS_TERMS,
3871 paths: Vec::new(),
3872 });
3873 }
3874 if units.iter().any(|unit| {
3875 (unit.contains("production") || unit.contains("live"))
3876 && (unit.contains("browser") || unit.contains("portal") || unit.contains("page"))
3877 && (unit.contains("inspected")
3878 || unit.contains("observed")
3879 || unit.contains("verified"))
3880 && !unit.contains("not obtained")
3881 && !unit.contains("unable")
3882 }) {
3883 claims.push(SummaryClaimRequirement {
3884 label: "production browser state was observed",
3885 tools: &["browse_observe"],
3886 require_ok: true,
3887 shell_terms: &[],
3888 paths: Vec::new(),
3889 });
3890 }
3891 if units.iter().any(|unit| {
3892 unit.contains("deployment")
3893 && (unit.contains("successfully fixed")
3894 || unit.contains("was deployed")
3895 || unit.contains("after fix")
3896 || unit.contains("post-deployment"))
3897 && !unit.contains("cannot")
3898 && !unit.contains("not obtained")
3899 }) {
3900 claims.push(SummaryClaimRequirement {
3901 label: "deployment state/change was verified",
3902 tools: &["shell"],
3903 require_ok: true,
3904 shell_terms: DEPLOYMENT_EVIDENCE_TERMS,
3905 paths: Vec::new(),
3906 });
3907 }
3908 if units.iter().any(|unit| {
3909 unit.contains("subscription")
3910 && (unit.contains("outside") || unit.contains("not in"))
3911 && unit
3912 .as_bytes()
3913 .windows(2)
3914 .any(|window| window[0] == b'n' && window[1].is_ascii_digit())
3915 && !unit.contains("cannot")
3916 && !unit.contains("not verified")
3917 && !unit.contains("not obtained")
3918 && !unit.contains("insufficient evidence")
3919 }) {
3920 claims.push(SummaryClaimRequirement {
3921 label: "named aircraft subscription status was observed live",
3922 tools: &["shell"],
3923 require_ok: true,
3924 shell_terms: APP_INSIGHTS_TERMS,
3925 paths: Vec::new(),
3926 });
3927 }
3928 claims
3929}
3930
3931fn shell_command(params: &Value) -> Option<String> {
3932 params
3933 .get("command")
3934 .and_then(Value::as_str)
3935 .map(|s| s.to_ascii_lowercase())
3936}
3937
3938fn normalized_receipt_path(params: &Value) -> Option<String> {
3939 params.get("path").and_then(Value::as_str).map(|path| {
3940 path.trim_start_matches("./")
3941 .replace('\\', "/")
3942 .to_ascii_lowercase()
3943 })
3944}
3945
3946fn text_mentions_summary_path(text: &str, path: &str) -> bool {
3947 let text = text.replace('\\', "/").to_ascii_lowercase();
3948 text.contains(path) || text.contains(&format!("./{path}"))
3949}
3950
3951fn receipt_mentions_summary_path(receipt: &AssistantToolReceipt, path: &str) -> bool {
3952 if receipt.tool == "shell" {
3953 return shell_command(&receipt.params)
3954 .map(|cmd| text_mentions_summary_path(&cmd, path))
3955 .unwrap_or(false);
3956 }
3957 normalized_receipt_path(&receipt.params)
3958 .map(|receipt_path| text_mentions_summary_path(&receipt_path, path))
3959 .unwrap_or(false)
3960}
3961
3962fn receipt_satisfies_claim(
3963 receipt: &AssistantToolReceipt,
3964 claim: &SummaryClaimRequirement,
3965) -> bool {
3966 if claim.require_ok && !receipt.ok {
3967 return false;
3968 }
3969 if !claim.tools.iter().any(|t| *t == receipt.tool) {
3970 return false;
3971 }
3972 if !claim.paths.is_empty()
3973 && !claim
3974 .paths
3975 .iter()
3976 .any(|path| receipt_mentions_summary_path(receipt, path))
3977 {
3978 return false;
3979 }
3980 if receipt.tool != "shell" || claim.shell_terms.is_empty() {
3981 return true;
3982 }
3983 let Some(cmd) = shell_command(&receipt.params) else {
3984 return false;
3985 };
3986 claim.shell_terms.iter().any(|term| cmd.contains(term))
3987}
3988
3989pub fn ungrounded_summary_claims(
4003 summary: &str,
4004 receipts: &[AssistantToolReceipt],
4005) -> Vec<&'static str> {
4006 summary_claim_requirements(summary)
4007 .into_iter()
4008 .filter(|claim| {
4009 !receipts
4010 .iter()
4011 .any(|receipt| receipt_satisfies_claim(receipt, claim))
4012 })
4013 .map(|claim| claim.label)
4014 .collect()
4015}
4016
4017fn apply_summary_claim_grounding(
4018 mut verdict: car_verify::goal::GoalVerdict,
4019 outcome: &AssistantOutcome,
4020) -> car_verify::goal::GoalVerdict {
4021 if !verdict.met {
4022 return verdict;
4023 }
4024 let ungrounded = ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
4025 if ungrounded.is_empty() {
4026 return verdict;
4027 }
4028 verdict.grounded = false;
4029 verdict.reason = format!(
4030 "{}; ungrounded assistant summary claim(s): {}",
4031 verdict.reason,
4032 ungrounded.join(", ")
4033 );
4034 verdict
4035}
4036
4037pub fn annotate_summary_with_claim_note(summary: &str, ungrounded: &[&'static str]) -> String {
4047 if ungrounded.is_empty() {
4048 return summary.to_string();
4049 }
4050 format!(
4051 "{summary}\n\n[claim check] unverified summary claim(s) this run \
4052 (no matching tool receipt): {}",
4053 ungrounded.join(", ")
4054 )
4055}
4056
4057pub struct GoalLoopResult {
4061 pub outcome: AssistantOutcome,
4062 pub run: car_verify::goal::GoalRun,
4063}
4064
4065const GOAL_EVALUATION_TIMEOUT: Duration =
4073 Duration::from_secs(crate::coder::shell_tool::DEFAULT_SHELL_TIMEOUT_SECS);
4074
4075pub async fn run_assistant_goal_loop<G, GF>(
4089 generator: &dyn TurnGenerator,
4090 runtime: &Runtime,
4091 cfg: &AssistantConfig,
4092 messages: &mut Vec<Message>,
4093 cancel: &std::sync::atomic::AtomicBool,
4094 approval: Option<&dyn ApprovalGate>,
4095 spec: &car_verify::goal::GoalSpec,
4096 gather: G,
4097 emit: impl FnMut(AssistantEvent),
4098) -> GoalLoopResult
4099where
4100 G: FnMut(&AssistantOutcome) -> GF,
4101 GF: std::future::Future<Output = car_engine::GoalGather>,
4102{
4103 run_assistant_goal_loop_in_session(
4104 generator, runtime, cfg, messages, cancel, approval, spec, None, gather, emit,
4105 )
4106 .await
4107}
4108
4109pub async fn run_assistant_goal_loop_in_session<G, GF>(
4111 generator: &dyn TurnGenerator,
4112 runtime: &Runtime,
4113 cfg: &AssistantConfig,
4114 messages: &mut Vec<Message>,
4115 cancel: &std::sync::atomic::AtomicBool,
4116 approval: Option<&dyn ApprovalGate>,
4117 spec: &car_verify::goal::GoalSpec,
4118 runtime_session_id: Option<&str>,
4119 gather: G,
4120 emit: impl FnMut(AssistantEvent),
4121) -> GoalLoopResult
4122where
4123 G: FnMut(&AssistantOutcome) -> GF,
4124 GF: std::future::Future<Output = car_engine::GoalGather>,
4125{
4126 run_assistant_goal_loop_in_session_durable(
4127 generator,
4128 runtime,
4129 cfg,
4130 messages,
4131 cancel,
4132 approval,
4133 spec,
4134 runtime_session_id,
4135 None,
4136 None,
4137 gather,
4138 emit,
4139 )
4140 .await
4141}
4142
4143pub async fn run_assistant_goal_loop_in_session_durable<G, GF>(
4144 generator: &dyn TurnGenerator,
4145 runtime: &Runtime,
4146 cfg: &AssistantConfig,
4147 messages: &mut Vec<Message>,
4148 cancel: &std::sync::atomic::AtomicBool,
4149 approval: Option<&dyn ApprovalGate>,
4150 spec: &car_verify::goal::GoalSpec,
4151 runtime_session_id: Option<&str>,
4152 durable_session_id: Option<&str>,
4153 durability: Option<&dyn super::governance::AssistantDurability>,
4154 mut gather: G,
4155 mut emit: impl FnMut(AssistantEvent),
4156) -> GoalLoopResult
4157where
4158 G: FnMut(&AssistantOutcome) -> GF,
4159 GF: std::future::Future<Output = car_engine::GoalGather>,
4160{
4161 use car_verify::goal::{
4162 anchor_directive, evaluate_goal, governor_check, GoalHalt, GoalRun, GoalRunState,
4163 GoalStatus, GoalVerdict,
4164 };
4165 use std::sync::atomic::Ordering;
4166
4167 let start = std::time::Instant::now();
4168 let mut run_state = GoalRunState::default();
4169 let mut evidence: Vec<GoalVerdict> = Vec::new();
4170 let mut all_models_served = Vec::new();
4171 let mut last_reason = String::new();
4172 let mut last_outcome = AssistantOutcome {
4173 status: "goal_pending",
4174 summary: String::new(),
4175 turns: 0,
4176 turns_completed: 0,
4177 tools_called: Vec::new(),
4178 tool_receipts: Vec::new(),
4179 prior_receipts: 0,
4180 models_served: Vec::new(),
4181 model_used: String::new(),
4182 auth_required: None,
4183 failure_cause: None,
4184 };
4185
4186 let finish = |status: GoalStatus,
4187 grounded: bool,
4188 reason: String,
4189 iterations: u32,
4190 evidence: Vec<GoalVerdict>,
4191 outcome: AssistantOutcome|
4192 -> GoalLoopResult {
4193 GoalLoopResult {
4194 run: GoalRun {
4195 status,
4196 iterations,
4197 grounded,
4198 cost_usd: 0.0,
4199 last_reason: reason,
4200 evidence,
4201 },
4202 outcome,
4203 }
4204 };
4205
4206 loop {
4207 run_state.elapsed_secs = start.elapsed().as_secs();
4208 if cancel.load(Ordering::Relaxed) {
4209 return finish(
4210 GoalStatus::Halted {
4211 halt: GoalHalt::Cancelled,
4212 },
4213 evidence.last().map(|v| v.grounded).unwrap_or(true),
4214 "cancelled".into(),
4215 run_state.turns,
4216 evidence,
4217 last_outcome,
4218 );
4219 }
4220 if let Some(halt) = governor_check(&spec.governor, &run_state) {
4221 return finish(
4222 GoalStatus::Halted { halt },
4223 evidence.last().map(|v| v.grounded).unwrap_or(true),
4224 if last_reason.is_empty() {
4225 halt.as_str().to_string()
4226 } else {
4227 format!("{} ({})", halt.as_str(), last_reason)
4228 },
4229 run_state.turns,
4230 evidence,
4231 last_outcome,
4232 );
4233 }
4234
4235 let directive = anchor_directive(&spec.goal, &last_reason);
4238 messages.push(Message::User { content: directive });
4239 if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
4240 if let Err(e) = store
4241 .checkpoint(
4242 session_id,
4243 messages,
4244 "goal_directive",
4245 serde_json::to_value(spec).ok(),
4246 )
4247 .await
4248 {
4249 last_outcome.status = "error";
4250 last_outcome.summary = format!("durable goal checkpoint failed: {e}");
4251 return finish(
4252 GoalStatus::Halted {
4253 halt: GoalHalt::Cancelled,
4254 },
4255 false,
4256 last_outcome.summary.clone(),
4257 run_state.turns,
4258 evidence,
4259 last_outcome,
4260 );
4261 }
4262 }
4263
4264 let mut outcome = run_assistant_loop_cancellable_in_session_durable(
4265 generator,
4266 runtime,
4267 cfg,
4268 messages,
4269 cancel,
4270 approval,
4271 None,
4272 runtime_session_id,
4273 durable_session_id,
4274 durability,
4275 false,
4276 &mut emit,
4277 )
4278 .await;
4279 all_models_served.append(&mut outcome.models_served);
4280 outcome.models_served = all_models_served.clone();
4281 run_state.turns += 1;
4282 if outcome.tools_called.is_empty() {
4285 run_state.turns_since_progress += 1;
4286 } else {
4287 run_state.turns_since_progress = 0;
4288 }
4289
4290 if outcome.status == "cancelled" {
4291 return finish(
4292 GoalStatus::Halted {
4293 halt: GoalHalt::Cancelled,
4294 },
4295 evidence.last().map(|v| v.grounded).unwrap_or(true),
4296 "cancelled".into(),
4297 run_state.turns,
4298 evidence,
4299 outcome,
4300 );
4301 }
4302
4303 if outcome.status == "auth_required" {
4309 let reason = outcome.summary.clone();
4310 return finish(
4311 GoalStatus::Halted {
4312 halt: GoalHalt::AuthRequired,
4313 },
4314 false,
4317 reason,
4318 run_state.turns,
4319 evidence,
4320 outcome,
4321 );
4322 }
4323
4324 let g = match tokio::time::timeout(GOAL_EVALUATION_TIMEOUT, gather(&outcome)).await {
4332 Ok(g) => g,
4333 Err(_) => {
4334 let reason = format!(
4335 "goal check did not complete within {}s — treating this turn's reply as \
4336 unevaluated rather than blocking on it",
4337 GOAL_EVALUATION_TIMEOUT.as_secs()
4338 );
4339 let verdict = GoalVerdict {
4352 met: false,
4353 grounded: false,
4354 reason: reason.clone(),
4355 };
4356 evidence.push(verdict.clone());
4357 runtime
4358 .record_goal_evaluated(
4359 &spec.goal,
4360 &spec.condition,
4361 run_state.turns,
4362 verdict.met,
4363 verdict.grounded,
4364 &verdict.reason,
4365 &outcome.model_used,
4366 )
4367 .await;
4368 tracing::warn!(
4369 target: "car::goal",
4370 iteration = run_state.turns,
4371 timeout_secs = GOAL_EVALUATION_TIMEOUT.as_secs(),
4372 "goal evaluation timed out — halting with the primary reply intact"
4373 );
4374 emit(AssistantEvent::GoalEvaluated {
4375 iteration: run_state.turns,
4376 met: false,
4377 grounded: false,
4378 reason: reason.clone(),
4379 });
4380 return finish(
4381 GoalStatus::Halted {
4382 halt: GoalHalt::EvaluationTimeout,
4383 },
4384 false,
4385 reason,
4386 run_state.turns,
4387 evidence,
4388 outcome,
4389 );
4390 }
4391 };
4392 let inputs = runtime.gather_goal_inputs(&g).await;
4393
4394 let base = evaluate_goal(&spec.condition, &inputs);
4399 let verdict = if base.met && base.grounded {
4400 let ungrounded = ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
4411 if !ungrounded.is_empty() {
4412 tracing::info!(
4413 target: "car::goal",
4414 iteration = run_state.turns,
4415 claims = %ungrounded.join(", "),
4416 "deterministic goal check passed; final-summary claim(s) unmatched \
4417 to a tool receipt — annotating reply, keeping grounded=true"
4418 );
4419 outcome.summary = annotate_summary_with_claim_note(&outcome.summary, &ungrounded);
4420 }
4421 base
4422 } else {
4423 apply_summary_claim_grounding(base, &outcome)
4429 };
4430 evidence.push(verdict.clone());
4431 runtime
4432 .record_goal_evaluated(
4433 &spec.goal,
4434 &spec.condition,
4435 run_state.turns,
4436 verdict.met,
4437 verdict.grounded,
4438 &verdict.reason,
4439 &outcome.model_used,
4440 )
4441 .await;
4442 tracing::info!(
4445 target: "car::goal",
4446 iteration = run_state.turns,
4447 met = verdict.met,
4448 grounded = verdict.grounded,
4449 reason = %verdict.reason,
4450 "goal evaluated"
4451 );
4452 emit(AssistantEvent::GoalEvaluated {
4453 iteration: run_state.turns,
4454 met: verdict.met,
4455 grounded: verdict.grounded,
4456 reason: verdict.reason.clone(),
4457 });
4458
4459 if verdict.met && verdict.grounded {
4460 return finish(
4461 GoalStatus::Achieved,
4462 verdict.grounded,
4463 verdict.reason,
4464 run_state.turns,
4465 evidence,
4466 outcome,
4467 );
4468 }
4469 last_reason = verdict.reason;
4470 last_outcome = outcome;
4471 }
4472}
4473
4474#[cfg(test)]
4475mod tests {
4476 use super::*;
4477 use crate::assistant::executor::GeneralExecutor;
4478 use async_trait::async_trait;
4479 use car_engine::{LocalSubstrate, Runtime, Substrate, ToolExecutor};
4480 use car_inference::{InferenceEngine, InferenceError, InferenceResult};
4481 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4482 use std::sync::{Arc, Mutex as StdMutex};
4483
4484 #[test]
4494 fn truncation_reports_true_size_and_elided_amount() {
4495 let total = OBSERVATION_CAP + 5_000;
4496 let out = cap("x".repeat(total));
4497
4498 assert!(
4499 out.contains(&format!("of {total} bytes")),
4500 "the TRUE size must be reported, not just the fact of truncation: {}",
4501 &out[out.len().saturating_sub(200)..]
4502 );
4503 assert!(
4504 out.contains("5000 bytes elided"),
4505 "the elided amount must be reported so the model can judge the loss: {}",
4506 &out[out.len().saturating_sub(200)..]
4507 );
4508 assert!(
4509 out.contains("NOT retained"),
4510 "the model must be told re-running is the only recovery"
4511 );
4512 assert!(out.starts_with(&"x".repeat(1_000)));
4514 }
4515
4516 #[test]
4519 fn observations_within_the_cap_are_unmodified() {
4520 let small = "y".repeat(OBSERVATION_CAP);
4521 assert_eq!(cap(small.clone()), small);
4522 let tiny = "hello".to_string();
4523 assert_eq!(cap(tiny.clone()), tiny);
4524 }
4525
4526 #[test]
4529 fn truncation_respects_char_boundaries() {
4530 let s = "€".repeat(OBSERVATION_CAP);
4532 let out = cap(s);
4533 assert!(out.contains("bytes elided"));
4534 assert!(out.is_char_boundary(0));
4535 }
4536
4537 #[test]
4540 fn guard_breaks_on_repeated_mutation_not_just_reads() {
4541 let mut g = NoProgressGuard::default();
4546 assert_eq!(
4548 g.observe("remember({\"body\":\"x\"})", true),
4549 GuardStep::Progress
4550 );
4551 let sig = "remember({\"body\":\"x\"})";
4554 let mut steps = vec![];
4555 for _ in 0..STALL_BREAK {
4556 steps.push(g.observe(sig, true));
4557 }
4558 assert!(
4559 steps.contains(&GuardStep::Break),
4560 "repeated identical mutation must eventually Break, got {steps:?}"
4561 );
4562 assert!(
4563 steps.contains(&GuardStep::Nudge),
4564 "should nudge before breaking"
4565 );
4566 }
4567
4568 #[test]
4569 fn guard_treats_distinct_mutations_as_progress() {
4570 let mut g = NoProgressGuard::default();
4572 for i in 0..20 {
4573 let sig = format!("remember({{\"body\":\"fact-{i}\"}})");
4574 assert_eq!(g.observe(&sig, true), GuardStep::Progress);
4575 }
4576 }
4577
4578 #[test]
4579 fn guard_read_only_repeat_still_breaks() {
4580 let mut g = NoProgressGuard::default();
4582 let mut steps = vec![];
4583 for _ in 0..(STALL_BREAK + 1) {
4584 steps.push(g.observe("recall({\"q\":\"x\"})", false));
4585 }
4586 assert!(steps.contains(&GuardStep::Break));
4587 }
4588
4589 #[test]
4590 fn guard_read_only_task_never_hard_stops_without_repeat() {
4591 let mut g = NoProgressGuard::default();
4594 let mut steps = vec![];
4595 for i in 0..(EXPLORE_NUDGE + 5) {
4596 steps.push(g.observe(&format!("read_file({{\"p\":\"f{i}\"}})"), false));
4597 }
4598 assert!(
4599 !steps.contains(&GuardStep::Break),
4600 "distinct reads must not Break"
4601 );
4602 assert!(
4603 steps.contains(&GuardStep::Nudge),
4604 "should soft-nudge after EXPLORE_NUDGE"
4605 );
4606 }
4607
4608 fn sys(t: &str) -> Message {
4611 Message::System { content: t.into() }
4612 }
4613 fn usr(t: &str) -> Message {
4614 Message::User { content: t.into() }
4615 }
4616 fn asst_call(id: &str) -> Message {
4617 Message::Assistant {
4618 content: String::new(),
4619 tool_calls: vec![serde_json::from_value(json!({
4620 "name": "write_file",
4621 "arguments": {"path": "a.js"},
4622 "id": id
4623 }))
4624 .unwrap()],
4625 thinking: vec![],
4626 model_id: None,
4627 local_last_resort: false,
4628 }
4629 }
4630 fn tool_res(id: &str, body: &str) -> Message {
4631 Message::ToolResult {
4632 tool_use_id: id.into(),
4633 content: body.into(),
4634 provenance: Default::default(),
4635 }
4636 }
4637 fn provider_item(id: &str, body: &str) -> Message {
4638 Message::ProviderOutputItems {
4639 protocol: car_inference::protocol::OPENAI_RESPONSES_PROTOCOL.into(),
4640 items: vec![json!({
4641 "type": "reasoning",
4642 "id": id,
4643 "status": "completed",
4644 "encrypted_content": body,
4645 })],
4646 }
4647 }
4648
4649 #[test]
4650 fn transcript_receipts_do_not_trust_repeated_provider_ids() {
4651 let call = |expression: &str| {
4652 serde_json::from_value(json!({
4653 "name": "calculate",
4654 "arguments": {"expression": expression},
4655 "id": "repeated"
4656 }))
4657 .unwrap()
4658 };
4659 let messages = vec![
4660 Message::Assistant {
4661 content: String::new(),
4662 tool_calls: vec![call("2+2"), call("3+3")],
4663 thinking: vec![],
4664 model_id: None,
4665 local_last_resort: false,
4666 },
4667 tool_res("repeated", r#"{"result":4}"#),
4668 tool_res("repeated", r#"{"result":6}"#),
4669 ];
4670
4671 let receipts = transcript_tool_receipts(&messages);
4672 assert_eq!(receipts.len(), 2);
4673 assert_eq!(receipts[0].call_id.as_deref(), Some("turn_1_call_1"));
4674 assert_eq!(receipts[1].call_id.as_deref(), Some("turn_1_call_2"));
4675 assert_eq!(receipts[0].sequence, Some(1));
4676 assert_eq!(receipts[1].sequence, Some(2));
4677 assert_eq!(receipts[0].params["expression"], "2+2");
4678 assert_eq!(receipts[1].params["expression"], "3+3");
4679 }
4680
4681 fn no_orphan_tool_results(msgs: &[Message]) -> bool {
4684 let mut seen_call_ids: std::collections::HashSet<String> = Default::default();
4685 for m in msgs {
4686 match m {
4687 Message::Assistant { tool_calls, .. } => {
4688 for c in tool_calls {
4689 if let Some(id) = &c.id {
4690 seen_call_ids.insert(id.clone());
4691 }
4692 }
4693 }
4694 Message::ToolResult { tool_use_id, .. } if !seen_call_ids.contains(tool_use_id) => {
4695 return false;
4696 }
4697 _ => {}
4698 }
4699 }
4700 true
4701 }
4702
4703 #[test]
4704 fn mutating_tools_are_derived_from_metadata_plus_builtin_file_writers() {
4705 let tools = vec![
4706 json!({"name": "remember", "mutating": true}),
4707 json!({"name": "recall"}),
4708 json!({"name": "generate_image", "mutating": true}),
4709 ];
4710 let names = mutating_tool_names(&tools);
4711
4712 assert!(names.contains("write_file"));
4713 assert!(names.contains("edit_file"));
4714 assert!(names.contains("remember"));
4715 assert!(names.contains("generate_image"));
4716 assert!(!names.contains("recall"));
4717 }
4718
4719 #[test]
4720 fn compaction_is_noop_under_budget_and_when_window_unknown() {
4721 let mut m = vec![
4722 sys("s"),
4723 usr("task"),
4724 asst_call("c1"),
4725 tool_res("c1", "small"),
4726 ];
4727 let before = m.clone();
4728 compact_history_to_window(&mut m, 128_000); assert_eq!(m, before, "under-budget history must be untouched");
4730 compact_history_to_window(&mut m, 0); assert_eq!(m, before, "unknown window must be a no-op");
4732 }
4733
4734 #[test]
4735 fn compaction_pins_system_and_task_keeps_tail_no_orphans() {
4736 let big = "x".repeat(20_000); let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
4738 for i in 0..12 {
4739 m.push(asst_call(&format!("c{i}")));
4740 m.push(tool_res(&format!("c{i}"), &big));
4741 }
4742 let window = 20_000; compact_history_to_window(&mut m, window);
4744
4745 assert!(matches!(&m[0], Message::System { .. }), "system pinned");
4747 assert!(
4748 matches!(&m[1], Message::User { content } if content == "THE ORIGINAL TASK"),
4749 "original task pinned"
4750 );
4751 assert!(
4753 matches!(m.last(), Some(Message::ToolResult { tool_use_id, .. }) if tool_use_id == "c11"),
4754 "most-recent tool result kept"
4755 );
4756 assert!(
4758 no_orphan_tool_results(&m),
4759 "no orphaned tool results after trim"
4760 );
4761 assert!(m.len() < 26, "history was compacted (was 26 msgs)");
4763 }
4764
4765 #[test]
4774 fn state_block_lands_at_the_tail_inside_the_last_message() {
4775 let mut messages = vec![
4776 sys("system prompt"),
4777 usr("do the thing"),
4778 tool_res("c1", "tool output here"),
4779 ];
4780 let before_prefix = format!("{:?}{:?}", messages[0], messages[1]);
4781
4782 append_state_block(&mut messages, "todo: 1/3 done\n [ ] 2 wire the CLI");
4783
4784 let Message::ToolResult { content, .. } = &messages[2] else {
4786 panic!("last message should still be the tool result");
4787 };
4788 assert!(content.starts_with("tool output here"), "{content}");
4789 assert!(
4790 content.contains("wire the CLI"),
4791 "state must be present: {content}"
4792 );
4793 assert!(content.contains("<runtime-state>"), "{content}");
4795 assert!(content.contains("</runtime-state>"), "{content}");
4796 assert_eq!(messages.len(), 3);
4798 assert_eq!(
4800 before_prefix,
4801 format!("{:?}{:?}", messages[0], messages[1]),
4802 "appending state must not perturb the cached prefix"
4803 );
4804 }
4805
4806 #[tokio::test]
4809 async fn state_block_never_enters_the_durable_history() {
4810 let dir = tempfile::tempdir().unwrap();
4811 let rt = runtime_for(dir.path()).await;
4812 let todos = Arc::new(tokio::sync::Mutex::new(super::super::todo::TodoList::new()));
4813 todos
4814 .lock()
4815 .await
4816 .write(&[json!({"text": "wire the CLI"})])
4817 .unwrap();
4818
4819 let seen = Arc::new(StdMutex::new(Vec::new()));
4820 let script = CapturingScript {
4821 turns: vec![turn("done", json!([]))],
4822 cursor: AtomicUsize::new(0),
4823 seen: Arc::clone(&seen),
4824 };
4825 let mut messages = vec![sys("sys"), usr("do it")];
4826 let mut cfg = cfg();
4827 cfg.todos = Some(Arc::clone(&todos));
4828 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
4829
4830 let sent = seen.lock().unwrap();
4832 let sent_msgs = sent[0].messages.as_ref().expect("messages sent");
4833 let tail = format!("{:?}", sent_msgs.last().unwrap());
4834 assert!(
4835 tail.contains("wire the CLI"),
4836 "the model must see live state: {tail}"
4837 );
4838
4839 assert!(
4841 !messages
4842 .iter()
4843 .any(|m| format!("{m:?}").contains("<runtime-state>")),
4844 "the block must not persist into history, or it stacks one copy per turn"
4845 );
4846 }
4847
4848 #[tokio::test]
4851 async fn no_state_block_when_there_is_nothing_to_say() {
4852 let dir = tempfile::tempdir().unwrap();
4853 let rt = runtime_for(dir.path()).await;
4854 let seen = Arc::new(StdMutex::new(Vec::new()));
4855 let script = CapturingScript {
4856 turns: vec![turn("done", json!([]))],
4857 cursor: AtomicUsize::new(0),
4858 seen: Arc::clone(&seen),
4859 };
4860 let mut messages = vec![sys("sys"), usr("do it")];
4861 let mut cfg = cfg();
4862 cfg.todos = Some(Arc::new(tokio::sync::Mutex::new(
4863 super::super::todo::TodoList::new(),
4864 )));
4865 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
4866
4867 let sent = seen.lock().unwrap();
4868 let all = format!("{:?}", sent[0].messages);
4869 assert!(
4870 !all.contains("<runtime-state>"),
4871 "empty plan must render nothing: {all}"
4872 );
4873 }
4874
4875 fn remember_receipt(subject: &str, ok: bool) -> AssistantToolReceipt {
4876 AssistantToolReceipt {
4877 tool: "remember".to_string(),
4878 call_id: None,
4879 sequence: None,
4880 ok,
4881 params: json!({"subject": subject, "body": "…"}),
4882 result: None,
4883 via: None,
4884 }
4885 }
4886
4887 #[test]
4894 fn written_facts_reach_the_state_block_without_being_asked_for() {
4895 let receipts = [
4896 remember_receipt("deploy target", true),
4897 AssistantToolReceipt {
4898 tool: "read_file".to_string(),
4899 call_id: None,
4900 sequence: None,
4901 ok: true,
4902 params: json!({"path": "x"}),
4903 result: None,
4904 via: None,
4905 },
4906 remember_receipt("user timezone", true),
4907 ];
4908
4909 let subjects = recent_fact_subjects(&receipts);
4910 assert_eq!(subjects, vec!["deploy target", "user timezone"]);
4911
4912 let block = render_state_block(None, &subjects).expect("facts alone must render a block");
4913 assert!(block.contains("deploy target"), "{block}");
4914 assert!(block.contains("user timezone"), "{block}");
4915 assert!(
4917 block.contains("recall"),
4918 "must point at the content: {block}"
4919 );
4920 assert!(!block.contains('…'), "bodies must not be inlined: {block}");
4921 }
4922
4923 #[test]
4927 fn a_failed_remember_is_not_reported_as_known() {
4928 let receipts = [
4929 remember_receipt("landed fact", true),
4930 remember_receipt("rejected fact", false),
4931 ];
4932 assert_eq!(recent_fact_subjects(&receipts), vec!["landed fact"]);
4933 }
4934
4935 #[test]
4939 fn re_remembering_a_subject_moves_it_instead_of_duplicating() {
4940 let receipts = [
4941 remember_receipt("api base url", true),
4942 remember_receipt("deploy target", true),
4943 remember_receipt("api base url", true),
4944 ];
4945 assert_eq!(
4946 recent_fact_subjects(&receipts),
4947 vec!["deploy target", "api base url"]
4948 );
4949 }
4950
4951 #[test]
4954 fn the_fact_list_is_bounded_and_says_what_it_dropped() {
4955 let subjects: Vec<String> = (0..12).map(|i| format!("fact {i}")).collect();
4956 let block = render_state_block(None, &subjects).expect("must render");
4957
4958 assert!(block.contains("fact 11"), "newest must survive: {block}");
4959 assert!(!block.contains("fact 6"), "oldest must be cut: {block}");
4960 assert!(
4961 block.contains("+7 earlier"),
4962 "a silent cut reads as 'that's all there is': {block}"
4963 );
4964 }
4965
4966 #[test]
4969 fn sections_render_independently_and_nothing_renders_nothing() {
4970 assert!(render_state_block(None, &[]).is_none());
4971 assert!(render_state_block(Some("todo: 0/1 done".into()), &[]).is_some());
4972 assert!(render_state_block(None, &["a fact".to_string()]).is_some());
4973
4974 let both = render_state_block(Some("todo: 0/1 done".into()), &["a fact".to_string()])
4975 .expect("must render");
4976 assert!(both.contains("todo:"), "{both}");
4977 assert!(both.contains("a fact"), "{both}");
4978 }
4979
4980 #[test]
4987 fn compaction_leaves_a_marker_the_model_can_see() {
4988 let big = "x".repeat(20_000);
4989 let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
4990 for i in 0..12 {
4991 m.push(asst_call(&format!("c{i}")));
4992 m.push(tool_res(&format!("c{i}"), &big));
4993 }
4994 compact_history_to_window(&mut m, 20_000);
4995
4996 let notice = m
4997 .iter()
4998 .find_map(|msg| match msg {
4999 Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX) => {
5000 Some(content.clone())
5001 }
5002 _ => None,
5003 })
5004 .expect("a compaction notice must be left in place of the removed turns");
5005
5006 assert!(
5007 notice.contains("earlier turns removed"),
5008 "the notice must say turns were removed: {notice}"
5009 );
5010 assert!(
5011 notice.contains("events_query"),
5012 "a notice that says something is missing without saying how to look \
5013 only turns a silent failure into a visible dead end: {notice}"
5014 );
5015 assert!(
5018 matches!(&m[2], Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX)),
5019 "notice belongs where the turns were, after system + task"
5020 );
5021 }
5022
5023 #[test]
5028 fn repeated_compaction_accumulates_into_one_notice() {
5029 let big = "x".repeat(20_000);
5030 let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
5031 for i in 0..12 {
5032 m.push(asst_call(&format!("c{i}")));
5033 m.push(tool_res(&format!("c{i}"), &big));
5034 }
5035 compact_history_to_window(&mut m, 20_000);
5036 let (first_turns, first_tokens) =
5037 parse_compaction_notice(&m[2]).expect("first notice parses");
5038
5039 for i in 12..24 {
5041 m.push(asst_call(&format!("c{i}")));
5042 m.push(tool_res(&format!("c{i}"), &big));
5043 }
5044 compact_history_to_window(&mut m, 20_000);
5045
5046 let notices: Vec<&String> = m
5047 .iter()
5048 .filter_map(|msg| match msg {
5049 Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX) => {
5050 Some(content)
5051 }
5052 _ => None,
5053 })
5054 .collect();
5055 assert_eq!(
5056 notices.len(),
5057 1,
5058 "exactly one notice, not a stack: {notices:?}"
5059 );
5060
5061 let (turns, tokens) = parse_compaction_notice(&m[2]).expect("notice still parses");
5062 assert!(
5063 turns > first_turns && tokens > first_tokens,
5064 "totals must accumulate across compactions ({first_turns}/{first_tokens} \
5065 -> {turns}/{tokens})"
5066 );
5067 }
5068
5069 #[test]
5072 fn compaction_notice_round_trips() {
5073 let rendered = format_compaction_notice(12, 34_000, CompactionRecovery::default());
5077 let parsed = parse_compaction_notice(&Message::System { content: rendered });
5078 assert_eq!(parsed, Some((12, 34_000)));
5079 assert_eq!(
5081 parse_compaction_notice(&sys("ordinary system prompt")),
5082 None
5083 );
5084 assert_eq!(parse_compaction_notice(&usr("a user turn")), None);
5085 }
5086
5087 #[test]
5088 fn compaction_keeps_responses_item_with_its_assistant_turn() {
5089 let big = "x".repeat(20_000);
5090 let mut messages = vec![sys("system"), usr("THE ORIGINAL TASK")];
5091 for i in 0..12 {
5092 messages.push(provider_item(&format!("rs_{i}"), &big));
5093 messages.push(asst_call(&format!("c{i}")));
5094 messages.push(tool_res(&format!("c{i}"), "ok"));
5095 }
5096
5097 compact_history_to_window(&mut messages, 20_000);
5098
5099 for (index, message) in messages.iter().enumerate() {
5100 if matches!(message, Message::ProviderOutputItems { .. }) {
5101 assert!(
5102 matches!(messages.get(index + 1), Some(Message::Assistant { .. })),
5103 "provider continuity item was orphaned from its assistant"
5104 );
5105 }
5106 }
5107 assert!(
5108 no_orphan_tool_results(&messages),
5109 "compacted history contains an orphan tool result"
5110 );
5111 }
5112
5113 #[tokio::test]
5119 async fn loop_compacts_history_to_window() {
5120 let dir = tempfile::tempdir().unwrap();
5121 let rt = runtime_for(dir.path()).await;
5122
5123 struct WindowedBig {
5127 cursor: AtomicUsize,
5128 }
5129 #[async_trait]
5130 impl TurnGenerator for WindowedBig {
5131 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5132 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5133 if i < 6 {
5134 Ok(turn(
5137 &"x".repeat(8000),
5138 json!([{ "id": format!("c{i}"), "name": "calculate",
5139 "arguments": { "expression": format!("1+{i}") } }]),
5140 ))
5141 } else {
5142 Ok(turn("done", json!([])))
5143 }
5144 }
5145 fn context_window(&self, _model: &str) -> usize {
5146 4000
5147 }
5148 }
5149
5150 let generator = WindowedBig {
5151 cursor: AtomicUsize::new(0),
5152 };
5153 let mut messages = vec![
5154 Message::System {
5155 content: "system".into(),
5156 },
5157 Message::User {
5158 content: "THE TASK".into(),
5159 },
5160 ];
5161 let mut c = cfg();
5162 c.max_turns = 8;
5163
5164 let out = run_assistant_loop(&generator, &rt, &c, &mut messages, |_e| {}).await;
5165
5166 assert_eq!(out.status, "success");
5167 assert!(
5170 messages.len() <= 11,
5171 "history bounded by compaction, got {} messages",
5172 messages.len()
5173 );
5174 assert!(
5175 matches!(&messages[0], Message::System { .. }),
5176 "system stays pinned"
5177 );
5178 assert!(
5179 matches!(&messages[1], Message::User { content } if content == "THE TASK"),
5180 "original task stays pinned"
5181 );
5182 assert!(
5183 no_orphan_tool_results(&messages),
5184 "no orphaned tool results in the live loop"
5185 );
5186 }
5187
5188 #[tokio::test]
5192 async fn loop_halts_a_no_progress_repeat_loop() {
5193 let dir = tempfile::tempdir().unwrap();
5194 let rt = runtime_for(dir.path()).await;
5195
5196 struct Stuck;
5197 #[async_trait]
5198 impl TurnGenerator for Stuck {
5199 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5200 Ok(turn(
5202 "re-reading",
5203 json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
5204 ))
5205 }
5206 }
5207
5208 let mut messages = vec![
5209 Message::System {
5210 content: "sys".into(),
5211 },
5212 Message::User {
5213 content: "task".into(),
5214 },
5215 ];
5216 let mut c = cfg();
5217 c.max_turns = 40; let out = run_assistant_loop(&Stuck, &rt, &c, &mut messages, |_e| {}).await;
5220
5221 assert_eq!(
5222 out.status, "stalled",
5223 "a no-progress loop must halt as `stalled`, not run to max_turns"
5224 );
5225 assert!(
5226 out.turns < 40,
5227 "must stop well before the turn cap, got {} turns",
5228 out.turns
5229 );
5230 }
5231
5232 #[tokio::test]
5237 async fn loop_halts_a_read_plus_readonly_shell_cycle() {
5238 let dir = tempfile::tempdir().unwrap();
5239 let rt = runtime_for(dir.path()).await;
5240
5241 struct Cycle {
5242 cursor: AtomicUsize,
5243 }
5244 #[async_trait]
5245 impl TurnGenerator for Cycle {
5246 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5247 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5248 if i.is_multiple_of(2) {
5249 Ok(turn(
5250 "read",
5251 json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
5252 ))
5253 } else {
5254 Ok(turn(
5255 "probe",
5256 json!([{ "name": "shell", "arguments": { "command": "wc -l app.js" } }]),
5257 ))
5258 }
5259 }
5260 }
5261
5262 let mut messages = vec![
5263 Message::System {
5264 content: "sys".into(),
5265 },
5266 Message::User {
5267 content: "task".into(),
5268 },
5269 ];
5270 let mut c = cfg();
5271 c.max_turns = 40;
5272
5273 let out = run_assistant_loop(
5274 &Cycle {
5275 cursor: AtomicUsize::new(0),
5276 },
5277 &rt,
5278 &c,
5279 &mut messages,
5280 |_e| {},
5281 )
5282 .await;
5283
5284 assert_eq!(
5285 out.status, "stalled",
5286 "a read/read-only-shell cycle with no file change must halt"
5287 );
5288 assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
5289 }
5290
5291 #[tokio::test]
5296 async fn loop_halts_a_repeatedly_failing_mutation() {
5297 let dir = tempfile::tempdir().unwrap();
5298 let rt = runtime_for(dir.path()).await;
5299
5300 struct FailWrite;
5301 #[async_trait]
5302 impl TurnGenerator for FailWrite {
5303 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5304 Ok(turn(
5307 "writing",
5308 json!([{ "name": "write_file",
5309 "arguments": { "path": "../../etc/evil", "content": "x" } }]),
5310 ))
5311 }
5312 }
5313
5314 let mut messages = vec![
5315 Message::System {
5316 content: "sys".into(),
5317 },
5318 Message::User {
5319 content: "task".into(),
5320 },
5321 ];
5322 let mut c = cfg();
5323 c.max_turns = 40;
5324
5325 let out = run_assistant_loop(&FailWrite, &rt, &c, &mut messages, |_e| {}).await;
5326
5327 assert_eq!(
5328 out.status, "stalled",
5329 "a repeatedly-failing mutation makes no progress and must halt (not reset the guard)"
5330 );
5331 assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
5332 }
5333
5334 fn turn_with_usage(
5338 text: &str,
5339 tool_calls: Value,
5340 prompt_tokens: u64,
5341 completion_tokens: u64,
5342 ) -> InferenceResult {
5343 serde_json::from_value(json!({
5344 "text": text,
5345 "tool_calls": tool_calls,
5346 "trace_id": "t",
5347 "model_used": "scripted",
5348 "latency_ms": 25,
5349 "usage": {
5350 "prompt_tokens": prompt_tokens,
5351 "completion_tokens": completion_tokens,
5352 "total_tokens": prompt_tokens + completion_tokens,
5353 "context_window": 8192,
5354 },
5355 }))
5356 .expect("scripted InferenceResult shape with usage")
5357 }
5358
5359 #[tokio::test]
5370 async fn assistant_loop_meters_every_model_call_with_real_tokens() {
5371 let dir = tempfile::tempdir().unwrap();
5372 let rt = runtime_for(dir.path()).await;
5373 let mut fallback_turn = turn_with_usage("The answer is 42.", json!([]), 200, 15);
5376 fallback_turn.model_identity.resolved_model_id = "mlx/qwen3-4b:4bit".into();
5377 fallback_turn.local_last_resort = true;
5378 let script = Script {
5379 turns: vec![
5380 turn_with_usage(
5381 "computing",
5382 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
5383 120,
5384 30,
5385 ),
5386 fallback_turn,
5387 ],
5388 cursor: AtomicUsize::new(0),
5389 };
5390 let mut messages = vec![
5391 Message::System {
5392 content: "sys".into(),
5393 },
5394 Message::User {
5395 content: "what is 6*7?".into(),
5396 },
5397 ];
5398 let mut assistant_events = Vec::new();
5399 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |event| {
5400 assistant_events.push(event)
5401 })
5402 .await;
5403 assert_eq!(outcome.status, "success");
5404 assert_eq!(
5405 outcome.models_served,
5406 vec![
5407 AssistantModelAttribution {
5408 model_id: "scripted".into(),
5409 local_last_resort: false,
5410 },
5411 AssistantModelAttribution {
5412 model_id: "mlx/qwen3-4b:4bit".into(),
5413 local_last_resort: true,
5414 },
5415 ],
5416 "the terminal run receipt must retain every turn, including the local fallback"
5417 );
5418 assert_eq!(
5419 outcome.model_used, "mlx/qwen3-4b:4bit",
5420 "final attribution must use the canonical resolved model id"
5421 );
5422
5423 let transcript_attributions: Vec<_> = messages
5424 .iter()
5425 .filter_map(|message| match message {
5426 Message::Assistant {
5427 model_id,
5428 local_last_resort,
5429 ..
5430 } => Some((model_id.as_deref(), *local_last_resort)),
5431 _ => None,
5432 })
5433 .collect();
5434 assert_eq!(
5435 transcript_attributions,
5436 vec![(Some("scripted"), false), (Some("mlx/qwen3-4b:4bit"), true)],
5437 "the exact replayable transcript must carry each serving attribution"
5438 );
5439
5440 let attributions: Vec<_> = assistant_events
5441 .iter()
5442 .filter_map(|event| match event {
5443 AssistantEvent::ModelServed {
5444 model_id,
5445 local_last_resort,
5446 } => Some((model_id.as_str(), *local_last_resort)),
5447 _ => None,
5448 })
5449 .collect();
5450 assert_eq!(
5451 attributions,
5452 vec![("scripted", false), ("mlx/qwen3-4b:4bit", true)],
5453 "every completed assistant turn must emit its canonical serving model and fallback marker"
5454 );
5455
5456 let events = rt.log.lock().await.events().to_vec();
5457 let metered: Vec<_> = events
5458 .iter()
5459 .filter(|e| e.kind == car_eventlog::EventKind::InferenceMetered)
5460 .collect();
5461 assert_eq!(
5462 metered.len(),
5463 2,
5464 "one InferenceMetered per model call; the loop made 2 generate() calls"
5465 );
5466 let metered_models: Vec<_> = metered
5467 .iter()
5468 .map(|event| event.data.get("model_id").and_then(Value::as_str))
5469 .collect();
5470 assert_eq!(
5471 metered_models,
5472 vec![Some("scripted"), Some("mlx/qwen3-4b:4bit")],
5473 "metered events must use the same canonical serving ids"
5474 );
5475 for ev in &metered {
5476 assert_eq!(
5477 ev.data.get("usage_measured").and_then(|v| v.as_bool()),
5478 Some(true)
5479 );
5480 }
5481
5482 let m = car_eventlog::harness_metrics::compute_harness_metrics(&events);
5483 assert_eq!(
5484 m.trajectory_efficiency.model_calls, 2,
5485 "harness metrics must see the model calls"
5486 );
5487 assert_eq!(
5488 m.trajectory_efficiency.total_tokens,
5489 120 + 30 + 200 + 15,
5490 "tokens must be the sum of the scripted usage, not an estimate"
5491 );
5492 assert!(m.trajectory_efficiency.wall_clock_ms > 0.0);
5493
5494 assert!(
5501 m.trajectory_efficiency.actions_succeeded > 0,
5502 "the executed `calculate` call must be recorded as a succeeded action; \
5503 got {m:?}"
5504 );
5505 assert!(
5506 m.trajectory_efficiency.success_rate.is_some(),
5507 "success_rate is the evolution gate's only regression guard and must be measured"
5508 );
5509 }
5510
5511 #[tokio::test]
5516 async fn unmeasured_usage_still_counts_the_call_but_fabricates_no_tokens() {
5517 let dir = tempfile::tempdir().unwrap();
5518 let rt = runtime_for(dir.path()).await;
5519 let script = Script {
5521 turns: vec![turn("done, no usage reported", json!([]))],
5522 cursor: AtomicUsize::new(0),
5523 };
5524 let mut messages = vec![
5525 Message::System {
5526 content: "sys".into(),
5527 },
5528 Message::User {
5529 content: "hi".into(),
5530 },
5531 ];
5532 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
5533 assert_eq!(outcome.status, "success");
5534
5535 let events = rt.log.lock().await.events().to_vec();
5536 let metered: Vec<_> = events
5537 .iter()
5538 .filter(|e| e.kind == car_eventlog::EventKind::InferenceMetered)
5539 .collect();
5540 assert_eq!(metered.len(), 1, "the call happened, so it is counted");
5541 assert_eq!(
5542 metered[0]
5543 .data
5544 .get("usage_measured")
5545 .and_then(|v| v.as_bool()),
5546 Some(false),
5547 "the journal must say the count was unavailable, not imply a zero"
5548 );
5549
5550 let m = car_eventlog::harness_metrics::compute_harness_metrics(&events);
5551 assert_eq!(m.trajectory_efficiency.model_calls, 1);
5552 assert_eq!(
5553 m.trajectory_efficiency.total_tokens, 0,
5554 "no usage reported means no tokens attributed — absent, not invented"
5555 );
5556 }
5557
5558 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
5559 serde_json::from_value(json!({
5560 "text": text,
5561 "tool_calls": tool_calls,
5562 "trace_id": "t",
5563 "model_used": "scripted",
5564 "latency_ms": 0,
5565 }))
5566 .expect("scripted InferenceResult shape")
5567 }
5568
5569 struct Script {
5570 turns: Vec<InferenceResult>,
5571 cursor: AtomicUsize,
5572 }
5573
5574 #[async_trait]
5575 impl TurnGenerator for Script {
5576 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5577 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5578 self.turns.get(i).cloned().ok_or("script exhausted".into())
5579 }
5580 }
5581
5582 struct CapturingGenerator {
5583 seen: Arc<StdMutex<Vec<GenerateRequest>>>,
5584 }
5585
5586 #[async_trait]
5587 impl TurnGenerator for CapturingGenerator {
5588 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5589 self.seen.lock().unwrap().push(req);
5590 Ok(turn("done", json!([])))
5591 }
5592 }
5593
5594 struct CapturingScript {
5595 turns: Vec<InferenceResult>,
5596 cursor: AtomicUsize,
5597 seen: Arc<StdMutex<Vec<GenerateRequest>>>,
5598 }
5599
5600 #[async_trait]
5601 impl TurnGenerator for CapturingScript {
5602 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5603 self.seen.lock().unwrap().push(req);
5604 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5605 self.turns.get(i).cloned().ok_or("script exhausted".into())
5606 }
5607 }
5608
5609 async fn runtime_for(dir: &std::path::Path) -> Runtime {
5613 let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
5614 let exec: Arc<dyn ToolExecutor> =
5615 Arc::new(GeneralExecutor::new(substrate.clone(), dir, true));
5616 let engine = Arc::new(InferenceEngine::new(Default::default()));
5617 let rt = Runtime::new()
5618 .with_inference(engine)
5619 .with_executor(exec)
5620 .with_substrate(substrate);
5621 rt.register_agent_basics().await;
5622 rt.register_tool_entry(
5623 car_engine::ToolEntry::builtin(car_ir::builtins::shell()).with_side_effects(true),
5624 )
5625 .await;
5626 rt
5627 }
5628
5629 fn cfg() -> AssistantConfig {
5630 AssistantConfig {
5631 model: Some("scripted".into()),
5632 strict_model: false,
5633 max_turns: 6,
5634 tools: GeneralExecutor::tool_defs(),
5635 gated_tools: Vec::new(),
5636 approval_policy: None,
5637 proactive_memory: None,
5638 tool_memory: None,
5639 tool_labels: None,
5643 todos: None,
5644 value_store_previews: false,
5645 response_format: None,
5646 context_window_override: None,
5647 refuse_unadvertised_tools: false,
5648 response_format_validator: None,
5649 delegate_budget: None,
5650 }
5651 }
5652
5653 #[derive(Default)]
5654 struct JsonProgress(StdMutex<Vec<Value>>);
5655
5656 impl super::super::do_json::EventSink for JsonProgress {
5657 fn emit(&self, event: Value) {
5658 self.0.lock().unwrap().push(event);
5659 }
5660 }
5661
5662 fn json_emitter(sink: Arc<JsonProgress>) -> Arc<super::super::do_json::JsonEmitter> {
5663 Arc::new(super::super::do_json::JsonEmitter::new(
5664 super::super::do_json::SandboxPosture {
5665 sandboxed: false,
5666 image: None,
5667 tier: "ReadOnly".into(),
5668 root: "/work".into(),
5669 mount: None,
5670 fallback_notice: None,
5671 },
5672 sink,
5673 ))
5674 }
5675
5676 #[tokio::test]
5677 async fn do_json_emits_inference_started_while_a_two_second_generation_is_in_flight() {
5678 struct Slow;
5679 #[async_trait]
5680 impl TurnGenerator for Slow {
5681 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5682 tokio::time::sleep(Duration::from_secs(2)).await;
5683 Ok(turn("done", json!([])))
5684 }
5685 }
5686
5687 let dir = tempfile::tempdir().unwrap();
5688 let rt = runtime_for(dir.path()).await;
5689 let sink = Arc::new(JsonProgress::default());
5690 let emitter = json_emitter(sink.clone());
5691 let mut messages = vec![sys("system"), usr("task")];
5692 let config = cfg();
5693 let run_emitter = emitter.clone();
5694 let run = run_assistant_loop(&Slow, &rt, &config, &mut messages, move |event| {
5695 run_emitter.on_assistant_event(&event);
5696 });
5697 tokio::pin!(run);
5698
5699 assert!(
5700 tokio::time::timeout(Duration::from_millis(100), &mut run)
5701 .await
5702 .is_err(),
5703 "the two-second generator must still be in flight"
5704 );
5705 {
5706 let events = sink.0.lock().unwrap();
5707 assert_eq!(events.len(), 1, "only the pre-await event should exist");
5708 assert_eq!(events[0]["type"], "inference_started");
5709 assert_eq!(events[0]["data"]["model"], "scripted");
5710 assert_eq!(events[0]["data"]["attempt"], 1);
5711 assert_eq!(events[0]["data"]["turn"], 1);
5712 }
5713
5714 let outcome = run.await;
5715 assert_eq!(outcome.status, "success");
5716 let types: Vec<String> = sink
5717 .0
5718 .lock()
5719 .unwrap()
5720 .iter()
5721 .map(|event| event["type"].as_str().unwrap().to_string())
5722 .collect();
5723 assert_eq!(types, ["inference_started", "model_served"]);
5724 }
5725
5726 #[tokio::test]
5727 async fn do_json_emits_inference_retry_between_started_and_served() {
5728 struct FailOnce {
5729 calls: AtomicUsize,
5730 }
5731
5732 #[async_trait]
5733 impl TurnGenerator for FailOnce {
5734 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5735 if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
5736 Err("transient transport".into())
5737 } else {
5738 Ok(turn("done", json!([])))
5739 }
5740 }
5741
5742 async fn generate_assistant_observed(
5743 &self,
5744 req: GenerateRequest,
5745 retry_observer: &mut (dyn FnMut(car_inference::InferenceRetryProgress) + Send),
5746 ) -> Result<InferenceResult, AssistantGenerateError> {
5747 match self.generate(req.clone()).await {
5748 Ok(result) => Ok(result),
5749 Err(_) => {
5750 retry_observer(car_inference::InferenceRetryProgress {
5751 model: "scripted".into(),
5752 attempt: 2,
5753 reason: "transport",
5754 backoff_ms: 25,
5755 });
5756 self.generate(req)
5757 .await
5758 .map_err(AssistantGenerateError::Other)
5759 }
5760 }
5761 }
5762 }
5763
5764 let dir = tempfile::tempdir().unwrap();
5765 let rt = runtime_for(dir.path()).await;
5766 let sink = Arc::new(JsonProgress::default());
5767 let emitter = json_emitter(sink.clone());
5768 let mut messages = vec![sys("system"), usr("task")];
5769 let run_emitter = emitter.clone();
5770 let outcome = run_assistant_loop(
5771 &FailOnce {
5772 calls: AtomicUsize::new(0),
5773 },
5774 &rt,
5775 &cfg(),
5776 &mut messages,
5777 move |event| run_emitter.on_assistant_event(&event),
5778 )
5779 .await;
5780
5781 assert_eq!(outcome.status, "success");
5782 let events = sink.0.lock().unwrap();
5783 let types: Vec<&str> = events
5784 .iter()
5785 .map(|event| event["type"].as_str().unwrap())
5786 .collect();
5787 assert_eq!(
5788 types,
5789 ["inference_started", "inference_retry", "model_served"]
5790 );
5791 assert_eq!(events[1]["data"]["attempt"], 2);
5792 assert_eq!(events[1]["data"]["reason"], "transport");
5793 assert_eq!(events[1]["data"]["backoff_ms"], 25);
5794 }
5795
5796 #[test]
5806 fn the_shipped_default_is_the_measured_one() {
5807 assert!(
5808 !VALUE_STORE_PREVIEWS_DEFAULT,
5809 "retained previews stay OFF because the 3-replicate car-bench-harness \
5810 A/B did not meet #813's fewer-calls criterion. Changing this needs a \
5811 new measurement, not an edit."
5812 );
5813 }
5814
5815 #[test]
5828 fn no_production_call_site_hard_codes_the_preview_default() {
5829 let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5830 for rel in [
5831 "src/assistant/agent_loop.rs",
5832 "src/assistant/chat.rs",
5833 "src/coder/discuss.rs",
5834 "src/mcp_assistant.rs",
5835 ] {
5836 let src = std::fs::read_to_string(crate_dir.join(rel))
5837 .unwrap_or_else(|e| panic!("reading {rel}: {e}"));
5838 let production = match src.find("\n#[cfg(test)]") {
5839 Some(cut) => &src[..cut],
5840 None => src.as_str(),
5841 };
5842 for literal in ["value_store_previews: false", "value_store_previews: true"] {
5843 assert!(
5844 !production.contains(literal),
5845 "{rel} hard-codes the preview arm in production code. The \
5846 shipped default is VALUE_STORE_PREVIEWS_DEFAULT, chosen from a \
5847 measured A/B; a literal here forks it silently."
5848 );
5849 }
5850 }
5851 }
5852
5853 #[tokio::test]
5863 async fn the_off_arm_still_truncates_exactly_as_before() {
5864 assert!(
5865 !cfg().value_store_previews,
5866 "this fixture is the OFF arm — it pins the pre-#813 observation path, \
5867 not the shipped default (see VALUE_STORE_PREVIEWS_DEFAULT)"
5868 );
5869
5870 let dir = tempfile::tempdir().unwrap();
5871 let big = "x".repeat(OBSERVATION_CAP + 40_000);
5872 std::fs::write(dir.path().join("big.txt"), &big).unwrap();
5873 let rt = runtime_for(dir.path()).await;
5874
5875 let script = Script {
5876 turns: vec![
5877 turn(
5878 "reading",
5879 json!([{ "id": "c1", "name": "read_file", "arguments": { "path": "./big.txt" } }]),
5880 ),
5881 turn("done", json!([])),
5882 ],
5883 cursor: AtomicUsize::new(0),
5884 };
5885 let mut messages = vec![
5886 Message::System {
5887 content: "sys".into(),
5888 },
5889 Message::User {
5890 content: "read it".into(),
5891 },
5892 ];
5893 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
5894
5895 let observation = messages
5896 .iter()
5897 .find_map(|m| match m {
5898 Message::ToolResult { content, .. } => Some(content.clone()),
5899 _ => None,
5900 })
5901 .expect("a tool observation");
5902 assert!(
5903 observation.contains("…[truncated:"),
5904 "off path must still truncate destructively: {}",
5905 &observation[observation.len().saturating_sub(200)..]
5906 );
5907 assert!(
5908 !observation.contains("[full value retained"),
5909 "no handle may leak into the default transcript"
5910 );
5911 let receipt_result = outcome.tool_receipts[0].result.as_deref().unwrap();
5912 assert!(
5913 receipt_result.len() > OBSERVATION_CAP,
5914 "host evidence must be built from the complete result, not the capped transcript"
5915 );
5916 assert!(!receipt_result.contains("…[truncated:"));
5917 }
5918
5919 #[tokio::test]
5928 async fn a_retained_value_survives_the_transcript_and_can_be_used_by_a_later_tool() {
5929 let dir = tempfile::tempdir().unwrap();
5930 let big = format!(
5932 "HEAD-MARKER\n{}\nTAIL-MARKER",
5933 "z".repeat(OBSERVATION_CAP + 40_000)
5934 );
5935 std::fs::write(dir.path().join("big.txt"), &big).unwrap();
5936 let rt = runtime_for(dir.path()).await;
5937
5938 let script = Script {
5939 turns: vec![
5940 turn(
5941 "reading",
5942 json!([{ "id": "c1", "name": "read_file", "arguments": { "path": "./big.txt" } }]),
5943 ),
5944 turn(
5945 "copying",
5946 json!([{ "id": "c2", "name": "write_file",
5947 "arguments": { "path": "./copy.txt", "content": "$r1.content" } }]),
5948 ),
5949 turn("done", json!([])),
5950 ],
5951 cursor: AtomicUsize::new(0),
5952 };
5953 let mut messages = vec![
5954 Message::System {
5955 content: "sys".into(),
5956 },
5957 Message::User {
5958 content: "copy it".into(),
5959 },
5960 ];
5961 let mut cfg = cfg();
5962 cfg.value_store_previews = true;
5963 cfg.max_turns = 8;
5964 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
5965
5966 let observation = messages
5967 .iter()
5968 .find_map(|m| match m {
5969 Message::ToolResult { content, .. } => Some(content.clone()),
5970 _ => None,
5971 })
5972 .expect("a tool observation");
5973
5974 assert!(
5976 observation.contains("content: text(len="),
5977 "the large field must announce its size and shape: {observation}"
5978 );
5979 assert!(
5980 observation.contains("[full value retained"),
5981 "the model must be told the value is reachable: {observation}"
5982 );
5983 assert!(
5984 observation.len() < 2_000,
5985 "preview must be bounded, got {} bytes",
5986 observation.len()
5987 );
5988 assert!(
5989 !observation.contains(&"z".repeat(1_000)),
5990 "the payload itself must not be in the transcript"
5991 );
5992
5993 let copied = std::fs::read_to_string(dir.path().join("copy.txt"))
6000 .expect("the second tool must have run with the resolved value");
6001 assert!(
6002 copied.len() > OBSERVATION_CAP,
6003 "only {} bytes came back; the value was not retained in full",
6004 copied.len()
6005 );
6006 assert!(
6007 copied.contains("HEAD-MARKER"),
6008 "the head — the only part destructive truncation ever kept — is missing"
6009 );
6010 assert!(
6011 copied.contains("TAIL-MARKER"),
6012 "the TAIL is the part cap() always destroyed; recovering it is the \
6013 whole point of #813"
6014 );
6015 assert!(
6017 !observation.contains("TAIL-MARKER"),
6018 "the tail must have come from the store, not the context: {observation}"
6019 );
6020 }
6021
6022 #[tokio::test]
6023 async fn loop_runs_a_tool_then_finishes() {
6024 let dir = tempfile::tempdir().unwrap();
6025 let rt = runtime_for(dir.path()).await;
6026 let script = Script {
6028 turns: vec![
6029 turn(
6030 "computing",
6031 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6032 ),
6033 turn("The answer is 42.", json!([])),
6034 ],
6035 cursor: AtomicUsize::new(0),
6036 };
6037 let mut messages = vec![
6038 Message::System {
6039 content: "sys".into(),
6040 },
6041 Message::User {
6042 content: "what is 6*7?".into(),
6043 },
6044 ];
6045 let mut events = Vec::new();
6046 let outcome =
6047 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
6048
6049 assert_eq!(outcome.status, "success");
6050 assert_eq!(outcome.summary, "The answer is 42.");
6051 assert!(outcome.tools_called.contains(&"calculate".to_string()));
6052 let call_index = events
6053 .iter()
6054 .position(|event| matches!(event, AssistantEvent::ToolCall { name, .. } if name == "calculate"))
6055 .expect("tool call event");
6056 let result_index = events
6057 .iter()
6058 .position(|event| matches!(event, AssistantEvent::ToolResult { name, ok: true, .. } if name == "calculate"))
6059 .expect("tool result event");
6060 let answer_index = events
6061 .iter()
6062 .rposition(|event| matches!(event, AssistantEvent::Done { text } if text == "The answer is 42."))
6063 .expect("assistant answer event");
6064 assert!(
6065 call_index < result_index && result_index < answer_index,
6066 "the result must follow its call and precede later assistant text"
6067 );
6068 let receipt = outcome
6069 .tool_receipts
6070 .iter()
6071 .find(|receipt| receipt.tool == "calculate")
6072 .expect("calculator receipt");
6073 assert_eq!(receipt.call_id.as_deref(), Some("turn_1_call_1"));
6074 assert_eq!(receipt.sequence, Some(1));
6075 assert!(messages.iter().any(|message| matches!(
6076 message,
6077 Message::Assistant { tool_calls, .. }
6078 if tool_calls.first().and_then(|call| call.id.as_deref()) == Some("c1")
6079 )));
6080 assert!(messages.iter().any(|message| matches!(
6081 message,
6082 Message::ToolResult { tool_use_id, .. } if tool_use_id == "c1"
6083 )));
6084 assert!(
6085 receipt
6086 .result
6087 .as_deref()
6088 .is_some_and(|result| result.contains("42")),
6089 "the per-tool receipt must retain the uncapped result used for host evidence"
6090 );
6091 }
6092
6093 #[tokio::test]
6099 async fn a_second_invocation_reports_only_its_own_calls() {
6100 let dir = tempfile::tempdir().unwrap();
6101 let rt = runtime_for(dir.path()).await;
6102 let mut messages = vec![
6103 Message::System {
6104 content: "sys".into(),
6105 },
6106 Message::User {
6107 content: "what is 6*7?".into(),
6108 },
6109 ];
6110 let script = || Script {
6111 turns: vec![
6112 turn(
6113 "computing",
6114 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6115 ),
6116 turn("The answer is 42.", json!([])),
6117 ],
6118 cursor: AtomicUsize::new(0),
6119 };
6120
6121 let first = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6122 assert_eq!(first.prior_receipts, 0, "nothing preceded the first turn");
6123 assert_eq!(first.run_receipts().len(), 1);
6124
6125 messages.push(Message::User {
6126 content: "again please".into(),
6127 });
6128 let second = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6129
6130 assert_eq!(
6132 second.tool_receipts.len(),
6133 2,
6134 "the full vec keeps the replayed seed for grounding"
6135 );
6136 assert_eq!(second.prior_receipts, 1);
6137 let run: Vec<_> = second
6138 .run_receipts()
6139 .iter()
6140 .map(|r| r.call_id.clone().unwrap())
6141 .collect();
6142 assert_eq!(
6143 run,
6144 vec!["turn_3_call_1".to_string()],
6145 "the receipt reports this invocation's call, not the first turn's"
6146 );
6147 }
6148
6149 #[tokio::test]
6155 async fn compaction_cannot_make_a_call_id_repeat_within_a_session() {
6156 let dir = tempfile::tempdir().unwrap();
6157 let rt = runtime_for(dir.path()).await;
6158 let script = || Script {
6159 turns: vec![
6160 turn(
6161 "computing",
6162 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6163 ),
6164 turn("The answer is 42.", json!([])),
6165 ],
6166 cursor: AtomicUsize::new(0),
6167 };
6168 let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6169
6170 let mut first_ids: Vec<String> = Vec::new();
6173 for round in 0..4 {
6174 if round > 0 {
6175 messages.push(usr("again please"));
6176 }
6177 let outcome = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6178 first_ids.extend(
6179 outcome
6180 .run_receipts()
6181 .iter()
6182 .filter_map(|r| r.call_id.clone()),
6183 );
6184 }
6185 assert_eq!(first_ids.first().map(String::as_str), Some("turn_1_call_1"));
6186
6187 let offset_before = transcript_turn_offset(&messages);
6189 let assistants_before = messages
6190 .iter()
6191 .filter(|m| matches!(m, Message::Assistant { .. }))
6192 .count();
6193 compact_history_measured(
6194 &mut messages,
6195 512,
6196 PromptMeasure {
6197 fixed_overhead: 0,
6198 reported: None,
6199 },
6200 );
6201 let assistants_after = messages
6202 .iter()
6203 .filter(|m| matches!(m, Message::Assistant { .. }))
6204 .count();
6205 assert!(
6207 assistants_after < assistants_before,
6208 "compaction must actually have dropped an Assistant turn"
6209 );
6210 assert!(
6211 messages.iter().any(|m| matches!(
6212 m,
6213 Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX)
6214 )),
6215 "and left the notice the offset is recovered from"
6216 );
6217 assert!(
6220 transcript_turn_offset(&messages) >= offset_before,
6221 "compaction moved the turn offset down: {} < {offset_before}",
6222 transcript_turn_offset(&messages)
6223 );
6224
6225 messages.push(usr("again please"));
6226 let second = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6227 let second_ids: Vec<String> = second
6228 .run_receipts()
6229 .iter()
6230 .filter_map(|r| r.call_id.clone())
6231 .collect();
6232 assert!(!second_ids.is_empty(), "the second turn called a tool");
6233 for id in &second_ids {
6234 assert!(
6235 !first_ids.contains(id),
6236 "{id} was already minted before compaction: {first_ids:?}"
6237 );
6238 }
6239 }
6240
6241 #[test]
6243 fn transcript_turn_offset_includes_compacted_away_turns() {
6244 let bare = vec![
6245 sys("sys"),
6246 usr("task"),
6247 Message::Assistant {
6248 content: "a".into(),
6249 tool_calls: vec![],
6250 thinking: vec![],
6251 model_id: None,
6252 local_last_resort: false,
6253 },
6254 ];
6255 assert_eq!(transcript_turn_offset(&bare), 1);
6256
6257 let mut compacted = bare.clone();
6258 compacted.insert(
6259 2,
6260 Message::System {
6261 content: format_compaction_notice(6, 400, CompactionRecovery::default()),
6262 },
6263 );
6264 eprintln!(
6265 "DEBUG notice={:?} parsed={:?}",
6266 match &compacted[2] {
6267 Message::System { content } => content.clone(),
6268 _ => String::new(),
6269 },
6270 parse_compaction_notice(&compacted[2])
6271 );
6272 assert_eq!(
6273 transcript_turn_offset(&compacted),
6274 7,
6275 "six dropped messages plus the one surviving Assistant turn"
6276 );
6277 }
6278
6279 struct FailAfterDispatch;
6280
6281 #[async_trait]
6282 impl ApprovalGate for FailAfterDispatch {
6283 async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
6284 ApprovalDecision::Approved
6285 }
6286
6287 async fn after_dispatch(
6288 &self,
6289 _call_id: &str,
6290 _tool: &str,
6291 _params: &Value,
6292 _ok: bool,
6293 _receipt: &Value,
6294 ) -> Result<(), String> {
6295 Err("receipt store unavailable".into())
6296 }
6297 }
6298
6299 #[tokio::test]
6300 async fn terminal_receipt_error_after_dispatch_emits_a_failed_tool_result() {
6301 let dir = tempfile::tempdir().unwrap();
6302 let rt = runtime_for(dir.path()).await;
6303 let script = Script {
6304 turns: vec![turn(
6305 "computing",
6306 json!([{ "id": "repeated", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6307 )],
6308 cursor: AtomicUsize::new(0),
6309 };
6310 let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6311 let mut config = cfg();
6312 config.gated_tools = vec!["calculate".into()];
6313 let cancel = AtomicBool::new(false);
6314 let mut events = Vec::new();
6315 let outcome = run_assistant_loop_cancellable(
6316 &script,
6317 &rt,
6318 &config,
6319 &mut messages,
6320 &cancel,
6321 Some(&FailAfterDispatch),
6322 None,
6323 |event| events.push(event),
6324 )
6325 .await;
6326
6327 assert_eq!(outcome.status, "error");
6328 assert_eq!(outcome.tool_receipts.len(), 1);
6329 assert!(!outcome.tool_receipts[0].ok);
6330 let result_index = events
6331 .iter()
6332 .position(|event| matches!(event, AssistantEvent::ToolResult { ok: false, .. }))
6333 .expect("failed tool result");
6334 let error_index = events
6335 .iter()
6336 .position(|event| matches!(event, AssistantEvent::Error(_)))
6337 .expect("terminal error");
6338 assert!(result_index < error_index);
6339 }
6340
6341 #[tokio::test]
6342 async fn cancellation_after_a_request_emits_its_failed_result_row() {
6343 let dir = tempfile::tempdir().unwrap();
6344 let rt = runtime_for(dir.path()).await;
6345 let script = Script {
6346 turns: vec![turn(
6347 "computing",
6348 json!([{ "id": "repeated", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6349 )],
6350 cursor: AtomicUsize::new(0),
6351 };
6352 let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6353 let cancel = AtomicBool::new(false);
6354 let mut events = Vec::new();
6355 let outcome = run_assistant_loop_cancellable(
6356 &script,
6357 &rt,
6358 &cfg(),
6359 &mut messages,
6360 &cancel,
6361 None,
6362 None,
6363 |event| {
6364 if matches!(event, AssistantEvent::ToolCall { .. }) {
6365 cancel.store(true, Ordering::Relaxed);
6366 }
6367 events.push(event);
6368 },
6369 )
6370 .await;
6371
6372 assert_eq!(outcome.status, "cancelled");
6373 assert_eq!(outcome.tool_receipts.len(), 1);
6374 assert!(!outcome.tool_receipts[0].ok);
6375 let call = events
6376 .iter()
6377 .find_map(|event| match event {
6378 AssistantEvent::ToolCall {
6379 call_id, sequence, ..
6380 } => Some((call_id, sequence)),
6381 _ => None,
6382 })
6383 .expect("request row");
6384 let result = events
6385 .iter()
6386 .find_map(|event| match event {
6387 AssistantEvent::ToolResult {
6388 call_id,
6389 sequence,
6390 ok,
6391 ..
6392 } => Some((call_id, sequence, ok)),
6393 _ => None,
6394 })
6395 .expect("cancel result row");
6396 assert_eq!(result.0, call.0);
6397 assert_eq!(result.1, call.1);
6398 assert!(!result.2);
6399 }
6400
6401 #[tokio::test]
6402 async fn loop_replays_managed_responses_continuity_on_second_turn() {
6403 let dir = tempfile::tempdir().unwrap();
6404 let rt = runtime_for(dir.path()).await;
6405 let reasoning = json!({
6406 "type": "reasoning",
6407 "id": "rs_agent",
6408 "status": "completed",
6409 "summary": [{"type": "summary_text", "text": "safe"}],
6410 "encrypted_content": "opaque-agent",
6411 });
6412 let mut first = turn(
6413 "checking",
6414 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6415 );
6416 first.provider_output_items = vec![reasoning.clone()];
6417 let seen = Arc::new(StdMutex::new(Vec::new()));
6418 let script = CapturingScript {
6419 turns: vec![first, turn("done", json!([]))],
6420 cursor: AtomicUsize::new(0),
6421 seen: seen.clone(),
6422 };
6423 let mut messages = vec![
6424 Message::System {
6425 content: "sys".into(),
6426 },
6427 Message::User {
6428 content: "calculate".into(),
6429 },
6430 ];
6431
6432 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_e| {}).await;
6433
6434 assert_eq!(outcome.status, "success");
6435 assert!(
6436 !outcome.summary.contains("opaque-agent"),
6437 "opaque continuity must never become user-visible text"
6438 );
6439 let seen = seen.lock().unwrap();
6440 let second = seen[1].messages.as_ref().expect("second-turn history");
6441 assert!(matches!(
6442 &second[2],
6443 Message::ProviderOutputItems { protocol, items }
6444 if protocol == car_inference::protocol::OPENAI_RESPONSES_PROTOCOL
6445 && items == &vec![reasoning]
6446 ));
6447 assert!(matches!(
6448 &second[3],
6449 Message::Assistant { content, .. } if content == "checking"
6450 ));
6451 assert!(matches!(&second[4], Message::ToolResult { .. }));
6452 }
6453
6454 #[tokio::test]
6455 async fn loop_injects_proactive_memory_before_generation() {
6456 let dir = tempfile::tempdir().unwrap();
6457 let rt = runtime_for(dir.path()).await;
6458 let memory = Arc::new(crate::assistant::memory::MemoryTools::open(
6459 dir.path().join("assistant-memory.json"),
6460 ));
6461 memory
6462 .execute(
6463 "remember",
6464 &json!({
6465 "subject": "phoenix task requirement",
6466 "body": "Requirement: for phoenix task work, run pytest before finishing."
6467 }),
6468 )
6469 .await
6470 .unwrap();
6471 let seen = Arc::new(StdMutex::new(Vec::new()));
6472 let generator = CapturingGenerator { seen: seen.clone() };
6473 let mut cfg = cfg();
6474 cfg.proactive_memory = Some(memory);
6475 let mut messages = vec![
6476 Message::System {
6477 content: "sys".into(),
6478 },
6479 Message::User {
6480 content: "finish the phoenix task".into(),
6481 },
6482 ];
6483
6484 let outcome = run_assistant_loop(&generator, &rt, &cfg, &mut messages, |_| {}).await;
6485
6486 assert_eq!(outcome.status, "success");
6487 {
6490 let captured = seen.lock().unwrap();
6491 let context = captured[0].context.as_deref().unwrap_or("");
6492 assert!(
6493 context.contains("## Proactive Memory"),
6494 "request context should carry proactive memory: {context}"
6495 );
6496 assert!(
6497 context.contains("run pytest before finishing"),
6498 "selected memory should be injected: {context}"
6499 );
6500 }
6501 let log = rt.log.lock().await;
6502 assert!(log
6503 .events()
6504 .iter()
6505 .any(|e| e.kind == car_eventlog::EventKind::ProactiveMemoryMaintained));
6506 assert!(log.events().iter().any(|e| {
6507 e.kind == car_eventlog::EventKind::ProactiveMemoryIntervention
6508 && e.data.get("decision") == Some(&json!("inject"))
6509 }));
6510 }
6511
6512 #[tokio::test]
6513 async fn loop_learns_the_call_that_recovered_a_failed_tool() {
6514 let dir = tempfile::tempdir().unwrap();
6518 let rt = runtime_for(dir.path()).await;
6519 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6520 dir.path().join("repairs.json"),
6521 ));
6522 let script = Script {
6523 turns: vec![
6524 turn(
6525 "trying",
6526 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6527 ),
6528 turn(
6529 "retrying",
6530 json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6531 ),
6532 turn("done", json!([])),
6533 ],
6534 cursor: AtomicUsize::new(0),
6535 };
6536 let mut cfg = cfg();
6537 cfg.tool_memory = Some(memory.clone());
6538 let mut messages = vec![
6539 Message::System {
6540 content: "sys".into(),
6541 },
6542 Message::User {
6543 content: "compute six times seven".into(),
6544 },
6545 ];
6546
6547 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6548
6549 assert_eq!(outcome.status, "success");
6550 assert_eq!(
6551 memory.learned_count(),
6552 1,
6553 "the recovering call should have been learned"
6554 );
6555 }
6556
6557 #[tokio::test]
6558 async fn a_learned_repair_reaches_the_next_run_that_hits_the_same_failure() {
6559 let dir = tempfile::tempdir().unwrap();
6563 let rt = runtime_for(dir.path()).await;
6564 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6565 dir.path().join("repairs.json"),
6566 ));
6567 let mut cfg = cfg();
6568 cfg.tool_memory = Some(memory.clone());
6569
6570 let learning = Script {
6571 turns: vec![
6572 turn(
6573 "trying",
6574 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6575 ),
6576 turn(
6577 "retrying",
6578 json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6579 ),
6580 turn("done", json!([])),
6581 ],
6582 cursor: AtomicUsize::new(0),
6583 };
6584 let mut messages = vec![
6585 Message::System {
6586 content: "sys".into(),
6587 },
6588 Message::User {
6589 content: "compute six times seven".into(),
6590 },
6591 ];
6592 run_assistant_loop(&learning, &rt, &cfg, &mut messages, |_| {}).await;
6593 assert_eq!(memory.learned_count(), 1, "run one must learn something");
6594
6595 let seen = Arc::new(StdMutex::new(Vec::new()));
6597 let second = CapturingScript {
6598 turns: vec![
6599 turn(
6600 "trying",
6601 json!([{ "id": "d1", "name": "calculate", "arguments": { "expression": "9 ** ** 9" } }]),
6602 ),
6603 turn("done", json!([])),
6604 ],
6605 cursor: AtomicUsize::new(0),
6606 seen: seen.clone(),
6607 };
6608 let mut messages = vec![
6609 Message::System {
6610 content: "sys".into(),
6611 },
6612 Message::User {
6613 content: "compute nine times nine".into(),
6614 },
6615 ];
6616 run_assistant_loop(&second, &rt, &cfg, &mut messages, |_| {}).await;
6617
6618 let captured = seen.lock().unwrap();
6619 let first_context = captured[0].context.as_deref().unwrap_or("");
6620 assert!(
6621 !first_context.contains("## Learned Repairs"),
6622 "nothing has failed yet on this run: {first_context}"
6623 );
6624 let after_failure = captured[1].context.as_deref().unwrap_or("");
6625 assert!(
6626 after_failure.contains("## Learned Repairs"),
6627 "the turn after the failure should carry the lead: {after_failure}"
6628 );
6629 assert!(
6630 after_failure.contains("6*7"),
6631 "the lead should be the call that actually recovered: {after_failure}"
6632 );
6633 }
6634
6635 #[tokio::test]
6636 async fn an_unrelated_later_success_is_not_credited_as_a_repair() {
6637 let dir = tempfile::tempdir().unwrap();
6641 let rt = runtime_for(dir.path()).await;
6642 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6643 dir.path().join("repairs.json"),
6644 ));
6645 let script = Script {
6646 turns: vec![
6647 turn(
6648 "trying",
6649 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6650 ),
6651 turn(
6652 "moving on",
6653 json!([{ "id": "c2", "name": "write_file", "arguments": { "path": "note.txt", "content": "hi" } }]),
6654 ),
6655 turn("done", json!([])),
6656 ],
6657 cursor: AtomicUsize::new(0),
6658 };
6659 let mut cfg = cfg();
6660 cfg.tool_memory = Some(memory.clone());
6661 let mut messages = vec![
6662 Message::System {
6663 content: "sys".into(),
6664 },
6665 Message::User {
6666 content: "do two things".into(),
6667 },
6668 ];
6669
6670 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6671
6672 assert_eq!(
6673 memory.learned_count(),
6674 0,
6675 "a different tool succeeding is not a repair for the one that failed"
6676 );
6677 }
6678
6679 async fn learn_over_gap(gap: usize, recover_with: &str) -> usize {
6683 let dir = tempfile::tempdir().unwrap();
6684 let rt = runtime_for(dir.path()).await;
6685 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6686 dir.path().join("repairs.json"),
6687 ));
6688 let mut turns = vec![turn(
6689 "trying",
6690 json!([{ "id": "c0", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6691 )];
6692 for i in 0..gap {
6694 turns.push(turn(
6695 "thinking",
6696 json!([{ "id": format!("g{i}"), "name": "todo_write",
6697 "arguments": { "items": [{"task": format!("step {i}"), "status": "pending"}] } }]),
6698 ));
6699 }
6700 turns.push(turn(
6701 "retrying",
6702 json!([{ "id": "cN", "name": "calculate", "arguments": { "expression": recover_with } }]),
6703 ));
6704 turns.push(turn("done", json!([])));
6705 let script = Script {
6706 turns,
6707 cursor: AtomicUsize::new(0),
6708 };
6709 let mut cfg = cfg();
6710 cfg.max_turns = 12;
6711 cfg.tool_memory = Some(memory.clone());
6712 let mut messages = vec![
6713 Message::System {
6714 content: "sys".into(),
6715 },
6716 Message::User {
6717 content: "compute six times seven".into(),
6718 },
6719 ];
6720 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6721 memory.learned_count()
6722 }
6723
6724 #[tokio::test]
6725 async fn a_recovery_inside_the_window_is_learned_and_one_outside_it_is_not() {
6726 assert_eq!(learn_over_gap(0, "6*7").await, 1, "next turn is a recovery");
6730 assert_eq!(
6731 learn_over_gap(RECOVERY_WINDOW_TURNS as usize - 1, "6*7").await,
6732 1,
6733 "the last turn inside the window still counts"
6734 );
6735 assert_eq!(
6736 learn_over_gap(RECOVERY_WINDOW_TURNS as usize + 2, "6*7").await,
6737 0,
6738 "well past the window is not a repair"
6739 );
6740 }
6741
6742 #[tokio::test]
6743 async fn an_identical_retry_that_happens_to_work_is_not_a_repair() {
6744 assert_eq!(
6749 learn_over_gap(0, "6 ** ** 7").await,
6750 0,
6751 "same arguments succeeding is a transient, not a repair"
6752 );
6753 }
6754
6755 #[tokio::test]
6756 async fn a_success_on_a_different_tool_never_closes_another_tools_failure() {
6757 let dir = tempfile::tempdir().unwrap();
6762 let rt = runtime_for(dir.path()).await;
6763 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6764 dir.path().join("repairs.json"),
6765 ));
6766 let script = Script {
6767 turns: vec![
6768 turn(
6769 "trying",
6770 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6771 ),
6772 turn(
6773 "different tool",
6774 json!([{ "id": "c2", "name": "todo_write",
6775 "arguments": { "items": [{"task": "unrelated", "status": "pending"}] } }]),
6776 ),
6777 turn("done", json!([])),
6778 ],
6779 cursor: AtomicUsize::new(0),
6780 };
6781 let mut cfg = cfg();
6782 cfg.tool_memory = Some(memory.clone());
6783 let mut messages = vec![
6784 Message::System {
6785 content: "sys".into(),
6786 },
6787 Message::User {
6788 content: "do things".into(),
6789 },
6790 ];
6791 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6792 assert_eq!(
6793 memory.learned_count(),
6794 0,
6795 "a different tool's success is not a repair for this one"
6796 );
6797 }
6798
6799 #[tokio::test]
6800 async fn one_stale_lead_costs_exactly_one_failure_however_many_retries() {
6801 let dir = tempfile::tempdir().unwrap();
6805 let rt = runtime_for(dir.path()).await;
6806 let store = dir.path().join("repairs.json");
6807 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6808 store.clone(),
6809 ));
6810 let mut cfg = cfg();
6811 cfg.max_turns = 12;
6812 cfg.tool_memory = Some(memory.clone());
6813
6814 let learn = Script {
6816 turns: vec![
6817 turn(
6818 "trying",
6819 json!([{ "id": "a1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6820 ),
6821 turn(
6822 "retrying",
6823 json!([{ "id": "a2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6824 ),
6825 turn("done", json!([])),
6826 ],
6827 cursor: AtomicUsize::new(0),
6828 };
6829 let mut messages = vec![
6830 Message::System {
6831 content: "sys".into(),
6832 },
6833 Message::User {
6834 content: "compute".into(),
6835 },
6836 ];
6837 run_assistant_loop(&learn, &rt, &cfg, &mut messages, |_| {}).await;
6838 assert_eq!(memory.learned_count(), 1);
6839
6840 let mut turns = Vec::new();
6843 for i in 0..5 {
6844 turns.push(turn(
6845 "failing",
6846 json!([{ "id": format!("b{i}"), "name": "calculate",
6847 "arguments": { "expression": format!("{i} ** ** {i}") } }]),
6848 ));
6849 }
6850 turns.push(turn("giving up", json!([])));
6851 let retry_storm = Script {
6852 turns,
6853 cursor: AtomicUsize::new(0),
6854 };
6855 let mut messages = vec![
6856 Message::System {
6857 content: "sys".into(),
6858 },
6859 Message::User {
6860 content: "compute".into(),
6861 },
6862 ];
6863 run_assistant_loop(&retry_storm, &rt, &cfg, &mut messages, |_| {}).await;
6864
6865 let sig = crate::assistant::tool_memory::FailureSignature::from_failure(
6866 "calculate",
6867 "[FAILED] bad expression",
6868 );
6869 assert!(
6870 memory.recall(&sig).is_some(),
6871 "one offered lead must cost one failure, not one per retry — \
6872 five penalties would have degraded it"
6873 );
6874 }
6875
6876 #[tokio::test]
6877 async fn penalty_markers_do_not_leak_between_runs() {
6878 let dir = tempfile::tempdir().unwrap();
6882 let rt = runtime_for(dir.path()).await;
6883 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6884 dir.path().join("repairs.json"),
6885 ));
6886 let mut cfg = cfg();
6887 cfg.tool_memory = Some(memory.clone());
6888
6889 let learn = Script {
6890 turns: vec![
6891 turn(
6892 "trying",
6893 json!([{ "id": "a1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6894 ),
6895 turn(
6896 "retrying",
6897 json!([{ "id": "a2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6898 ),
6899 turn("done", json!([])),
6900 ],
6901 cursor: AtomicUsize::new(0),
6902 };
6903 let mut messages = vec![
6904 Message::System {
6905 content: "sys".into(),
6906 },
6907 Message::User {
6908 content: "compute".into(),
6909 },
6910 ];
6911 run_assistant_loop(&learn, &rt, &cfg, &mut messages, |_| {}).await;
6912
6913 for round in 0..3 {
6916 let single = Script {
6917 turns: vec![
6918 turn(
6919 "trying",
6920 json!([{ "id": format!("r{round}"), "name": "calculate",
6921 "arguments": { "expression": format!("{round} ** ** {round}") } }]),
6922 ),
6923 turn("done", json!([])),
6924 ],
6925 cursor: AtomicUsize::new(0),
6926 };
6927 let mut messages = vec![
6928 Message::System {
6929 content: "sys".into(),
6930 },
6931 Message::User {
6932 content: "compute".into(),
6933 },
6934 ];
6935 run_assistant_loop(&single, &rt, &cfg, &mut messages, |_| {}).await;
6936 }
6937
6938 let sig = crate::assistant::tool_memory::FailureSignature::from_failure(
6939 "calculate",
6940 "[FAILED] bad expression",
6941 );
6942 assert!(
6943 memory.recall(&sig).is_some(),
6944 "a first failure in a fresh run is not evidence against a lead"
6945 );
6946 }
6947
6948 #[tokio::test]
6949 async fn the_none_path_writes_nothing_to_disk() {
6950 let dir = tempfile::tempdir().unwrap();
6953 let rt = runtime_for(dir.path()).await;
6954 let store = dir.path().join("repairs.json");
6955 let script = Script {
6956 turns: vec![
6957 turn(
6958 "trying",
6959 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6960 ),
6961 turn(
6962 "retrying",
6963 json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6964 ),
6965 turn("done", json!([])),
6966 ],
6967 cursor: AtomicUsize::new(0),
6968 };
6969 let mut messages = vec![
6970 Message::System {
6971 content: "sys".into(),
6972 },
6973 Message::User {
6974 content: "compute".into(),
6975 },
6976 ];
6977 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
6978 assert!(
6979 !store.exists(),
6980 "a surface that did not opt in must leave no store behind"
6981 );
6982 }
6983
6984 #[test]
6985 fn a_delegate_child_inherits_the_learning_store() {
6986 let dir = tempfile::tempdir().unwrap();
6990 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6991 dir.path().join("repairs.json"),
6992 ));
6993 let mut parent = cfg();
6994 parent.tools = vec![json!({
6995 "name": "calculate",
6996 "description": "d",
6997 "input_schema": {"type": "object"}
6998 })];
6999 parent.tool_memory = Some(memory.clone());
7000 let child = delegate_child_config(
7001 &parent,
7002 &DelegateRequest {
7003 goal: "sub".into(),
7004 tools: None,
7005 max_turns: 2,
7006 },
7007 )
7008 .expect("child config");
7009 let inherited = child.tool_memory.expect("child inherits the store");
7010 assert!(
7011 Arc::ptr_eq(&inherited, &memory),
7012 "the child must learn into the SAME store, not a fresh one"
7013 );
7014 }
7015
7016 #[tokio::test]
7017 async fn a_secret_in_a_recovering_call_never_reaches_the_store_through_the_loop() {
7018 let dir = tempfile::tempdir().unwrap();
7021 let rt = runtime_for(dir.path()).await;
7022 let store = dir.path().join("repairs.json");
7023 let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
7024 store.clone(),
7025 ));
7026 let script = Script {
7027 turns: vec![
7028 turn(
7029 "trying",
7030 json!([{ "id": "c1", "name": "write_file",
7031 "arguments": { "path": "x.txt", "content": "nope", "bogus": true } }]),
7032 ),
7033 turn(
7034 "retrying",
7035 json!([{ "id": "c2", "name": "write_file",
7036 "arguments": { "path": "x.txt",
7037 "content": "token ghp_ABCDEFGHIJKLMNOPQRST" } }]),
7038 ),
7039 turn("done", json!([])),
7040 ],
7041 cursor: AtomicUsize::new(0),
7042 };
7043 let mut cfg = cfg();
7044 cfg.tool_memory = Some(memory.clone());
7045 let mut messages = vec![
7046 Message::System {
7047 content: "sys".into(),
7048 },
7049 Message::User {
7050 content: "write the file".into(),
7051 },
7052 ];
7053 run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
7054 if store.exists() {
7055 let on_disk = std::fs::read_to_string(&store).unwrap();
7056 assert!(
7057 !on_disk.contains("ghp_ABCDEFGHIJKLMNOPQRST"),
7058 "a credential must not survive into the durable store: {on_disk}"
7059 );
7060 }
7061 }
7062
7063 #[tokio::test]
7064 async fn learning_is_off_unless_the_surface_opted_in() {
7065 let dir = tempfile::tempdir().unwrap();
7068 let rt = runtime_for(dir.path()).await;
7069 let seen = Arc::new(StdMutex::new(Vec::new()));
7070 let script = CapturingScript {
7071 turns: vec![
7072 turn(
7073 "trying",
7074 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
7075 ),
7076 turn("done", json!([])),
7077 ],
7078 cursor: AtomicUsize::new(0),
7079 seen: seen.clone(),
7080 };
7081 let mut messages = vec![
7082 Message::System {
7083 content: "sys".into(),
7084 },
7085 Message::User {
7086 content: "compute".into(),
7087 },
7088 ];
7089
7090 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
7091
7092 let captured = seen.lock().unwrap();
7093 assert!(
7094 captured.iter().all(|req| !req
7095 .context
7096 .as_deref()
7097 .unwrap_or("")
7098 .contains("Learned Repairs")),
7099 "no surface opted in, so nothing should be recalled"
7100 );
7101 }
7102
7103 #[tokio::test]
7104 async fn loop_journals_turn_completed_at_empty_tool_calls_terminal() {
7105 let dir = tempfile::tempdir().unwrap();
7111 let rt = runtime_for(dir.path()).await;
7112 let script = Script {
7113 turns: vec![
7114 turn(
7115 "computing",
7116 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
7117 ),
7118 turn("The answer is 42.", json!([])),
7119 ],
7120 cursor: AtomicUsize::new(0),
7121 };
7122 let mut messages = vec![
7123 Message::System {
7124 content: "sys".into(),
7125 },
7126 Message::User {
7127 content: "what is 6*7?".into(),
7128 },
7129 ];
7130 let mut events = Vec::new();
7131 let outcome =
7132 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
7133 assert_eq!(outcome.status, "success");
7134 assert_eq!(outcome.model_used, "scripted");
7136
7137 let log = rt.log.lock().await;
7138 let tc = log
7139 .events()
7140 .iter()
7141 .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
7142 .expect("empty-tool-calls terminal must journal a TurnCompleted");
7143 assert_eq!(
7144 tc.data.get("decision"),
7145 Some(&serde_json::json!("empty_tool_calls"))
7146 );
7147 assert_eq!(tc.data.get("turns"), Some(&serde_json::json!(2)));
7148 assert_eq!(
7149 tc.data.get("model_id"),
7150 Some(&serde_json::json!("scripted"))
7151 );
7152 assert_eq!(
7154 tc.data.get("model_tier"),
7155 Some(&serde_json::json!("unknown"))
7156 );
7157 }
7158
7159 #[tokio::test]
7160 async fn loop_journals_turn_completed_at_max_turns_terminal() {
7161 let dir = tempfile::tempdir().unwrap();
7166 let rt = runtime_for(dir.path()).await;
7167 let tool_turn = || {
7168 turn(
7169 "still going",
7170 json!([{ "id": "c", "name": "calculate", "arguments": { "expression": "1+1" } }]),
7171 )
7172 };
7173 let script = Script {
7174 turns: (0..10).map(|_| tool_turn()).collect(),
7175 cursor: AtomicUsize::new(0),
7176 };
7177 let mut messages = vec![
7178 Message::System {
7179 content: "sys".into(),
7180 },
7181 Message::User {
7182 content: "loop".into(),
7183 },
7184 ];
7185 let cfg = AssistantConfig {
7187 max_turns: 3,
7188 ..cfg()
7189 };
7190 let mut events = Vec::new();
7191 let outcome =
7192 run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
7193 assert_eq!(outcome.status, "max_turns");
7194
7195 let log = rt.log.lock().await;
7196 let tc = log
7197 .events()
7198 .iter()
7199 .find(|e| {
7200 e.kind == car_eventlog::EventKind::TurnCompleted
7201 && e.data.get("decision") == Some(&serde_json::json!("max_turns"))
7202 })
7203 .expect("max_turns terminal must journal a TurnCompleted");
7204 assert_eq!(tc.data.get("turns"), Some(&serde_json::json!(3)));
7205 }
7206
7207 #[test]
7208 fn summary_claim_grounding_requires_matching_receipts() {
7209 let ungrounded = ungrounded_summary_claims("I ran the tests and they passed.", &[]);
7210 assert_eq!(ungrounded, vec!["tests were run/passed"]);
7211
7212 let grounded = ungrounded_summary_claims(
7213 "I ran the tests and they passed.",
7214 &[AssistantToolReceipt {
7215 tool: "shell".into(),
7216 call_id: Some("s1".into()),
7217 sequence: None,
7218 ok: true,
7219 params: json!({ "command": "cargo test -q" }),
7220 result: None,
7221 via: None,
7222 }],
7223 );
7224 assert!(grounded.is_empty(), "{grounded:?}");
7225
7226 let failed = ungrounded_summary_claims(
7227 "I ran the tests and they passed.",
7228 &[AssistantToolReceipt {
7229 tool: "shell".into(),
7230 call_id: Some("s1".into()),
7231 sequence: None,
7232 ok: false,
7233 params: json!({ "command": "cargo test -q" }),
7234 result: None,
7235 via: None,
7236 }],
7237 );
7238 assert_eq!(failed, vec!["tests were run/passed"]);
7239 }
7240
7241 #[test]
7242 fn summary_claim_grounding_catches_verification_and_check_claims() {
7243 assert_eq!(
7244 ungrounded_summary_claims("Verified with cargo test.", &[]),
7245 vec!["tests were run/passed"]
7246 );
7247 assert_eq!(
7248 ungrounded_summary_claims("cargo check passed.", &[]),
7249 vec!["build succeeded", "checks were run/passed"]
7250 );
7251 assert_eq!(
7252 ungrounded_summary_claims("All checks are green.", &[]),
7253 vec!["checks were run/passed"]
7254 );
7255
7256 let cargo_check = [AssistantToolReceipt {
7257 tool: "shell".into(),
7258 call_id: Some("s1".into()),
7259 sequence: None,
7260 ok: true,
7261 params: json!({ "command": "cargo check -p car-server-core" }),
7262 result: None,
7263 via: None,
7264 }];
7265 assert!(
7266 ungrounded_summary_claims("cargo check passed.", &cargo_check).is_empty(),
7267 "cargo check receipt should ground both build and check claims"
7268 );
7269
7270 let diff_check = [AssistantToolReceipt {
7271 tool: "shell".into(),
7272 call_id: Some("s2".into()),
7273 sequence: None,
7274 ok: true,
7275 params: json!({ "command": "git diff --check" }),
7276 result: None,
7277 via: None,
7278 }];
7279 assert!(
7280 ungrounded_summary_claims("All checks are green.", &diff_check).is_empty(),
7281 "diff-check receipt should ground generic check claims"
7282 );
7283
7284 let tests = [AssistantToolReceipt {
7285 tool: "shell".into(),
7286 call_id: Some("s3".into()),
7287 sequence: None,
7288 ok: true,
7289 params: json!({ "command": "npm run test -- --watch=false" }),
7290 result: None,
7291 via: None,
7292 }];
7293 assert!(
7294 ungrounded_summary_claims("Verified with npm run test.", &tests).is_empty(),
7295 "npm run test receipt should ground verification test claims"
7296 );
7297
7298 assert_eq!(
7299 ungrounded_summary_claims("ctest passed.", &[]),
7300 vec!["tests were run/passed"]
7301 );
7302
7303 let ctest = [AssistantToolReceipt {
7304 tool: "shell".into(),
7305 call_id: Some("s4".into()),
7306 sequence: None,
7307 ok: true,
7308 params: json!({ "command": "ctest --test-dir build --output-on-failure" }),
7309 result: None,
7310 via: None,
7311 }];
7312 assert!(
7313 ungrounded_summary_claims("ctest passed.", &ctest).is_empty(),
7314 "ctest receipt should ground CMake test claims"
7315 );
7316
7317 let cmake_build = [AssistantToolReceipt {
7318 tool: "shell".into(),
7319 call_id: Some("s5".into()),
7320 sequence: None,
7321 ok: true,
7322 params: json!({ "command": "cmake -S . -B build && cmake --build build" }),
7323 result: None,
7324 via: None,
7325 }];
7326 assert!(
7327 ungrounded_summary_claims("CMake build succeeded.", &cmake_build).is_empty(),
7328 "cmake --build receipt should ground CMake build claims"
7329 );
7330
7331 let pnpm_check = [AssistantToolReceipt {
7332 tool: "shell".into(),
7333 call_id: Some("s6".into()),
7334 sequence: None,
7335 ok: true,
7336 params: json!({ "command": "pnpm check" }),
7337 result: None,
7338 via: None,
7339 }];
7340 assert!(
7341 ungrounded_summary_claims("Checks passed.", &pnpm_check).is_empty(),
7342 "package check receipts should ground generic check claims"
7343 );
7344 }
7345
7346 #[test]
7347 fn production_investigation_claims_require_matching_live_receipts() {
7348 let summary = "Repository is clean and HEAD matches origin. Application Insights telemetry showed no recurrence. The production portal page was inspected.";
7349 assert_eq!(
7350 ungrounded_summary_claims(summary, &[]),
7351 vec![
7352 "repository cleanliness was verified",
7353 "repository revision/remote relationship was verified",
7354 "live Application Insights evidence was observed",
7355 "production browser state was observed",
7356 ]
7357 );
7358
7359 let receipts = vec![
7360 AssistantToolReceipt {
7361 tool: "shell".into(),
7362 call_id: Some("git".into()),
7363 sequence: None,
7364 ok: true,
7365 params: json!({"command": "git status && git rev-parse HEAD && git rev-parse origin/main"}),
7366 result: None,
7367 via: None,
7368 },
7369 AssistantToolReceipt {
7370 tool: "shell".into(),
7371 call_id: Some("ai".into()),
7372 sequence: None,
7373 ok: true,
7374 params: json!({"command": "az monitor app-insights query --analytics-query 'exceptions | summarize count()'"}),
7375 result: None,
7376 via: None,
7377 },
7378 AssistantToolReceipt {
7379 tool: "browse_observe".into(),
7380 call_id: Some("browser".into()),
7381 sequence: None,
7382 ok: true,
7383 params: json!({}),
7384 result: None,
7385 via: None,
7386 },
7387 ];
7388 assert!(ungrounded_summary_claims(summary, &receipts).is_empty());
7389
7390 let cautious = "Source tests assert Information-level logging without an exception object.\nApplication Insights query: not obtained.\nProduction browser state: not obtained.\nCannot determine whether N744JS is outside the subscription or whether deployment 23708 fixed the issue.";
7391 assert!(
7392 ungrounded_summary_claims(cautious, &[]).is_empty(),
7393 "explicitly source-scoped and negated claims must not be rejected"
7394 );
7395 }
7396
7397 #[test]
7398 fn summary_file_claim_grounding_requires_matching_named_path() {
7399 let other_edit = [AssistantToolReceipt {
7400 tool: "edit_file".into(),
7401 call_id: Some("e1".into()),
7402 sequence: None,
7403 ok: true,
7404 params: json!({ "path": "src/other.rs" }),
7405 result: None,
7406 via: None,
7407 }];
7408 assert_eq!(
7409 ungrounded_summary_claims("Updated file src/lib.rs.", &other_edit),
7410 vec!["files were created/updated"]
7411 );
7412
7413 let matching_edit = [AssistantToolReceipt {
7414 tool: "edit_file".into(),
7415 call_id: Some("e2".into()),
7416 sequence: None,
7417 ok: true,
7418 params: json!({ "path": "./src/lib.rs" }),
7419 result: None,
7420 via: None,
7421 }];
7422 assert!(
7423 ungrounded_summary_claims("Updated file src/lib.rs.", &matching_edit).is_empty(),
7424 "matching edit_file path should ground the specific update claim"
7425 );
7426
7427 let shell_touch = [AssistantToolReceipt {
7428 tool: "shell".into(),
7429 call_id: Some("s1".into()),
7430 sequence: None,
7431 ok: true,
7432 params: json!({ "command": "touch src/lib.rs" }),
7433 result: None,
7434 via: None,
7435 }];
7436 assert!(
7437 ungrounded_summary_claims("Created file src/lib.rs.", &shell_touch).is_empty(),
7438 "matching shell command path should ground the specific creation claim"
7439 );
7440 }
7441
7442 #[test]
7443 fn summary_read_claim_grounding_requires_matching_named_path() {
7444 let other_read = [AssistantToolReceipt {
7445 tool: "read_file".into(),
7446 call_id: Some("r1".into()),
7447 sequence: None,
7448 ok: true,
7449 params: json!({ "path": "src/other.rs" }),
7450 result: None,
7451 via: None,
7452 }];
7453 assert_eq!(
7454 ungrounded_summary_claims("Inspected file src/lib.rs.", &other_read),
7455 vec!["files were read/inspected"]
7456 );
7457
7458 let matching_read = [AssistantToolReceipt {
7459 tool: "read_file".into(),
7460 call_id: Some("r2".into()),
7461 sequence: None,
7462 ok: true,
7463 params: json!({ "path": "src/lib.rs" }),
7464 result: None,
7465 via: None,
7466 }];
7467 assert!(
7468 ungrounded_summary_claims("Inspected file src/lib.rs.", &matching_read).is_empty(),
7469 "matching read_file path should ground the specific inspection claim"
7470 );
7471
7472 let generic_update = [AssistantToolReceipt {
7473 tool: "edit_file".into(),
7474 call_id: Some("e1".into()),
7475 sequence: None,
7476 ok: true,
7477 params: json!({ "path": "src/lib.rs" }),
7478 result: None,
7479 via: None,
7480 }];
7481 assert!(
7482 ungrounded_summary_claims("Updated files.", &generic_update).is_empty(),
7483 "generic file claims should keep the existing tool-class grounding"
7484 );
7485 }
7486
7487 #[tokio::test]
7493 async fn goal_loop_converges_when_the_command_check_passes() {
7494 use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7495
7496 let dir = tempfile::tempdir().unwrap();
7497 let rt = runtime_for(dir.path()).await;
7498
7499 let create = crate::coder::test_cmds::touch("donefile");
7502 let script = Script {
7503 turns: vec![
7504 turn("Let me start.", json!([])),
7505 turn(
7506 "creating it",
7507 json!([{ "id": "s1", "name": "shell", "arguments": { "command": create } }]),
7508 ),
7509 turn("Done — created donefile.", json!([])),
7510 ],
7511 cursor: AtomicUsize::new(0),
7512 };
7513
7514 let spec = GoalSpec {
7515 goal: "create a file named donefile".into(),
7516 condition: GoalCondition::Command {
7517 id: "donefile".into(),
7518 expect_exit: 0,
7519 },
7520 governor: GoalGovernor {
7521 max_turns: Some(5),
7522 ..Default::default()
7523 },
7524 };
7525
7526 let mut messages = vec![Message::System {
7527 content: "sys".into(),
7528 }];
7529 let never = std::sync::atomic::AtomicBool::new(false);
7530 let donefile = dir.path().join("donefile");
7531 let mut events = Vec::new();
7532
7533 let result = run_assistant_goal_loop(
7534 &script,
7535 &rt,
7536 &cfg(),
7537 &mut messages,
7538 &never,
7539 None,
7540 &spec,
7541 |_outcome| {
7542 let exists = donefile.exists();
7544 async move {
7545 let mut g = car_engine::GoalGather::default();
7546 g.command_exits
7547 .insert("donefile".into(), if exists { 0 } else { 1 });
7548 g
7549 }
7550 },
7551 |e| events.push(e),
7552 )
7553 .await;
7554
7555 assert_eq!(
7556 result.run.status,
7557 GoalStatus::Achieved,
7558 "{:?}",
7559 result.run.last_reason
7560 );
7561 assert_eq!(
7562 result.run.iterations, 2,
7563 "should converge on the 2nd iteration"
7564 );
7565 assert!(
7566 result.run.grounded,
7567 "a Command-check completion is grounded"
7568 );
7569 assert!(donefile.exists(), "the real file must have been created");
7570 assert_eq!(
7571 result.outcome.models_served.len(),
7572 3,
7573 "the terminal goal-run receipt must retain model calls from every iteration"
7574 );
7575 assert!(result
7576 .outcome
7577 .models_served
7578 .iter()
7579 .all(|attribution| attribution.model_id == "scripted"));
7580 let checks: Vec<_> = events
7581 .iter()
7582 .filter_map(|e| match e {
7583 AssistantEvent::GoalEvaluated {
7584 iteration,
7585 met,
7586 grounded,
7587 reason,
7588 } => Some((*iteration, *met, *grounded, reason.as_str())),
7589 _ => None,
7590 })
7591 .collect();
7592 assert_eq!(checks.len(), 2, "one verifier event per goal iteration");
7593 assert_eq!(checks[0].0, 1);
7594 assert!(
7595 !checks[0].1,
7596 "first iteration should not meet the command condition"
7597 );
7598 assert_eq!(checks[1].0, 2);
7599 assert!(
7600 checks[1].1,
7601 "second iteration should meet the command condition"
7602 );
7603 assert!(checks[1].2, "command-backed completion is grounded");
7604
7605 let log = rt.log.lock().await;
7606 let goal_events: Vec<_> = log
7607 .events()
7608 .iter()
7609 .filter(|e| e.kind == car_eventlog::EventKind::GoalEvaluated)
7610 .collect();
7611 assert_eq!(
7612 goal_events.len(),
7613 2,
7614 "event log should audit each verifier pass"
7615 );
7616 assert_eq!(goal_events[0].data.get("iteration"), Some(&json!(1)));
7617 assert_eq!(goal_events[0].data.get("met"), Some(&json!(false)));
7618 assert_eq!(
7619 goal_events[1].data.get("goal"),
7620 Some(&json!("create a file named donefile"))
7621 );
7622 assert_eq!(
7623 goal_events[1].data.get("condition"),
7624 Some(&json!({"kind": "command", "id": "donefile", "expect_exit": 0}))
7625 );
7626 assert_eq!(goal_events[1].data.get("iteration"), Some(&json!(2)));
7627 assert_eq!(goal_events[1].data.get("met"), Some(&json!(true)));
7628 assert_eq!(goal_events[1].data.get("grounded"), Some(&json!(true)));
7629 }
7630
7631 #[tokio::test]
7637 async fn deterministic_pass_not_reopened_by_ungrounded_prose() {
7638 use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7639
7640 let dir = tempfile::tempdir().unwrap();
7641 let rt = runtime_for(dir.path()).await;
7642 let script = Script {
7645 turns: vec![turn("I ran the tests and they passed.", json!([]))],
7646 cursor: AtomicUsize::new(0),
7647 };
7648 let spec = GoalSpec {
7649 goal: "make tests pass".into(),
7650 condition: GoalCondition::Command {
7651 id: "tests".into(),
7652 expect_exit: 0,
7653 },
7654 governor: GoalGovernor {
7655 max_turns: Some(3),
7656 ..Default::default()
7657 },
7658 };
7659 let mut messages = vec![Message::System {
7660 content: "sys".into(),
7661 }];
7662 let never = std::sync::atomic::AtomicBool::new(false);
7663 let mut events = Vec::new();
7664
7665 let result = run_assistant_goal_loop(
7666 &script,
7667 &rt,
7668 &cfg(),
7669 &mut messages,
7670 &never,
7671 None,
7672 &spec,
7673 |_outcome| async move {
7674 let mut g = car_engine::GoalGather::default();
7675 g.command_exits.insert("tests".into(), 0);
7676 g
7677 },
7678 |e| events.push(e),
7679 )
7680 .await;
7681
7682 assert_eq!(
7685 result.run.status,
7686 GoalStatus::Achieved,
7687 "{:?}",
7688 result.run.last_reason
7689 );
7690 assert_eq!(result.run.iterations, 1);
7691 assert!(result.run.grounded, "command-backed completion is grounded");
7692 assert_eq!(result.run.evidence.len(), 1);
7693 assert!(result.run.evidence[0].met && result.run.evidence[0].grounded);
7694 assert!(
7696 result.outcome.summary.contains("[claim check]")
7697 && result.outcome.summary.contains("tests were run/passed"),
7698 "summary should carry the claim-check note: {}",
7699 result.outcome.summary
7700 );
7701 assert!(
7704 !serde_json::to_string(&messages)
7705 .unwrap_or_default()
7706 .contains("[claim check]"),
7707 "the claim-check note must not leak into the thread messages"
7708 );
7709 let streamed: Vec<_> = events
7711 .iter()
7712 .filter_map(|e| match e {
7713 AssistantEvent::GoalEvaluated { grounded, .. } => Some(*grounded),
7714 _ => None,
7715 })
7716 .collect();
7717 assert_eq!(streamed, vec![true], "streamed verdict stays grounded=true");
7718 let log = rt.log.lock().await;
7721 let goal_events: Vec<_> = log
7722 .events()
7723 .iter()
7724 .filter(|e| e.kind == car_eventlog::EventKind::GoalEvaluated)
7725 .collect();
7726 assert_eq!(goal_events.len(), 1);
7727 assert_eq!(goal_events[0].data.get("met"), Some(&json!(true)));
7728 assert_eq!(goal_events[0].data.get("grounded"), Some(&json!(true)));
7729 assert!(
7730 !goal_events[0]
7731 .data
7732 .get("reason")
7733 .and_then(|r| r.as_str())
7734 .unwrap_or("")
7735 .contains("ungrounded assistant summary claim"),
7736 "durable reason must not record the prose mismatch as a failure"
7737 );
7738 }
7739
7740 #[tokio::test]
7746 async fn ungrounded_claim_without_deterministic_pass_still_fails_closed() {
7747 use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7748
7749 let dir = tempfile::tempdir().unwrap();
7750 let rt = runtime_for(dir.path()).await;
7751 let script = Script {
7752 turns: vec![turn("I ran the tests and they passed.", json!([]))],
7753 cursor: AtomicUsize::new(0),
7754 };
7755 let spec = GoalSpec {
7758 goal: "make tests pass".into(),
7759 condition: GoalCondition::ModelJudge { id: "judge".into() },
7760 governor: GoalGovernor {
7761 max_turns: Some(1),
7762 ..Default::default()
7763 },
7764 };
7765 let mut messages = vec![Message::System {
7766 content: "sys".into(),
7767 }];
7768 let never = std::sync::atomic::AtomicBool::new(false);
7769
7770 let result = run_assistant_goal_loop(
7771 &script,
7772 &rt,
7773 &cfg(),
7774 &mut messages,
7775 &never,
7776 None,
7777 &spec,
7778 |_outcome| async move {
7779 let mut g = car_engine::GoalGather::default();
7780 g.model_verdicts.insert("judge".into(), true);
7781 g
7782 },
7783 |_| {},
7784 )
7785 .await;
7786
7787 assert_eq!(
7788 result.run.status,
7789 GoalStatus::Halted {
7790 halt: GoalHalt::TurnBudget
7791 }
7792 );
7793 assert_eq!(result.run.evidence.len(), 1);
7794 assert!(result.run.evidence[0].met);
7795 assert!(
7796 !result.run.evidence[0].grounded,
7797 "a model-judge completion with an ungrounded claim stays ungrounded"
7798 );
7799 assert!(result
7800 .run
7801 .last_reason
7802 .contains("ungrounded assistant summary claim"));
7803 assert!(!result.outcome.summary.contains("[claim check]"));
7806 }
7807
7808 #[tokio::test]
7812 async fn grounded_prose_on_deterministic_pass_unannotated() {
7813 use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7814
7815 let dir = tempfile::tempdir().unwrap();
7816 let rt = runtime_for(dir.path()).await;
7817 let create = crate::coder::test_cmds::touch("donefile");
7822 let script = Script {
7823 turns: vec![
7824 turn(
7825 "creating it",
7826 json!([{ "id": "s1", "name": "shell", "arguments": { "command": create } }]),
7827 ),
7828 turn("Done — created donefile.", json!([])),
7829 ],
7830 cursor: AtomicUsize::new(0),
7831 };
7832 let spec = GoalSpec {
7833 goal: "create a file named donefile".into(),
7834 condition: GoalCondition::Command {
7835 id: "donefile".into(),
7836 expect_exit: 0,
7837 },
7838 governor: GoalGovernor {
7839 max_turns: Some(3),
7840 ..Default::default()
7841 },
7842 };
7843 let mut messages = vec![Message::System {
7844 content: "sys".into(),
7845 }];
7846 let never = std::sync::atomic::AtomicBool::new(false);
7847 let donefile = dir.path().join("donefile");
7848
7849 let result = run_assistant_goal_loop(
7850 &script,
7851 &rt,
7852 &cfg(),
7853 &mut messages,
7854 &never,
7855 None,
7856 &spec,
7857 |_outcome| {
7858 let exists = donefile.exists();
7859 async move {
7860 let mut g = car_engine::GoalGather::default();
7861 g.command_exits
7862 .insert("donefile".into(), if exists { 0 } else { 1 });
7863 g
7864 }
7865 },
7866 |_| {},
7867 )
7868 .await;
7869
7870 assert_eq!(
7871 result.run.status,
7872 GoalStatus::Achieved,
7873 "{:?}",
7874 result.run.last_reason
7875 );
7876 assert_eq!(result.run.iterations, 1);
7877 assert!(result.run.grounded);
7878 assert_eq!(result.outcome.summary, "Done — created donefile.");
7880 assert!(!result.outcome.summary.contains("[claim check]"));
7881 }
7882
7883 #[tokio::test]
7886 async fn goal_loop_halts_on_turn_budget() {
7887 use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7888
7889 let dir = tempfile::tempdir().unwrap();
7890 let rt = runtime_for(dir.path()).await;
7891
7892 struct Idle;
7894 #[async_trait]
7895 impl TurnGenerator for Idle {
7896 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
7897 Ok(turn("thinking...", json!([])))
7898 }
7899 }
7900
7901 let spec = GoalSpec {
7902 goal: "impossible".into(),
7903 condition: GoalCondition::Command {
7904 id: "never".into(),
7905 expect_exit: 0,
7906 },
7907 governor: GoalGovernor {
7908 max_turns: Some(3),
7909 ..Default::default()
7910 },
7911 };
7912 let mut messages = vec![Message::System {
7913 content: "sys".into(),
7914 }];
7915 let never = std::sync::atomic::AtomicBool::new(false);
7916
7917 let result = run_assistant_goal_loop(
7918 &Idle,
7919 &rt,
7920 &cfg(),
7921 &mut messages,
7922 &never,
7923 None,
7924 &spec,
7925 |_o| async {
7926 let mut g = car_engine::GoalGather::default();
7927 g.command_exits.insert("never".into(), 1);
7928 g
7929 },
7930 |_e| {},
7931 )
7932 .await;
7933
7934 assert_eq!(
7935 result.run.status,
7936 GoalStatus::Halted {
7937 halt: GoalHalt::TurnBudget
7938 }
7939 );
7940 assert_eq!(result.run.iterations, 3);
7941 }
7942
7943 #[tokio::test(start_paused = true)]
7952 async fn goal_loop_fails_open_when_the_check_never_resolves() {
7953 use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7954
7955 let dir = tempfile::tempdir().unwrap();
7956 let rt = runtime_for(dir.path()).await;
7957
7958 struct Answers;
7962 #[async_trait]
7963 impl TurnGenerator for Answers {
7964 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
7965 Ok(turn("Here is your answer.", json!([])))
7966 }
7967 }
7968
7969 let spec = GoalSpec {
7970 goal: "answer the question".into(),
7971 condition: GoalCondition::Command {
7972 id: "verify".into(),
7973 expect_exit: 0,
7974 },
7975 governor: GoalGovernor {
7976 max_turns: Some(8),
7981 ..Default::default()
7982 },
7983 };
7984 let mut messages = vec![Message::System {
7985 content: "sys".into(),
7986 }];
7987 let never = std::sync::atomic::AtomicBool::new(false);
7988 let mut events = Vec::new();
7989
7990 let result = run_assistant_goal_loop(
7991 &Answers,
7992 &rt,
7993 &cfg(),
7994 &mut messages,
7995 &never,
7996 None,
7997 &spec,
7998 |_outcome| std::future::pending::<car_engine::GoalGather>(),
7999 |e| events.push(e),
8000 )
8001 .await;
8002
8003 assert_eq!(
8004 result.run.status,
8005 GoalStatus::Halted {
8006 halt: GoalHalt::EvaluationTimeout
8007 },
8008 "{:?}",
8009 result.run.last_reason
8010 );
8011 assert_eq!(
8012 result.run.iterations, 1,
8013 "must halt on the FIRST stuck evaluation, not burn the rest of the turn budget \
8014 re-running the model against a check that can never be graded"
8015 );
8016 assert!(
8017 result.run.last_reason.contains("did not complete within"),
8018 "{}",
8019 result.run.last_reason
8020 );
8021 assert_eq!(
8022 result.outcome.summary, "Here is your answer.",
8023 "the primary reply must survive an evaluation pass that never resolves"
8024 );
8025 assert!(
8026 events.iter().any(|e| matches!(
8027 e,
8028 AssistantEvent::GoalEvaluated {
8029 met: false,
8030 grounded: false,
8031 ..
8032 }
8033 )),
8034 "the unevaluated outcome must still be streamed as a goal_evaluated event — \
8035 grounded: false, not true: there is no verdict to be grounded, the check \
8036 never ran (car#1113 review)"
8037 );
8038 assert!(
8039 !result.run.grounded,
8040 "GoalRun.grounded must not claim a deterministic verdict exists when the \
8041 check never got the chance to run"
8042 );
8043 }
8044
8045 struct FixedGate(bool);
8046 #[async_trait]
8047 impl ApprovalGate for FixedGate {
8048 async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
8049 if self.0 {
8050 ApprovalDecision::Approved
8051 } else {
8052 ApprovalDecision::Denied("user declined".into())
8053 }
8054 }
8055 }
8056
8057 struct CapturingGen {
8058 images_seen: std::sync::Arc<std::sync::Mutex<Option<usize>>>,
8059 }
8060 #[async_trait]
8061 impl TurnGenerator for CapturingGen {
8062 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
8063 *self.images_seen.lock().unwrap() = req.images.as_ref().map(|v| v.len());
8064 Ok(turn("done", json!([]))) }
8066 }
8067
8068 #[tokio::test]
8069 async fn images_are_attached_to_the_first_request() {
8070 let dir = tempfile::tempdir().unwrap();
8071 let rt = runtime_for(dir.path()).await;
8072 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
8073 let generator = CapturingGen {
8074 images_seen: seen.clone(),
8075 };
8076 let img = ContentBlock::ImageUrl {
8077 url: "https://example.com/x.png".into(),
8078 detail: "auto".into(),
8079 };
8080 let mut messages = vec![
8081 Message::System {
8082 content: "s".into(),
8083 },
8084 Message::User {
8085 content: "describe".into(),
8086 },
8087 ];
8088 let never = std::sync::atomic::AtomicBool::new(false);
8089 let imgs = [img];
8090 run_assistant_loop_cancellable(
8091 &generator,
8092 &rt,
8093 &cfg(),
8094 &mut messages,
8095 &never,
8096 None,
8097 Some(&imgs),
8098 |_| {},
8099 )
8100 .await;
8101 assert_eq!(
8102 *seen.lock().unwrap(),
8103 Some(1),
8104 "the image should reach the first request"
8105 );
8106 }
8107
8108 #[tokio::test]
8109 async fn gated_tool_is_denied_without_a_gate() {
8110 let dir = tempfile::tempdir().unwrap();
8111 let rt = runtime_for(dir.path()).await;
8112 let script = Script {
8113 turns: vec![
8114 turn(
8115 "",
8116 json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "x.txt", "content": "no" } }]),
8117 ),
8118 turn("could not write", json!([])),
8119 ],
8120 cursor: AtomicUsize::new(0),
8121 };
8122 let mut cfg = cfg();
8123 cfg.gated_tools = vec!["write_file".into()];
8124 let mut messages = vec![
8125 Message::System {
8126 content: "s".into(),
8127 },
8128 Message::User {
8129 content: "write x".into(),
8130 },
8131 ];
8132 let never = std::sync::atomic::AtomicBool::new(false);
8133 let outcome = run_assistant_loop_cancellable(
8134 &script,
8135 &rt,
8136 &cfg,
8137 &mut messages,
8138 &never,
8139 None,
8140 None,
8141 |_| {},
8142 )
8143 .await;
8144 assert_eq!(outcome.status, "success");
8145 assert!(
8146 !dir.path().join("x.txt").exists(),
8147 "gated write must not run"
8148 );
8149 assert!(!outcome.tools_called.contains(&"write_file".to_string()));
8150 }
8151
8152 #[tokio::test]
8153 async fn gated_tool_runs_when_approved() {
8154 let dir = tempfile::tempdir().unwrap();
8155 let rt = runtime_for(dir.path()).await;
8156 let script = Script {
8157 turns: vec![
8158 turn(
8159 "",
8160 json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "ok.txt", "content": "yes" } }]),
8161 ),
8162 turn("wrote it", json!([])),
8163 ],
8164 cursor: AtomicUsize::new(0),
8165 };
8166 let mut cfg = cfg();
8167 cfg.gated_tools = vec!["write_file".into()];
8168 let gate = FixedGate(true);
8169 let mut messages = vec![
8170 Message::System {
8171 content: "s".into(),
8172 },
8173 Message::User {
8174 content: "write ok".into(),
8175 },
8176 ];
8177 let never = std::sync::atomic::AtomicBool::new(false);
8178 let outcome = run_assistant_loop_cancellable(
8179 &script,
8180 &rt,
8181 &cfg,
8182 &mut messages,
8183 &never,
8184 Some(&gate),
8185 None,
8186 |_| {},
8187 )
8188 .await;
8189 assert_eq!(outcome.status, "success");
8190 assert_eq!(
8191 std::fs::read_to_string(dir.path().join("ok.txt")).unwrap(),
8192 "yes"
8193 );
8194 }
8195
8196 #[tokio::test]
8197 async fn loop_writes_a_file_through_the_runtime() {
8198 let dir = tempfile::tempdir().unwrap();
8199 let rt = runtime_for(dir.path()).await;
8200 let script = Script {
8201 turns: vec![
8202 turn(
8203 "",
8204 json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
8205 ),
8206 turn("Wrote hi.txt.", json!([])),
8207 ],
8208 cursor: AtomicUsize::new(0),
8209 };
8210 let mut messages = vec![
8211 Message::System {
8212 content: "sys".into(),
8213 },
8214 Message::User {
8215 content: "write hi.txt".into(),
8216 },
8217 ];
8218 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8219 assert_eq!(outcome.status, "success");
8220 assert_eq!(
8221 std::fs::read_to_string(dir.path().join("hi.txt")).unwrap(),
8222 "hello"
8223 );
8224 }
8225
8226 fn json_object_cfg() -> AssistantConfig {
8229 AssistantConfig {
8230 response_format: Some(car_inference::ResponseFormat::JsonObject),
8231 ..cfg()
8232 }
8233 }
8234
8235 fn repair_notices(events: &[AssistantEvent]) -> (usize, usize) {
8236 let mut fired = 0;
8237 let mut still_invalid = 0;
8238 for e in events {
8239 if let AssistantEvent::Text(t) = e {
8240 if t == FORMAT_REPAIR_NOTICE {
8241 fired += 1;
8242 }
8243 if t == FORMAT_REPAIR_STILL_INVALID {
8244 still_invalid += 1;
8245 }
8246 }
8247 }
8248 (fired, still_invalid)
8249 }
8250
8251 #[tokio::test]
8255 async fn response_format_is_never_on_tool_turns_only_on_the_repair_turn() {
8256 let dir = tempfile::tempdir().unwrap();
8257 let rt = runtime_for(dir.path()).await;
8258 let tool_turn = || {
8259 turn(
8260 "computing",
8261 json!([{ "id": "c", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8262 )
8263 };
8264
8265 let seen = Arc::new(StdMutex::new(Vec::new()));
8267 let script = CapturingScript {
8268 turns: vec![
8269 tool_turn(),
8270 turn("The sum is 2.", json!([])),
8271 turn(r#"{"sum": 2}"#, json!([])),
8272 ],
8273 cursor: AtomicUsize::new(0),
8274 seen: Arc::clone(&seen),
8275 };
8276 let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8277 let outcome =
8278 run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |_| {}).await;
8279 assert_eq!(outcome.status, "success");
8280 assert_eq!(outcome.summary, r#"{"sum": 2}"#);
8281 {
8282 let reqs = seen.lock().unwrap();
8283 assert_eq!(reqs.len(), 3);
8284 for (i, r) in reqs[..2].iter().enumerate() {
8285 assert!(r.tools.is_some(), "request {i} offers tools");
8286 assert!(
8287 r.response_format.is_none(),
8288 "request {i} offers tools, so it must not be JSON-constrained"
8289 );
8290 }
8291 assert!(reqs[2].tools.is_none(), "the repair turn offers no tools");
8292 assert_eq!(
8293 reqs[2].response_format,
8294 Some(car_inference::ResponseFormat::JsonObject),
8295 "and is the one request that carries the format"
8296 );
8297 }
8298
8299 let seen = Arc::new(StdMutex::new(Vec::new()));
8301 let script = CapturingScript {
8302 turns: vec![turn(r#"{"sum": 2}"#, json!([]))],
8303 cursor: AtomicUsize::new(0),
8304 seen: Arc::clone(&seen),
8305 };
8306 let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8307 let no_tools_cfg = AssistantConfig {
8308 tools: Vec::new(),
8309 ..json_object_cfg()
8310 };
8311 let outcome = run_assistant_loop(&script, &rt, &no_tools_cfg, &mut messages, |_| {}).await;
8312 assert_eq!(outcome.status, "success");
8313 {
8314 let reqs = seen.lock().unwrap();
8315 assert_eq!(reqs.len(), 1, "a valid answer costs no extra call");
8316 assert!(reqs[0].tools.is_none());
8317 assert_eq!(
8318 reqs[0].response_format,
8319 Some(car_inference::ResponseFormat::JsonObject)
8320 );
8321 }
8322
8323 let seen = Arc::new(StdMutex::new(Vec::new()));
8325 let script = CapturingScript {
8326 turns: vec![tool_turn(), turn("two", json!([]))],
8327 cursor: AtomicUsize::new(0),
8328 seen: Arc::clone(&seen),
8329 };
8330 let mut messages = vec![sys("sys"), usr("add 1 and 1")];
8331 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8332 assert_eq!(outcome.status, "success");
8333 let reqs = seen.lock().unwrap();
8334 assert_eq!(reqs.len(), 2);
8335 assert!(reqs.iter().all(|r| r.response_format.is_none()));
8336 }
8337
8338 #[tokio::test]
8343 async fn invalid_final_answer_triggers_exactly_one_toolless_repair() {
8344 let dir = tempfile::tempdir().unwrap();
8345 let rt = runtime_for(dir.path()).await;
8346 let seen = Arc::new(StdMutex::new(Vec::new()));
8347 let script = CapturingScript {
8348 turns: vec![
8349 turn("Sure! The answer is: sum = 2.", json!([])),
8350 turn(r#"{"sum": 2}"#, json!([])),
8351 ],
8352 cursor: AtomicUsize::new(0),
8353 seen: Arc::clone(&seen),
8354 };
8355 let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8356 let mut events = Vec::new();
8357 let repair_cfg = AssistantConfig {
8358 model: Some("newsroom-editor".into()),
8359 strict_model: true,
8360 ..json_object_cfg()
8361 };
8362 let outcome =
8363 run_assistant_loop(&script, &rt, &repair_cfg, &mut messages, |e| events.push(e)).await;
8364 assert_eq!(outcome.status, "success");
8365 assert_eq!(
8366 outcome.summary, r#"{"sum": 2}"#,
8367 "the repaired text is the answer"
8368 );
8369 assert_eq!(
8370 outcome.turns, 1,
8371 "a repair is a model call, not a loop turn"
8372 );
8373
8374 let reqs = seen.lock().unwrap();
8375 assert_eq!(reqs.len(), 2, "draft + exactly one repair");
8376 for (index, request) in reqs.iter().enumerate() {
8377 assert_eq!(
8378 request.model.as_deref(),
8379 Some("newsroom-editor"),
8380 "request {index} must retain the configured editor model"
8381 );
8382 assert!(
8383 request.params.strict_model,
8384 "request {index} must retain strict model selection"
8385 );
8386 assert_eq!(
8387 request.expected_row_digest, None,
8388 "assistant config exposes no immutable row precondition"
8389 );
8390 assert_eq!(
8391 request.expected_catalog_revision, None,
8392 "assistant config exposes no catalog revision precondition"
8393 );
8394 }
8395 let repair = &reqs[1];
8396 assert!(
8397 repair.tools.is_none(),
8398 "the repair turn advertises no tools"
8399 );
8400 assert_eq!(
8401 repair.response_format,
8402 Some(car_inference::ResponseFormat::JsonObject)
8403 );
8404 let history = repair.messages.as_ref().unwrap();
8405 assert!(
8406 matches!(history.last(), Some(Message::User { content }) if content == FORMAT_REPAIR_NUDGE),
8407 "the nudge is the last message the repair sees"
8408 );
8409 assert!(
8410 matches!(&history[history.len() - 2], Message::Assistant { content, .. } if content.contains("sum = 2")),
8411 "the draft is in the transcript so the model can see what it got wrong"
8412 );
8413
8414 let (fired, still_invalid) = repair_notices(&events);
8415 assert_eq!(fired, 1, "the repair must be visible in the event stream");
8416 assert_eq!(still_invalid, 0);
8417 assert!(
8418 matches!(events.last(), Some(AssistantEvent::Done { text }) if text == r#"{"sum": 2}"#)
8419 );
8420 assert!(
8422 matches!(messages.last(), Some(Message::Assistant { content, .. }) if content == r#"{"sum": 2}"#)
8423 );
8424 assert!(
8425 matches!(&messages[messages.len() - 2], Message::User { content } if content == FORMAT_REPAIR_NUDGE)
8426 );
8427 }
8428
8429 #[tokio::test]
8431 async fn valid_final_answer_triggers_no_repair() {
8432 let dir = tempfile::tempdir().unwrap();
8433 let rt = runtime_for(dir.path()).await;
8434 let seen = Arc::new(StdMutex::new(Vec::new()));
8435 let script = CapturingScript {
8436 turns: vec![turn("```json\n{\"sum\": 2}\n```", json!([]))],
8439 cursor: AtomicUsize::new(0),
8440 seen: Arc::clone(&seen),
8441 };
8442 let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8443 let mut events = Vec::new();
8444 let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
8445 events.push(e)
8446 })
8447 .await;
8448 assert_eq!(outcome.status, "success");
8449 assert_eq!(seen.lock().unwrap().len(), 1);
8450 assert_eq!(repair_notices(&events), (0, 0));
8451 }
8452
8453 #[tokio::test]
8456 async fn a_repair_that_still_misses_is_reported_not_retried() {
8457 let dir = tempfile::tempdir().unwrap();
8458 let rt = runtime_for(dir.path()).await;
8459 let seen = Arc::new(StdMutex::new(Vec::new()));
8460 let script = CapturingScript {
8461 turns: vec![
8462 turn("not json", json!([])),
8463 turn("still not json", json!([])),
8464 turn(r#"{"never": "reached"}"#, json!([])),
8465 ],
8466 cursor: AtomicUsize::new(0),
8467 seen: Arc::clone(&seen),
8468 };
8469 let mut messages = vec![sys("sys"), usr("answer as JSON")];
8470 let mut events = Vec::new();
8471 let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
8472 events.push(e)
8473 })
8474 .await;
8475 assert_eq!(outcome.status, "success");
8476 assert_eq!(outcome.summary, "still not json");
8477 assert_eq!(seen.lock().unwrap().len(), 2, "one repair, never a second");
8478 assert_eq!(repair_notices(&events), (1, 1));
8479 }
8480
8481 #[test]
8485 fn final_text_format_check_semantics() {
8486 use car_inference::ResponseFormat::{JsonObject, JsonSchema};
8487 let schema = JsonSchema {
8488 schema: json!({"type": "array"}),
8489 strict: false,
8490 name: None,
8491 };
8492 assert!(final_text_matches_format(r#"{"a": 1}"#, &JsonObject, None));
8493 assert!(final_text_matches_format(
8494 "```json\n{\"a\": 1}\n```",
8495 &JsonObject,
8496 None
8497 ));
8498 assert!(
8499 !final_text_matches_format("[1, 2]", &JsonObject, None),
8500 "an array is not an object"
8501 );
8502 assert!(!final_text_matches_format(
8503 "Here: {\"a\": 1}",
8504 &JsonObject,
8505 None
8506 ));
8507 assert!(
8508 final_text_matches_format("[1, 2]", &schema, None),
8509 "schema mode is parse-only"
8510 );
8511 assert!(!final_text_matches_format("nope", &schema, None));
8512 let requires_legs: ResponseFormatValidator = Arc::new(|v| v.get("legs").is_some());
8513 assert!(
8514 final_text_matches_format(r#"{"legs": []}"#, &schema, Some(&requires_legs)),
8515 "conforming JSON passes the caller's schema check"
8516 );
8517 assert!(
8518 !final_text_matches_format(r#"{"nope": 1}"#, &schema, Some(&requires_legs)),
8519 "valid JSON of the wrong shape must fail once a validator exists"
8520 );
8521 assert_eq!(extract_json_payload("```\n[1]\n```"), "[1]");
8522 assert_eq!(extract_json_payload(" [1] "), "[1]");
8523 assert_eq!(
8524 extract_json_payload("```json\n{}"),
8525 "```json\n{}",
8526 "an unclosed fence is left alone"
8527 );
8528 }
8529
8530 struct WindowedScript {
8535 inner: CapturingScript,
8536 window: usize,
8537 }
8538
8539 #[async_trait]
8540 impl TurnGenerator for WindowedScript {
8541 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
8542 self.inner.generate(req).await
8543 }
8544 fn context_window(&self, _model: &str) -> usize {
8545 self.window
8546 }
8547 }
8548
8549 fn windowed(window: usize) -> (WindowedScript, Arc<StdMutex<Vec<GenerateRequest>>>) {
8550 let seen = Arc::new(StdMutex::new(Vec::new()));
8551 let script = WindowedScript {
8552 inner: CapturingScript {
8553 turns: vec![turn("done", json!([]))],
8554 cursor: AtomicUsize::new(0),
8555 seen: Arc::clone(&seen),
8556 },
8557 window,
8558 };
8559 (script, seen)
8560 }
8561
8562 fn long_history() -> Vec<Message> {
8565 let big = "x".repeat(20_000);
8566 let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
8567 for i in 0..12 {
8568 m.push(asst_call(&format!("c{i}")));
8569 m.push(tool_res(&format!("c{i}"), &big));
8570 }
8571 m
8572 }
8573
8574 fn has_compaction_notice(messages: &[Message]) -> bool {
8575 messages.iter().any(|m| {
8576 matches!(m, Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX))
8577 })
8578 }
8579
8580 fn window_advisories(events: &[AssistantEvent]) -> usize {
8581 events
8582 .iter()
8583 .filter(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with("[context window:")))
8584 .count()
8585 }
8586
8587 #[tokio::test]
8590 async fn context_window_override_below_the_registry_window_tightens_compaction() {
8591 let dir = tempfile::tempdir().unwrap();
8592 let rt = runtime_for(dir.path()).await;
8593 let (script, seen) = windowed(200_000);
8594 let mut messages = long_history();
8595 let cfg = AssistantConfig {
8596 context_window_override: Some(20_000),
8597 refuse_unadvertised_tools: false,
8598 response_format_validator: None,
8599 delegate_budget: None,
8600 ..cfg()
8601 };
8602 let mut events = Vec::new();
8603 let outcome =
8604 run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
8605 assert_eq!(outcome.status, "success");
8606 assert!(
8607 has_compaction_notice(&messages),
8608 "the 20k override must compact"
8609 );
8610 let reqs = seen.lock().unwrap();
8612 assert!(has_compaction_notice(reqs[0].messages.as_ref().unwrap()));
8613 assert_eq!(window_advisories(&events), 0, "tightening is not clamped");
8614 }
8615
8616 #[tokio::test]
8618 async fn no_context_window_override_leaves_the_registry_window_in_charge() {
8619 let dir = tempfile::tempdir().unwrap();
8620 let rt = runtime_for(dir.path()).await;
8621 let (script, _seen) = windowed(200_000);
8622 let mut messages = long_history();
8623 let mut events = Vec::new();
8624 let outcome =
8625 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
8626 assert_eq!(outcome.status, "success");
8627 assert!(!has_compaction_notice(&messages), "60k fits a 200k window");
8628 assert_eq!(window_advisories(&events), 0);
8629 }
8630
8631 #[tokio::test]
8636 async fn context_window_override_above_the_registry_window_is_clamped_and_announced() {
8637 let dir = tempfile::tempdir().unwrap();
8638 let rt = runtime_for(dir.path()).await;
8639 let (script, _seen) = windowed(200_000);
8640 let mut messages = long_history();
8641 let cfg = AssistantConfig {
8642 context_window_override: Some(400_000),
8643 refuse_unadvertised_tools: false,
8644 response_format_validator: None,
8645 delegate_budget: None,
8646 ..cfg()
8647 };
8648 let mut events = Vec::new();
8649 let outcome =
8650 run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
8651 assert_eq!(outcome.status, "success");
8652 assert!(!has_compaction_notice(&messages));
8653 assert_eq!(window_advisories(&events), 1, "the clamp must be visible");
8654 }
8655
8656 #[test]
8657 fn resolve_context_window_clamps_only_upward_against_a_known_window() {
8658 assert_eq!(resolve_context_window(None, 200_000), (200_000, None));
8659 assert_eq!(resolve_context_window(None, 0), (0, None));
8660 assert_eq!(
8661 resolve_context_window(Some(20_000), 200_000),
8662 (20_000, None)
8663 );
8664 let (w, advisory) = resolve_context_window(Some(400_000), 200_000);
8665 assert_eq!(w, 200_000);
8666 assert!(advisory
8667 .unwrap()
8668 .contains("exceeds the model's known window"));
8669 assert_eq!(resolve_context_window(Some(400_000), 0), (400_000, None));
8672 }
8673
8674 #[test]
8675 fn the_assistant_loops_compaction_notice_text_is_unchanged() {
8676 assert_eq!(
8681 format_compaction_notice(3, 1234, CompactionRecovery::default()),
8682 "[history compacted: 3 earlier turns removed to fit the context window, \
8683 ~1234 tokens. They are gone from this transcript but the run's event log \
8684 still has them — call `events_query` (e.g. {\"kinds\": [\"action_failed\"], \
8685 \"limit\": 5}) to see what was already tried, rather than assuming you never \
8686 tried it.]"
8687 );
8688 assert_eq!(
8689 format_compaction_notice(3, 1234, CompactionRecovery::EventsQuery),
8690 format_compaction_notice(3, 1234, CompactionRecovery::default()),
8691 "EventsQuery is the default; no caller changes behavior by omitting it"
8692 );
8693
8694 let unrecoverable = format_compaction_notice(3, 1234, CompactionRecovery::Unrecoverable);
8696 assert!(unrecoverable.starts_with(COMPACTION_NOTICE_PREFIX));
8697 assert!(!unrecoverable.contains("events_query"));
8698 assert!(!unrecoverable.contains("event log"));
8699 assert_eq!(
8700 parse_compaction_notice(&Message::System {
8701 content: unrecoverable,
8702 }),
8703 Some((3, 1234)),
8704 "both arms must round-trip through parse_compaction_notice"
8705 );
8706 }
8707
8708 #[test]
8709 fn history_budget_is_the_same_number_the_inline_expression_produced() {
8710 for window in [0usize, 1, 3, 5, 4_096, 8_192, 131_072, 200_000, 1_048_576] {
8715 assert_eq!(
8716 history_budget(window),
8717 window / 4 * 3,
8718 "budget changed for window {window}"
8719 );
8720 }
8721 assert_eq!(history_budget(200_000), 150_000);
8722 assert_eq!(history_budget(0), 0);
8723 }
8724
8725 fn modest_history() -> Vec<Message> {
8730 let body = "x".repeat(2_000);
8731 let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
8732 for i in 0..12 {
8733 m.push(asst_call(&format!("c{i}")));
8734 m.push(tool_res(&format!("c{i}"), &body));
8735 }
8736 m
8737 }
8738
8739 #[tokio::test]
8744 async fn reported_prompt_tokens_far_above_the_estimate_trigger_compaction_next_turn() {
8745 let dir = tempfile::tempdir().unwrap();
8746 let rt = runtime_for(dir.path()).await;
8747 let seen = Arc::new(StdMutex::new(Vec::new()));
8748 let script = WindowedScript {
8749 inner: CapturingScript {
8750 turns: vec![
8751 turn_with_usage(
8752 "computing",
8753 json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8754 190_000,
8755 10,
8756 ),
8757 turn("done", json!([])),
8758 ],
8759 cursor: AtomicUsize::new(0),
8760 seen: Arc::clone(&seen),
8761 },
8762 window: 200_000,
8763 };
8764 let mut messages = modest_history();
8765 let estimate = messages.iter().map(approx_message_tokens).sum::<usize>();
8766 assert!(
8767 estimate < 20_000,
8768 "fixture estimate must sit far under the 150k budget: {estimate}"
8769 );
8770
8771 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8772 assert_eq!(outcome.status, "success");
8773
8774 let reqs = seen.lock().unwrap();
8775 assert_eq!(reqs.len(), 2);
8776 assert!(
8777 !has_compaction_notice(reqs[0].messages.as_ref().unwrap()),
8778 "turn 1 has no report yet and the estimate fits"
8779 );
8780 assert!(
8781 has_compaction_notice(reqs[1].messages.as_ref().unwrap()),
8782 "turn 2 must compact on the 190k the provider reported for turn 1"
8783 );
8784 assert!(has_compaction_notice(&messages));
8785 }
8786
8787 #[tokio::test]
8789 async fn no_usage_report_falls_back_to_the_estimate() {
8790 let dir = tempfile::tempdir().unwrap();
8791 let rt = runtime_for(dir.path()).await;
8792 let seen = Arc::new(StdMutex::new(Vec::new()));
8793 let script = WindowedScript {
8794 inner: CapturingScript {
8795 turns: vec![
8796 turn(
8797 "computing",
8798 json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8799 ),
8800 turn("done", json!([])),
8801 ],
8802 cursor: AtomicUsize::new(0),
8803 seen: Arc::clone(&seen),
8804 },
8805 window: 200_000,
8806 };
8807 let mut messages = modest_history();
8808 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8809 assert_eq!(outcome.status, "success");
8810 assert_eq!(seen.lock().unwrap().len(), 2);
8811 assert!(!has_compaction_notice(&messages));
8812 }
8813
8814 #[test]
8818 fn measured_compaction_scales_the_drop_to_the_reported_size_and_round_trips() {
8819 let mut m = modest_history();
8820 let len = m.len();
8821 let estimate: usize = m.iter().map(approx_message_tokens).sum();
8822 let measure = PromptMeasure {
8824 fixed_overhead: 0,
8825 reported: Some((estimate * 40, len)),
8826 };
8827 compact_history_measured(&mut m, 200_000, measure);
8828 let notice = m
8829 .iter()
8830 .find(|msg| parse_compaction_notice(msg).is_some())
8831 .expect("must compact on the reported size");
8832 let (turns, tokens) = parse_compaction_notice(notice).unwrap();
8833 assert!(turns > 0);
8834 assert!(
8835 tokens > estimate,
8836 "dropped tokens are accounted in the scaled (provider) measure: {tokens} vs raw estimate {estimate}"
8837 );
8838 let remaining: usize = m.iter().map(approx_message_tokens).sum();
8841 assert!(remaining < estimate);
8842 }
8843
8844 #[test]
8847 fn fixed_overhead_counts_toward_the_budget() {
8848 let mut m = modest_history();
8849 let estimate: usize = m.iter().map(approx_message_tokens).sum();
8850 let window = estimate * 4 / 3 + 40;
8852 compact_history_measured(&mut m, window, PromptMeasure::default());
8853 assert!(!has_compaction_notice(&m), "history alone fits");
8854 compact_history_measured(
8855 &mut m,
8856 window,
8857 PromptMeasure {
8858 fixed_overhead: 5_000,
8859 reported: None,
8860 },
8861 );
8862 assert!(has_compaction_notice(&m), "history + tool defs does not");
8863 }
8864
8865 #[test]
8868 fn reported_count_only_rescales_beyond_a_quarter_off() {
8869 let m = modest_history();
8870 let len = m.len();
8871 let estimate: usize = m.iter().map(approx_message_tokens).sum();
8872 let mut close = m.clone();
8874 compact_history_measured(
8875 &mut close,
8876 estimate * 4 / 3,
8877 PromptMeasure {
8878 fixed_overhead: 0,
8879 reported: Some((estimate * 11 / 10, len)),
8880 },
8881 );
8882 let (_, close_tokens) = close
8883 .iter()
8884 .find_map(parse_compaction_notice)
8885 .expect("110% of a budget-sized estimate must compact");
8886 let mut far = m.clone();
8888 compact_history_measured(
8889 &mut far,
8890 estimate * 4 / 3,
8891 PromptMeasure {
8892 fixed_overhead: 0,
8893 reported: Some((estimate * 20, len)),
8894 },
8895 );
8896 let (_, far_tokens) = far.iter().find_map(parse_compaction_notice).unwrap();
8897 assert!(
8898 far_tokens > close_tokens * 5,
8899 "{far_tokens} vs {close_tokens}"
8900 );
8901 }
8902
8903 fn delegate_cfg() -> AssistantConfig {
8907 let mut tools = GeneralExecutor::tool_defs();
8908 tools.push(delegate_tool_def(&tools));
8909 AssistantConfig { tools, ..cfg() }
8910 }
8911
8912 fn delegate_call(params: Value) -> InferenceResult {
8913 turn(
8914 "delegating",
8915 json!([{ "id": "d1", "name": DELEGATE_TOOL, "arguments": params }]),
8916 )
8917 }
8918
8919 fn calc_call() -> InferenceResult {
8920 turn(
8921 "computing",
8922 json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8923 )
8924 }
8925
8926 fn tool_names(req: &GenerateRequest) -> Vec<String> {
8927 req.tools
8928 .as_deref()
8929 .unwrap_or_default()
8930 .iter()
8931 .filter_map(|d| d.get("name").and_then(Value::as_str))
8932 .map(str::to_string)
8933 .collect()
8934 }
8935
8936 fn delegate_result(messages: &[Message]) -> (String, bool) {
8938 messages
8939 .iter()
8940 .find_map(|m| match m {
8941 Message::ToolResult {
8942 tool_use_id,
8943 content,
8944 provenance,
8945 } if tool_use_id == "d1" => {
8946 Some((content.clone(), *provenance == Provenance::External))
8947 }
8948 _ => None,
8949 })
8950 .expect("the delegate call must have a tool result")
8951 }
8952
8953 #[test]
8954 fn delegate_tool_def_enumerates_parent_tools_and_excludes_itself() {
8955 let mut tools = GeneralExecutor::tool_defs();
8956 let def = delegate_tool_def(&tools);
8957 assert_eq!(def["name"], DELEGATE_TOOL);
8958 assert_eq!(def["tier"], "read_only");
8959 assert_eq!(def["mutating"], true, "a finished delegation is progress");
8960 assert_eq!(def["parameters"]["required"], json!(["goal"]));
8961 let en = def["parameters"]["properties"]["tools"]["items"]["enum"]
8962 .as_array()
8963 .unwrap()
8964 .clone();
8965 assert!(en.iter().any(|v| v == "calculate"));
8966 assert!(!en.iter().any(|v| v == DELEGATE_TOOL));
8967 tools.push(def);
8969 let again = delegate_tool_def(&tools);
8970 assert!(!again["parameters"]["properties"]["tools"]["items"]["enum"]
8971 .as_array()
8972 .unwrap()
8973 .iter()
8974 .any(|v| v == DELEGATE_TOOL));
8975 assert!(mutating_tool_names(&tools).contains(DELEGATE_TOOL));
8976 }
8977
8978 #[test]
8979 fn delegate_params_parse_with_defaults_and_cap() {
8980 let r = parse_delegate_params(&json!({"goal": " count "})).unwrap();
8981 assert_eq!(r.goal, "count");
8982 assert_eq!(r.tools, None);
8983 assert_eq!(r.max_turns, DELEGATE_DEFAULT_MAX_TURNS);
8984 let r =
8985 parse_delegate_params(&json!({"goal": "x", "tools": ["calculate"], "max_turns": 500}))
8986 .unwrap();
8987 assert_eq!(r.tools.as_deref(), Some(&["calculate".to_string()][..]));
8988 assert_eq!(r.max_turns, DELEGATE_MAX_TURNS_CAP);
8989 assert!(parse_delegate_params(&json!({"goal": ""})).is_err());
8990 assert!(parse_delegate_params(&json!({"goal": "x", "max_turns": 0})).is_err());
8991 assert!(parse_delegate_params(&json!({"goal": "x", "tools": "calculate"})).is_err());
8992 }
8993
8994 #[test]
8996 fn delegate_child_config_derives_from_the_parent() {
8997 let mut parent = delegate_cfg();
8998 parent.gated_tools = vec!["shell".into()];
8999 parent.context_window_override = Some(20_000);
9000 parent.response_format = Some(car_inference::ResponseFormat::JsonObject);
9001 parent.todos = Some(Arc::new(tokio::sync::Mutex::new(
9002 super::super::todo::TodoList::new(),
9003 )));
9004 let req = parse_delegate_params(&json!({"goal": "g", "tools": ["calculate"]})).unwrap();
9005 let child = delegate_child_config(&parent, &req).unwrap();
9006 assert_eq!(
9007 child
9008 .tools
9009 .iter()
9010 .map(|d| d["name"].as_str().unwrap())
9011 .collect::<Vec<_>>(),
9012 vec!["calculate"]
9013 );
9014 assert!(child.refuse_unadvertised_tools);
9015 assert_eq!(child.max_turns, DELEGATE_DEFAULT_MAX_TURNS);
9016 assert!(child.todos.is_none());
9017 assert!(child.response_format.is_none(), "children answer in prose");
9018 assert_eq!(
9019 child.gated_tools, parent.gated_tools,
9020 "gates inherited whole"
9021 );
9022 assert_eq!(child.context_window_override, Some(20_000));
9023 assert_eq!(child.model, parent.model);
9024 let all = delegate_child_config(
9026 &parent,
9027 &parse_delegate_params(&json!({"goal": "g"})).unwrap(),
9028 )
9029 .unwrap();
9030 let names: Vec<&str> = all
9031 .tools
9032 .iter()
9033 .map(|d| d["name"].as_str().unwrap())
9034 .collect();
9035 assert!(names.contains(&"calculate"));
9036 assert!(!names.contains(&DELEGATE_TOOL));
9037 assert_eq!(names.len(), parent.tools.len() - 1);
9038 }
9039
9040 #[tokio::test]
9044 async fn delegate_child_runs_with_a_fresh_history_and_returns_only_its_final_text() {
9045 let dir = tempfile::tempdir().unwrap();
9046 let rt = runtime_for(dir.path()).await;
9047 let seen = Arc::new(StdMutex::new(Vec::new()));
9048 let script = CapturingScript {
9049 turns: vec![
9050 delegate_call(json!({"goal": "what is 1+1? reply with the number only"})),
9051 calc_call(), turn("2", json!([])), turn("The answer is 2.", json!([])), ],
9055 cursor: AtomicUsize::new(0),
9056 seen: Arc::clone(&seen),
9057 };
9058 let mut messages = vec![
9059 sys("PARENT SYSTEM PROMPT"),
9060 usr("PARENT TASK: add one and one"),
9061 ];
9062 let mut events = Vec::new();
9063 let outcome = run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9064 events.push(e)
9065 })
9066 .await;
9067 assert_eq!(outcome.status, "success");
9068 assert_eq!(outcome.summary, "The answer is 2.");
9069 assert_eq!(outcome.turns, 2, "child turns are not the parent's");
9070
9071 let reqs = seen.lock().unwrap();
9072 assert_eq!(reqs.len(), 4);
9073 let child_first = reqs[1].messages.as_ref().unwrap();
9074 assert_eq!(
9075 child_first.len(),
9076 2,
9077 "exactly system + goal: {child_first:?}"
9078 );
9079 assert!(
9080 matches!(&child_first[0], Message::System { content } if content == "PARENT SYSTEM PROMPT")
9081 );
9082 assert!(
9083 matches!(&child_first[1], Message::User { content } if content.starts_with("what is 1+1?"))
9084 );
9085 assert!(
9086 !serde_json::to_string(child_first)
9087 .unwrap()
9088 .contains("PARENT TASK"),
9089 "nothing from the parent's transcript reaches the child"
9090 );
9091 let child_tools = tool_names(&reqs[1]);
9092 assert!(child_tools.contains(&"calculate".to_string()));
9093 assert!(
9094 !child_tools.contains(&DELEGATE_TOOL.to_string()),
9095 "no nesting"
9096 );
9097 assert!(tool_names(&reqs[0]).contains(&DELEGATE_TOOL.to_string()));
9098 let parent_second = reqs[3].messages.as_ref().unwrap();
9101 assert!(!serde_json::to_string(parent_second)
9102 .unwrap()
9103 .contains("computing"));
9104 drop(reqs);
9105
9106 let (content, external) = delegate_result(&messages);
9107 assert_eq!(content, "2", "only the child's final text comes back");
9108 assert!(!external, "calculate is internal");
9109
9110 assert!(events
9112 .iter()
9113 .any(|e| matches!(e, AssistantEvent::ToolCall { name, .. } if name == DELEGATE_TOOL)));
9114 assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: true, content, .. } if name == DELEGATE_TOOL && content == "2")));
9115 assert!(events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with("[delegate: what is 1+1?") && t.ends_with("2 turns, ok]"))));
9116 assert!(
9117 !events
9118 .iter()
9119 .any(|e| matches!(e, AssistantEvent::ToolCall { name, .. } if name == "calculate")),
9120 "the child's own tool calls are not forwarded"
9121 );
9122 let receipt = outcome
9123 .tool_receipts
9124 .iter()
9125 .find(|r| r.tool == DELEGATE_TOOL)
9126 .unwrap();
9127 assert!(receipt.ok);
9128 assert_eq!(receipt.call_id.as_deref(), Some("turn_1_call_1"));
9129 assert_eq!(receipt.sequence, Some(1));
9130 assert_eq!(outcome.tools_called, vec![DELEGATE_TOOL.to_string()]);
9131 }
9132
9133 #[tokio::test]
9136 async fn delegate_child_tool_subset_is_enforced_at_execution() {
9137 let dir = tempfile::tempdir().unwrap();
9138 let rt = runtime_for(dir.path()).await;
9139 let seen = Arc::new(StdMutex::new(Vec::new()));
9140 let script = CapturingScript {
9141 turns: vec![
9142 delegate_call(json!({"goal": "write hi.txt", "tools": ["calculate"]})),
9143 turn(
9144 "writing",
9145 json!([{ "id": "w", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
9146 ),
9147 turn("could not write", json!([])),
9148 turn("done", json!([])),
9149 ],
9150 cursor: AtomicUsize::new(0),
9151 seen: Arc::clone(&seen),
9152 };
9153 let mut messages = vec![sys("sys"), usr("task")];
9154 let outcome =
9155 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9156 assert_eq!(outcome.status, "success");
9157 assert!(
9158 !dir.path().join("hi.txt").exists(),
9159 "the ungranted write must not run"
9160 );
9161 let reqs = seen.lock().unwrap();
9162 assert_eq!(tool_names(&reqs[1]), vec!["calculate".to_string()]);
9163 let child_second = reqs[2].messages.as_ref().unwrap();
9164 let refusal = child_second
9165 .iter()
9166 .find_map(|m| match m {
9167 Message::ToolResult {
9168 tool_use_id,
9169 content,
9170 ..
9171 } if tool_use_id == "w" => Some(content.clone()),
9172 _ => None,
9173 })
9174 .unwrap();
9175 assert!(
9176 refusal.contains("not granted to this delegate"),
9177 "{refusal}"
9178 );
9179 assert!(
9180 refusal.contains("calculate"),
9181 "says what IS allowed: {refusal}"
9182 );
9183 }
9184
9185 #[tokio::test]
9188 async fn delegate_cannot_nest() {
9189 let dir = tempfile::tempdir().unwrap();
9190 let rt = runtime_for(dir.path()).await;
9191 let seen = Arc::new(StdMutex::new(Vec::new()));
9192 let script = CapturingScript {
9193 turns: vec![
9194 delegate_call(json!({"goal": "go deeper"})),
9195 turn(
9196 "nesting",
9197 json!([{ "id": "n", "name": DELEGATE_TOOL, "arguments": { "goal": "deeper still" } }]),
9198 ),
9199 turn("could not nest", json!([])),
9200 turn("done", json!([])),
9201 ],
9202 cursor: AtomicUsize::new(0),
9203 seen: Arc::clone(&seen),
9204 };
9205 let mut messages = vec![sys("sys"), usr("task")];
9206 let outcome =
9207 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9208 assert_eq!(outcome.status, "success");
9209 {
9210 let reqs = seen.lock().unwrap();
9211 assert_eq!(reqs.len(), 4, "the nested call must not spawn a grandchild");
9212 let child_second = reqs[2].messages.as_ref().unwrap();
9213 let refusal = serde_json::to_string(child_second).unwrap();
9214 assert!(
9215 refusal.contains("not granted to this delegate"),
9216 "{refusal}"
9217 );
9218 }
9219
9220 let seen = Arc::new(StdMutex::new(Vec::new()));
9222 let script = CapturingScript {
9223 turns: vec![
9224 delegate_call(json!({"goal": "go deeper", "tools": [DELEGATE_TOOL]})),
9225 turn("done", json!([])),
9226 ],
9227 cursor: AtomicUsize::new(0),
9228 seen: Arc::clone(&seen),
9229 };
9230 let mut messages = vec![sys("sys"), usr("task")];
9231 let mut events = Vec::new();
9232 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9233 events.push(e)
9234 })
9235 .await;
9236 assert_eq!(seen.lock().unwrap().len(), 2, "no child ran");
9237 let (content, _) = delegate_result(&messages);
9238 assert!(
9239 content.contains("privilege escalation rejected"),
9240 "{content}"
9241 );
9242 assert!(content.contains("cannot delegate further"), "{content}");
9243 assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: false, .. } if name == DELEGATE_TOOL)));
9244 }
9245
9246 #[tokio::test]
9248 async fn delegate_escalation_is_refused_without_spawning() {
9249 let dir = tempfile::tempdir().unwrap();
9250 let rt = runtime_for(dir.path()).await;
9251 let seen = Arc::new(StdMutex::new(Vec::new()));
9252 let script = CapturingScript {
9253 turns: vec![
9254 delegate_call(json!({"goal": "x", "tools": ["calculate", "launch_missiles"]})),
9255 turn("done", json!([])),
9256 ],
9257 cursor: AtomicUsize::new(0),
9258 seen: Arc::clone(&seen),
9259 };
9260 let mut messages = vec![sys("sys"), usr("task")];
9261 let outcome =
9262 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9263 assert_eq!(outcome.status, "success");
9264 assert_eq!(seen.lock().unwrap().len(), 2);
9265 let (content, _) = delegate_result(&messages);
9266 assert!(content.contains("launch_missiles"), "{content}");
9267 let receipt = outcome
9268 .tool_receipts
9269 .iter()
9270 .find(|r| r.tool == DELEGATE_TOOL)
9271 .unwrap();
9272 assert!(!receipt.ok);
9273 assert!(
9274 outcome.tools_called.is_empty(),
9275 "a refused delegation is not progress"
9276 );
9277 }
9278
9279 #[tokio::test]
9283 async fn delegate_turn_cap_is_an_error_result() {
9284 let dir = tempfile::tempdir().unwrap();
9285 let rt = runtime_for(dir.path()).await;
9286 let seen = Arc::new(StdMutex::new(Vec::new()));
9287 let script = CapturingScript {
9288 turns: vec![
9289 delegate_call(json!({"goal": "keep computing", "max_turns": 1})),
9290 calc_call(), turn("done", json!([])),
9292 ],
9293 cursor: AtomicUsize::new(0),
9294 seen: Arc::clone(&seen),
9295 };
9296 let mut messages = vec![sys("sys"), usr("task")];
9297 let mut events = Vec::new();
9298 let outcome = run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9299 events.push(e)
9300 })
9301 .await;
9302 assert_eq!(outcome.status, "success");
9303 assert_eq!(seen.lock().unwrap().len(), 3);
9304 let (content, _) = delegate_result(&messages);
9305 assert!(content.contains("did not finish"), "{content}");
9306 assert!(content.contains("status: max_turns"), "{content}");
9307 assert!(events
9308 .iter()
9309 .any(|e| matches!(e, AssistantEvent::Text(t) if t.ends_with("1 turns, error]"))));
9310 assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: false, .. } if name == DELEGATE_TOOL)));
9311 assert!(outcome.tools_called.is_empty());
9312 }
9313
9314 #[tokio::test]
9316 async fn delegate_summary_is_capped() {
9317 let dir = tempfile::tempdir().unwrap();
9318 let rt = runtime_for(dir.path()).await;
9319 let seen = Arc::new(StdMutex::new(Vec::new()));
9320 let long = "y".repeat(OBSERVATION_CAP * 3);
9321 let script = CapturingScript {
9322 turns: vec![
9323 delegate_call(json!({"goal": "dump"})),
9324 turn(&long, json!([])),
9325 turn("done", json!([])),
9326 ],
9327 cursor: AtomicUsize::new(0),
9328 seen: Arc::clone(&seen),
9329 };
9330 let mut messages = vec![sys("sys"), usr("task")];
9331 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9332 let (content, _) = delegate_result(&messages);
9333 assert!(content.len() < long.len());
9334 assert!(
9335 content.contains("bytes elided"),
9336 "the cap must say what it dropped"
9337 );
9338 }
9339
9340 #[tokio::test]
9344 async fn delegate_child_inherits_the_parents_approval_gate() {
9345 let dir = tempfile::tempdir().unwrap();
9346 let rt = runtime_for(dir.path()).await;
9347 let seen = Arc::new(StdMutex::new(Vec::new()));
9348 let script = CapturingScript {
9349 turns: vec![
9350 delegate_call(
9351 json!({"goal": "write hi.txt", "tools": ["write_file", "calculate"]}),
9352 ),
9353 turn(
9354 "writing",
9355 json!([{ "id": "w", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
9356 ),
9357 turn("denied", json!([])),
9358 turn("done", json!([])),
9359 ],
9360 cursor: AtomicUsize::new(0),
9361 seen: Arc::clone(&seen),
9362 };
9363 let mut cfg = delegate_cfg();
9364 cfg.gated_tools = vec!["write_file".into(), "edit_file".into(), "shell".into()];
9365 let mut messages = vec![sys("sys"), usr("task")];
9366 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9367 assert_eq!(outcome.status, "success");
9368 assert!(!dir.path().join("hi.txt").exists());
9369 let reqs = seen.lock().unwrap();
9370 assert!(tool_names(&reqs[1]).contains(&"write_file".to_string()));
9372 let child_second = serde_json::to_string(reqs[2].messages.as_ref().unwrap()).unwrap();
9373 assert!(child_second.contains("needs approval"), "{child_second}");
9374 assert!(!child_second.contains("not granted"), "{child_second}");
9375 }
9376
9377 #[tokio::test]
9380 async fn delegate_is_not_intercepted_unless_advertised() {
9381 let dir = tempfile::tempdir().unwrap();
9382 let rt = runtime_for(dir.path()).await;
9383 let seen = Arc::new(StdMutex::new(Vec::new()));
9384 let script = CapturingScript {
9385 turns: vec![delegate_call(json!({"goal": "x"})), turn("done", json!([]))],
9386 cursor: AtomicUsize::new(0),
9387 seen: Arc::clone(&seen),
9388 };
9389 let mut messages = vec![sys("sys"), usr("task")];
9390 run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
9391 assert_eq!(seen.lock().unwrap().len(), 2, "no child ran");
9392 let (content, _) = delegate_result(&messages);
9393 assert!(!content.is_empty());
9394 assert!(
9395 !content.contains("did not finish"),
9396 "not a delegation at all: {content}"
9397 );
9398 }
9399
9400 #[tokio::test]
9406 async fn delegate_child_receipts_ground_the_parents_claims() {
9407 let dir = tempfile::tempdir().unwrap();
9408 let rt = runtime_for(dir.path()).await;
9409 let seen = Arc::new(StdMutex::new(Vec::new()));
9410 let script = CapturingScript {
9411 turns: vec![
9412 delegate_call(json!({"goal": "run the test suite and report", "tools": ["shell"]})),
9413 turn(
9414 "running",
9415 json!([{ "id": "s", "name": "shell", "arguments": { "command": "echo cargo test ok" } }]),
9416 ),
9417 turn("The suite ran.", json!([])),
9418 turn("I ran the tests and they passed.", json!([])),
9419 ],
9420 cursor: AtomicUsize::new(0),
9421 seen: Arc::clone(&seen),
9422 };
9423 let mut messages = vec![sys("sys"), usr("run the tests")];
9424 let outcome =
9425 run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9426 assert_eq!(outcome.status, "success", "{}", outcome.summary);
9429 assert_eq!(outcome.summary, "I ran the tests and they passed.");
9430 let child_shell = outcome
9431 .tool_receipts
9432 .iter()
9433 .find(|r| r.tool == "shell")
9434 .expect("the child's shell receipt must be in the parent's list");
9435 assert!(child_shell.ok);
9436 let del = outcome
9438 .tool_receipts
9439 .iter()
9440 .find(|r| r.tool == DELEGATE_TOOL)
9441 .unwrap();
9442 assert!(del.via.is_none());
9443 assert_eq!(
9448 child_shell.via.as_deref(),
9449 Some(format!("delegate:{}", del.call_id.as_deref().unwrap()).as_str())
9450 );
9451 assert!(
9452 ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts).is_empty(),
9453 "the merged shell receipt grounds the tests-passed claim"
9454 );
9455 assert_eq!(
9460 child_shell.call_id.as_deref(),
9461 Some(format!("{}/turn_1_call_1", del.call_id.as_deref().unwrap()).as_str())
9462 );
9463 let mut ids: Vec<&str> = outcome
9464 .tool_receipts
9465 .iter()
9466 .filter_map(|r| r.call_id.as_deref())
9467 .collect();
9468 let total = ids.len();
9469 assert!(
9470 total >= 2,
9471 "the parent's delegate call and the child's shell"
9472 );
9473 ids.sort_unstable();
9474 ids.dedup();
9475 assert_eq!(ids.len(), total, "no two receipts may share a call id");
9476 }
9477
9478 #[tokio::test]
9481 async fn delegate_budget_caps_delegations() {
9482 let dir = tempfile::tempdir().unwrap();
9483 let rt = runtime_for(dir.path()).await;
9484 let seen = Arc::new(StdMutex::new(Vec::new()));
9485 let script = CapturingScript {
9486 turns: vec![
9487 delegate_call(json!({"goal": "first"})),
9488 turn("one", json!([])), delegate_call(json!({"goal": "second"})),
9490 turn("done", json!([])),
9492 ],
9493 cursor: AtomicUsize::new(0),
9494 seen: Arc::clone(&seen),
9495 };
9496 let mut cfg = delegate_cfg();
9497 cfg.delegate_budget = Some(DelegateBudget {
9498 max_delegations: 1,
9499 max_child_turns: 300,
9500 });
9501 let mut messages = vec![sys("sys"), usr("task")];
9502 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9503 assert_eq!(outcome.status, "success");
9504 assert_eq!(seen.lock().unwrap().len(), 4);
9505 let refusals: Vec<&AssistantToolReceipt> = outcome
9506 .tool_receipts
9507 .iter()
9508 .filter(|r| r.tool == DELEGATE_TOOL && !r.ok)
9509 .collect();
9510 assert_eq!(refusals.len(), 1);
9511 let refusal_text = messages
9512 .iter()
9513 .find_map(|m| match m {
9514 Message::ToolResult { content, .. } if content.contains("budget exhausted") => {
9515 Some(content.clone())
9516 }
9517 _ => None,
9518 })
9519 .expect("the refusal must reach the model");
9520 assert!(refusal_text.contains("1 delegations"), "{refusal_text}");
9521 }
9522
9523 #[tokio::test]
9525 async fn delegate_budget_caps_cumulative_child_turns() {
9526 let dir = tempfile::tempdir().unwrap();
9527 let rt = runtime_for(dir.path()).await;
9528 let seen = Arc::new(StdMutex::new(Vec::new()));
9529 let script = CapturingScript {
9530 turns: vec![
9531 delegate_call(json!({"goal": "first"})),
9532 calc_call(), turn("one", json!([])), delegate_call(json!({"goal": "second"})),
9535 turn("done", json!([])),
9536 ],
9537 cursor: AtomicUsize::new(0),
9538 seen: Arc::clone(&seen),
9539 };
9540 let mut cfg = delegate_cfg();
9541 cfg.delegate_budget = Some(DelegateBudget {
9542 max_delegations: 20,
9543 max_child_turns: 2,
9544 });
9545 let mut messages = vec![sys("sys"), usr("task")];
9546 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9547 assert_eq!(outcome.status, "success");
9548 assert_eq!(seen.lock().unwrap().len(), 5, "no second child spawned");
9549 assert!(
9550 messages.iter().any(|m| matches!(m, Message::ToolResult { content, .. } if content.contains("2 child turns"))),
9551 "the refusal names the spent turn budget"
9552 );
9553 }
9554
9555 #[tokio::test]
9559 async fn json_schema_shape_mismatch_triggers_the_repair() {
9560 let dir = tempfile::tempdir().unwrap();
9561 let rt = runtime_for(dir.path()).await;
9562 let seen = Arc::new(StdMutex::new(Vec::new()));
9563 let script = CapturingScript {
9564 turns: vec![
9565 turn(r#"{"nope": 1}"#, json!([])),
9566 turn(r#"{"legs": []}"#, json!([])),
9567 ],
9568 cursor: AtomicUsize::new(0),
9569 seen: Arc::clone(&seen),
9570 };
9571 let mut cfg = cfg();
9572 cfg.response_format = Some(car_inference::ResponseFormat::JsonSchema {
9573 schema: json!({"type": "object", "required": ["legs"]}),
9574 strict: false,
9575 name: None,
9576 });
9577 cfg.response_format_validator = Some(Arc::new(|v| v.get("legs").is_some()));
9578 let mut messages = vec![sys("sys"), usr("plan the flight, answer as JSON")];
9579 let mut events = Vec::new();
9580 let outcome =
9581 run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
9582 assert_eq!(outcome.status, "success");
9583 assert_eq!(outcome.summary, r#"{"legs": []}"#);
9584 let reqs = seen.lock().unwrap();
9585 assert_eq!(reqs.len(), 2, "valid-but-wrong-shape JSON must be repaired");
9586 assert!(reqs[1].tools.is_none());
9587 let nudge = reqs[1]
9588 .messages
9589 .as_ref()
9590 .unwrap()
9591 .last()
9592 .and_then(|m| match m {
9593 Message::User { content } => Some(content.clone()),
9594 _ => None,
9595 })
9596 .unwrap();
9597 assert!(
9598 nudge.contains("JSON Schema"),
9599 "schema-worded, not 'object': {nudge}"
9600 );
9601 assert_eq!(repair_notices(&events), (1, 0));
9602 }
9603
9604 #[tokio::test]
9608 async fn a_failed_repair_call_keeps_the_draft_answer() {
9609 let dir = tempfile::tempdir().unwrap();
9610 let rt = runtime_for(dir.path()).await;
9611 let seen = Arc::new(StdMutex::new(Vec::new()));
9612 let script = CapturingScript {
9613 turns: vec![turn("The answer is 2, not JSON.", json!([]))],
9615 cursor: AtomicUsize::new(0),
9616 seen: Arc::clone(&seen),
9617 };
9618 let mut messages = vec![sys("sys"), usr("answer as JSON")];
9619 let mut events = Vec::new();
9620 let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
9621 events.push(e)
9622 })
9623 .await;
9624 assert_eq!(outcome.status, "success", "the draft is still an answer");
9625 assert_eq!(outcome.summary, "The answer is 2, not JSON.");
9626 assert!(
9627 events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with(FORMAT_REPAIR_FAILED_PREFIX))),
9628 "the failure must be visible"
9629 );
9630 assert!(
9631 matches!(messages.last(), Some(Message::Assistant { content, .. }) if content == "The answer is 2, not JSON."),
9632 "the transcript ends on the draft answer, not a dangling nudge: {:?}",
9633 messages.last()
9634 );
9635 }
9636
9637 struct TypedFailure(AssistantGenerateError);
9643
9644 #[async_trait]
9645 impl TurnGenerator for TypedFailure {
9646 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
9647 panic!("the assistant loop must generate through the typed seam")
9648 }
9649
9650 async fn generate_assistant(
9651 &self,
9652 _req: GenerateRequest,
9653 ) -> Result<InferenceResult, AssistantGenerateError> {
9654 Err(self.0.clone())
9655 }
9656 }
9657
9658 struct ThreeReceiptsThenTransient(AtomicUsize);
9659
9660 #[async_trait]
9661 impl TurnGenerator for ThreeReceiptsThenTransient {
9662 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
9663 panic!("the assistant loop must preserve the typed transient seam")
9664 }
9665
9666 async fn generate_assistant(
9667 &self,
9668 _req: GenerateRequest,
9669 ) -> Result<InferenceResult, AssistantGenerateError> {
9670 let turn_index = self.0.fetch_add(1, Ordering::SeqCst);
9671 if turn_index < 3 {
9672 return Ok(turn(
9673 "working",
9674 json!([{
9675 "id": format!("calc-{turn_index}"),
9676 "name": "calculate",
9677 "arguments": {"expression": format!("1+{turn_index}")}
9678 }]),
9679 ));
9680 }
9681 Err(AssistantGenerateError::from(InferenceError::Transient {
9682 status: Some(503),
9683 message: "provider unavailable after retries".into(),
9684 }))
9685 }
9686 }
9687
9688 #[derive(Default)]
9689 struct DiscardJsonEvents;
9690
9691 impl super::super::do_json::EventSink for DiscardJsonEvents {
9692 fn emit(&self, _event: Value) {}
9693 }
9694
9695 #[tokio::test]
9700 async fn transient_failure_preserves_completed_turns_receipts_and_typed_cause() {
9701 let dir = tempfile::tempdir().unwrap();
9702 let rt = runtime_for(dir.path()).await;
9703 let mut messages = vec![sys("sys"), usr("do several steps")];
9704 let outcome = run_assistant_loop(
9705 &ThreeReceiptsThenTransient(AtomicUsize::new(0)),
9706 &rt,
9707 &cfg(),
9708 &mut messages,
9709 |_| {},
9710 )
9711 .await;
9712
9713 assert_eq!(outcome.status, "error");
9714 assert_eq!(outcome.turns, 4, "the failed attempt was consumed");
9715 assert_eq!(outcome.turns_completed, 3);
9716 assert_eq!(outcome.tool_receipts.len(), 3);
9717 assert_eq!(
9718 outcome.failure_cause,
9719 Some(AssistantFailureCause::TransientInference { status: Some(503) })
9720 );
9721
9722 let emitter = super::super::do_json::JsonEmitter::new(
9723 super::super::do_json::SandboxPosture {
9724 sandboxed: false,
9725 image: None,
9726 tier: "ReadOnly".into(),
9727 root: dir.path().display().to_string(),
9728 mount: None,
9729 fallback_notice: None,
9730 },
9731 Arc::new(DiscardJsonEvents),
9732 );
9733 let document = emitter.finish(&outcome, None);
9734 assert_eq!(document["status"], "error");
9735 assert_eq!(document["turns_completed"], 3);
9736 assert_eq!(document["receipts"]["total"], 3);
9737 assert_eq!(document["receipts"]["sample"].as_array().unwrap().len(), 3);
9738 assert_eq!(document["failure"]["cause"], "transient_inference");
9739 assert_eq!(document["failure"]["status"], 503);
9740 }
9741
9742 fn parslee_signed_out() -> AssistantGenerateError {
9746 AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9747 provider: "parslee".into(),
9748 model: "parslee/advisor".into(),
9749 reason: car_inference::CredentialFailure::SignedOut,
9750 detail: "no account is signed in. Run `car auth login`".into(),
9751 })
9752 }
9753
9754 fn parslee_no_workspace() -> AssistantGenerateError {
9755 AssistantGenerateError::from(InferenceError::WorkspaceRequired {
9756 provider: "Parslee".into(),
9757 detail: "finish setting up at https://parslee.ai, then try again".into(),
9758 })
9759 }
9760
9761 fn auth_required_events(events: &[AssistantEvent]) -> Vec<(AuthRequiredReason, String)> {
9762 events
9763 .iter()
9764 .filter_map(|e| match e {
9765 AssistantEvent::AuthRequired { reason, message } => {
9766 Some((*reason, message.clone()))
9767 }
9768 _ => None,
9769 })
9770 .collect()
9771 }
9772
9773 #[tokio::test]
9781 async fn a_signed_out_parslee_turn_ends_with_auth_required_not_an_error() {
9782 let dir = tempfile::tempdir().unwrap();
9783 let rt = runtime_for(dir.path()).await;
9784 let mut messages = vec![sys("sys"), usr("hello")];
9785 let mut events = Vec::new();
9786 let outcome = run_assistant_loop(
9787 &TypedFailure(parslee_signed_out()),
9788 &rt,
9789 &cfg(),
9790 &mut messages,
9791 |e| events.push(e),
9792 )
9793 .await;
9794
9795 assert_eq!(outcome.status, "auth_required");
9796 assert_eq!(outcome.auth_required, Some(AuthRequiredReason::SignedOut));
9797 let emitted = auth_required_events(&events);
9798 assert_eq!(emitted.len(), 1, "exactly one terminal refusal");
9799 assert_eq!(emitted[0].0, AuthRequiredReason::SignedOut);
9800 assert_eq!(emitted[0].1, AUTH_REQUIRED_SIGNED_OUT_MESSAGE);
9801 assert!(
9802 emitted[0].1.contains(
9803 "New to Parslee? Create your account at parslee.ai first, then come back \
9804 and sign in."
9805 ),
9806 "the interim sign-up path must be named verbatim: {}",
9807 emitted[0].1
9808 );
9809 assert_eq!(
9810 outcome.summary, emitted[0].1,
9811 "the outcome summary and the event must say the same thing"
9812 );
9813 assert!(
9814 !events.iter().any(|e| matches!(e, AssistantEvent::Error(_))),
9815 "an auth refusal is not also an error"
9816 );
9817 assert!(
9818 !events
9819 .iter()
9820 .any(|e| matches!(e, AssistantEvent::ModelServed { .. })),
9821 "nothing served this turn"
9822 );
9823 assert!(outcome.models_served.is_empty());
9824 }
9825
9826 #[tokio::test]
9829 async fn a_no_workspace_turn_ends_with_the_no_workspace_reason() {
9830 let dir = tempfile::tempdir().unwrap();
9831 let rt = runtime_for(dir.path()).await;
9832 let mut messages = vec![sys("sys"), usr("hello")];
9833 let mut events = Vec::new();
9834 let outcome = run_assistant_loop(
9835 &TypedFailure(parslee_no_workspace()),
9836 &rt,
9837 &cfg(),
9838 &mut messages,
9839 |e| events.push(e),
9840 )
9841 .await;
9842
9843 assert_eq!(outcome.status, "auth_required");
9844 assert_eq!(outcome.auth_required, Some(AuthRequiredReason::NoWorkspace));
9845 let emitted = auth_required_events(&events);
9846 assert_eq!(emitted.len(), 1);
9847 assert_eq!(emitted[0].1, AUTH_REQUIRED_NO_WORKSPACE_MESSAGE);
9848 assert!(
9849 emitted[0].1.contains("parslee.ai"),
9850 "the remedy is the web step: {}",
9851 emitted[0].1
9852 );
9853 assert!(
9854 !emitted[0]
9855 .1
9856 .to_ascii_lowercase()
9857 .contains("sign in to continue"),
9858 "a signed-in person must not be told to sign in: {}",
9859 emitted[0].1
9860 );
9861 }
9862
9863 #[test]
9865 fn auth_required_reasons_have_stable_wire_spellings() {
9866 assert_eq!(AuthRequiredReason::SignedOut.as_str(), "signed_out");
9867 assert_eq!(AuthRequiredReason::Expired.as_str(), "expired");
9868 assert_eq!(AuthRequiredReason::NoWorkspace.as_str(), "no_workspace");
9869 }
9870
9871 #[test]
9875 fn auth_required_messages_are_the_approved_copy() {
9876 assert_eq!(
9877 AuthRequiredReason::SignedOut.remedy(),
9878 "Parslee Core runs on your Parslee account. Sign in to continue. New to Parslee? \
9879 Create your account at parslee.ai first, then come back and sign in."
9880 );
9881 assert_eq!(
9882 AuthRequiredReason::Expired.remedy(),
9883 "Your Parslee sign-in has expired. Sign in again to continue."
9884 );
9885 assert_eq!(
9886 AuthRequiredReason::NoWorkspace.remedy(),
9887 "Your Parslee account has no workspace yet. Finish setting up at parslee.ai, then \
9888 try again."
9889 );
9890 }
9897
9898 #[test]
9906 fn the_expired_reason_is_reserved_and_maps_from_a_constructed_value() {
9907 let expired = AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9908 provider: "parslee".into(),
9909 model: "parslee/advisor".into(),
9910 reason: car_inference::CredentialFailure::Expired {
9911 expires_at: 1_234_567,
9912 },
9913 detail: "the Parslee token expired".into(),
9914 });
9915 assert_eq!(
9916 auth_required_reason(&expired),
9917 Some(AuthRequiredReason::Expired)
9918 );
9919 }
9920
9921 #[tokio::test]
9927 async fn non_account_failures_keep_todays_error_event() {
9928 let cases: Vec<(&str, AssistantGenerateError)> = vec![
9929 (
9930 "store unreadable",
9931 AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9932 provider: "parslee".into(),
9933 model: "parslee/advisor".into(),
9934 reason: car_inference::CredentialFailure::StoreUnreadable,
9935 detail: "the credential store could not be read".into(),
9936 }),
9937 ),
9938 (
9939 "env var missing",
9940 AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9941 provider: "openai".into(),
9942 model: "openai/gpt-5.6".into(),
9943 reason: car_inference::CredentialFailure::EnvVarMissing {
9944 env_var: "OPENAI_API_KEY".into(),
9945 },
9946 detail: "set OPENAI_API_KEY".into(),
9947 }),
9948 ),
9949 (
9950 "race retryable",
9951 AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9952 provider: "parslee".into(),
9953 model: "parslee/advisor".into(),
9954 reason: car_inference::CredentialFailure::RaceRetryable,
9955 detail: "credential appeared on re-read".into(),
9956 }),
9957 ),
9958 (
9959 "another provider signed out",
9960 AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9961 provider: "anthropic".into(),
9962 model: "anthropic/claude-haiku-4-5:latest".into(),
9963 reason: car_inference::CredentialFailure::SignedOut,
9964 detail: "no account is signed in".into(),
9965 }),
9966 ),
9967 (
9968 "provider account 401",
9969 AssistantGenerateError::from(InferenceError::ProviderAccount {
9970 provider: "parslee".into(),
9971 status: 401,
9972 message: "Authentication required".into(),
9973 }),
9974 ),
9975 (
9976 "another provider's workspace gap",
9977 AssistantGenerateError::from(InferenceError::WorkspaceRequired {
9978 provider: "someoneelse".into(),
9979 detail: "not our account".into(),
9980 }),
9981 ),
9982 (
9983 "untyped parslee 401",
9986 AssistantGenerateError::from(InferenceError::InferenceFailed(
9987 "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required"
9988 .into(),
9989 )),
9990 ),
9991 ];
9992
9993 for (label, error) in cases {
9994 assert_eq!(auth_required_reason(&error), None, "{label}");
9995
9996 let dir = tempfile::tempdir().unwrap();
9997 let rt = runtime_for(dir.path()).await;
9998 let mut messages = vec![sys("sys"), usr("hello")];
9999 let mut events = Vec::new();
10000 let outcome =
10001 run_assistant_loop(&TypedFailure(error), &rt, &cfg(), &mut messages, |e| {
10002 events.push(e)
10003 })
10004 .await;
10005
10006 assert_eq!(outcome.status, "error", "{label}");
10007 assert_eq!(outcome.auth_required, None, "{label}");
10008 assert!(
10009 events.iter().any(|e| matches!(e, AssistantEvent::Error(_))),
10010 "{label} must still report an error"
10011 );
10012 assert!(
10013 auth_required_events(&events).is_empty(),
10014 "{label} must not offer a sign-in"
10015 );
10016 }
10017 }
10018
10019 struct DraftThenTypedFailure {
10022 calls: AtomicUsize,
10023 error: AssistantGenerateError,
10024 }
10025
10026 #[async_trait]
10027 impl TurnGenerator for DraftThenTypedFailure {
10028 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
10029 panic!("the assistant loop must generate through the typed seam")
10030 }
10031
10032 async fn generate_assistant(
10033 &self,
10034 _req: GenerateRequest,
10035 ) -> Result<InferenceResult, AssistantGenerateError> {
10036 if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
10037 return Ok(turn("The answer is 2, not JSON.", json!([])));
10038 }
10039 Err(self.error.clone())
10040 }
10041 }
10042
10043 #[tokio::test]
10051 async fn an_auth_failure_on_the_repair_turn_keeps_the_draft_answer() {
10052 let dir = tempfile::tempdir().unwrap();
10053 let rt = runtime_for(dir.path()).await;
10054 let generator = DraftThenTypedFailure {
10055 calls: AtomicUsize::new(0),
10056 error: parslee_signed_out(),
10057 };
10058 let mut messages = vec![sys("sys"), usr("answer as JSON")];
10059 let mut events = Vec::new();
10060 let outcome = run_assistant_loop(&generator, &rt, &json_object_cfg(), &mut messages, |e| {
10061 events.push(e)
10062 })
10063 .await;
10064
10065 assert_eq!(
10066 generator.calls.load(Ordering::SeqCst),
10067 2,
10068 "primary + repair"
10069 );
10070 assert_eq!(outcome.status, "success", "the draft is still an answer");
10071 assert_eq!(outcome.summary, "The answer is 2, not JSON.");
10072 assert_eq!(outcome.auth_required, None);
10073 assert!(
10074 auth_required_events(&events).is_empty(),
10075 "the repair turn does not refuse"
10076 );
10077 assert!(
10078 events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with(FORMAT_REPAIR_FAILED_PREFIX))),
10079 "the failure must still be visible"
10080 );
10081 assert_eq!(
10082 outcome.models_served.len(),
10083 1,
10084 "the primary call's attribution stands"
10085 );
10086 }
10087
10088 #[tokio::test]
10092 async fn delegate_child_cannot_touch_the_parents_todo_list() {
10093 let dir = tempfile::tempdir().unwrap();
10094 let rt = runtime_for(dir.path()).await;
10095 let todos = Arc::new(tokio::sync::Mutex::new(super::super::todo::TodoList::new()));
10096 todos
10097 .lock()
10098 .await
10099 .write(&[json!({"text": "the parent's plan"})])
10100 .unwrap();
10101 let before = todos.lock().await.render();
10102
10103 let seen = Arc::new(StdMutex::new(Vec::new()));
10104 let script = CapturingScript {
10105 turns: vec![
10106 delegate_call(json!({"goal": "reorganize", "tools": ["calculate"]})),
10107 turn(
10108 "writing todos",
10109 json!([{ "id": "t", "name": "todo_write", "arguments": { "todos": [{"text": "hijacked"}] } }]),
10110 ),
10111 turn("could not", json!([])),
10112 turn("done", json!([])),
10113 ],
10114 cursor: AtomicUsize::new(0),
10115 seen: Arc::clone(&seen),
10116 };
10117 let mut cfg = delegate_cfg();
10118 cfg.todos = Some(Arc::clone(&todos));
10119 let mut messages = vec![sys("sys"), usr("task")];
10120 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
10121 assert_eq!(outcome.status, "success");
10122 assert_eq!(
10123 todos.lock().await.render(),
10124 before,
10125 "the parent's list is untouched"
10126 );
10127 let child_second =
10128 serde_json::to_string(seen.lock().unwrap()[2].messages.as_ref().unwrap()).unwrap();
10129 assert!(
10130 child_second.contains("not granted to this delegate"),
10131 "{child_second}"
10132 );
10133 }
10134
10135 #[tokio::test]
10138 async fn delegate_marks_the_parent_result_external_when_a_child_receipt_is() {
10139 let dir = tempfile::tempdir().unwrap();
10140 let rt = runtime_for(dir.path()).await;
10141 let mut tools = GeneralExecutor::tool_defs();
10142 tools.push(json!({
10144 "name": "http_request",
10145 "description": "Fetch a URL.",
10146 "parameters": {"type": "object", "properties": {"url": {"type": "string"}}}
10147 }));
10148 tools.push(delegate_tool_def(&tools));
10149 let cfg = AssistantConfig { tools, ..cfg() };
10150
10151 let seen = Arc::new(StdMutex::new(Vec::new()));
10152 let script = CapturingScript {
10153 turns: vec![
10154 delegate_call(json!({"goal": "fetch the page", "tools": ["http_request"]})),
10155 turn(
10156 "fetching",
10157 json!([{ "id": "h", "name": "http_request", "arguments": { "url": "https://example.invalid/" } }]),
10158 ),
10159 turn("could not fetch", json!([])),
10160 turn("done", json!([])),
10161 ],
10162 cursor: AtomicUsize::new(0),
10163 seen: Arc::clone(&seen),
10164 };
10165 let mut messages = vec![sys("sys"), usr("task")];
10166 let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
10167 assert_eq!(outcome.status, "success");
10168 let (_, external) = delegate_result(&messages);
10169 assert!(
10170 external,
10171 "a child receipt from an external-labelled tool must mark the parent's result External"
10172 );
10173 }
10174
10175 #[tokio::test]
10180 async fn reported_count_is_reset_after_compaction_not_reused_stale() {
10181 let dir = tempfile::tempdir().unwrap();
10182 let rt = runtime_for(dir.path()).await;
10183 let seen = Arc::new(StdMutex::new(Vec::new()));
10184 let script = WindowedScript {
10185 inner: CapturingScript {
10186 turns: vec![
10187 turn_with_usage(
10188 "computing",
10189 json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
10190 190_000,
10191 10,
10192 ),
10193 turn(
10195 "still computing",
10196 json!([{ "id": "k2", "name": "calculate", "arguments": { "expression": "2+2" } }]),
10197 ),
10198 turn("done", json!([])),
10199 ],
10200 cursor: AtomicUsize::new(0),
10201 seen: Arc::clone(&seen),
10202 },
10203 window: 200_000,
10204 };
10205 let mut messages = modest_history();
10206 let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
10207 assert_eq!(outcome.status, "success");
10208 let reqs = seen.lock().unwrap();
10209 assert_eq!(reqs.len(), 3);
10210 let notice_of = |req: &GenerateRequest| {
10211 req.messages
10212 .as_ref()
10213 .unwrap()
10214 .iter()
10215 .find_map(parse_compaction_notice)
10216 };
10217 let after_first = notice_of(&reqs[1]).expect("turn 2 compacts on the 190k report");
10218 let after_second = notice_of(&reqs[2]).expect("the notice persists");
10219 assert_eq!(
10220 after_first, after_second,
10221 "no second compaction: the stale 190k report must not survive the first one"
10222 );
10223 }
10224}