1use chrono::Utc;
2use futures::future::BoxFuture;
3use futures::StreamExt;
4use serde_json::{json, Map, Number, Value};
5use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tandem_observability::{emit_event, ObservabilityEvent, ProcessKind};
10use tandem_providers::{ChatAttachment, ChatMessage, ProviderRegistry, StreamChunk, TokenUsage};
11use tandem_tools::{validate_tool_schemas, ToolRegistry};
12use tandem_types::{
13 ContextMode, EngineEvent, HostOs, HostRuntimeContext, Message, MessagePart, MessagePartInput,
14 MessageRole, ModelSpec, PathStyle, PrewriteCoverageMode, PrewriteRequirements,
15 SendMessageRequest, SharedToolProgressSink, ShellFamily, ToolMode, ToolProgressEvent,
16 ToolProgressSink, ToolSchema,
17};
18use tandem_wire::WireMessagePart;
19use tokio_util::sync::CancellationToken;
20use tracing::Level;
21
22mod loop_guards;
23mod prewrite_gate;
24
25use loop_guards::{
26 duplicate_signature_limit_for, tool_budget_for, websearch_duplicate_signature_limit,
27};
28#[cfg(test)]
29use loop_guards::{parse_budget_override, HARD_TOOL_CALL_CEILING};
30use prewrite_gate::{
31 describe_unmet_prewrite_requirements_for_prompt, evaluate_prewrite_gate, PrewriteProgress,
32};
33
34use crate::tool_router::{
35 classify_intent, default_mode_name, is_short_simple_prompt, max_tools_per_call_expanded,
36 select_tool_subset, should_escalate_auto_tools, tool_router_enabled, ToolIntent,
37 ToolRoutingDecision,
38};
39use crate::{
40 any_policy_matches, derive_session_title_from_prompt, title_needs_repair,
41 tool_name_matches_policy, AgentDefinition, AgentRegistry, CancellationRegistry, EventBus,
42 PermissionAction, PermissionManager, PluginRegistry, Storage,
43};
44use crate::{
45 build_tool_effect_ledger_record, finalize_mutation_checkpoint_record,
46 mutation_checkpoint_event, prepare_mutation_checkpoint, tool_effect_ledger_event,
47 MutationCheckpointOutcome, ToolEffectLedgerPhase, ToolEffectLedgerStatus,
48};
49use tokio::sync::RwLock;
50
51#[derive(Default)]
52struct StreamedToolCall {
53 name: String,
54 args: String,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum RawToolArgsState {
59 Present,
60 Empty,
61 Unparseable,
62}
63
64impl RawToolArgsState {
65 fn as_str(self) -> &'static str {
66 match self {
67 Self::Present => "present",
68 Self::Empty => "empty",
69 Self::Unparseable => "unparseable",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum WritePathRecoveryMode {
76 Heuristic,
77 OutputTargetOnly,
78}
79
80#[derive(Debug, Clone)]
81pub struct SpawnAgentToolContext {
82 pub session_id: String,
83 pub message_id: String,
84 pub tool_call_id: Option<String>,
85 pub args: Value,
86}
87
88#[derive(Debug, Clone)]
89pub struct SpawnAgentToolResult {
90 pub output: String,
91 pub metadata: Value,
92}
93
94#[derive(Debug, Clone)]
95pub struct ToolPolicyContext {
96 pub session_id: String,
97 pub message_id: String,
98 pub tool: String,
99 pub args: Value,
100}
101
102#[derive(Debug, Clone)]
103pub struct ToolPolicyDecision {
104 pub allowed: bool,
105 pub reason: Option<String>,
106}
107
108#[derive(Clone)]
109struct EngineToolProgressSink {
110 event_bus: EventBus,
111 session_id: String,
112 message_id: String,
113 tool_call_id: Option<String>,
114 source_tool: String,
115}
116
117impl ToolProgressSink for EngineToolProgressSink {
118 fn publish(&self, event: ToolProgressEvent) {
119 let properties = merge_tool_progress_properties(
120 event.properties,
121 &self.session_id,
122 &self.message_id,
123 self.tool_call_id.as_deref(),
124 &self.source_tool,
125 );
126 self.event_bus
127 .publish(EngineEvent::new(event.event_type, properties));
128 }
129}
130
131fn merge_tool_progress_properties(
132 properties: Value,
133 session_id: &str,
134 message_id: &str,
135 tool_call_id: Option<&str>,
136 source_tool: &str,
137) -> Value {
138 let mut base = Map::new();
139 base.insert(
140 "sessionID".to_string(),
141 Value::String(session_id.to_string()),
142 );
143 base.insert(
144 "messageID".to_string(),
145 Value::String(message_id.to_string()),
146 );
147 base.insert(
148 "sourceTool".to_string(),
149 Value::String(source_tool.to_string()),
150 );
151 if let Some(tool_call_id) = tool_call_id {
152 base.insert(
153 "toolCallID".to_string(),
154 Value::String(tool_call_id.to_string()),
155 );
156 }
157 match properties {
158 Value::Object(mut map) => {
159 for (key, value) in base {
160 map.insert(key, value);
161 }
162 Value::Object(map)
163 }
164 other => {
165 base.insert("data".to_string(), other);
166 Value::Object(base)
167 }
168 }
169}
170
171pub trait SpawnAgentHook: Send + Sync {
172 fn spawn_agent(
173 &self,
174 ctx: SpawnAgentToolContext,
175 ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
176}
177
178pub trait ToolPolicyHook: Send + Sync {
179 fn evaluate_tool(
180 &self,
181 ctx: ToolPolicyContext,
182 ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
183}
184
185#[derive(Debug, Clone)]
186pub struct PromptContextHookContext {
187 pub session_id: String,
188 pub message_id: String,
189 pub provider_id: String,
190 pub model_id: String,
191 pub iteration: usize,
192}
193
194pub trait PromptContextHook: Send + Sync {
195 fn augment_provider_messages(
196 &self,
197 ctx: PromptContextHookContext,
198 messages: Vec<ChatMessage>,
199 ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
200}
201
202#[derive(Clone)]
203pub struct EngineLoop {
204 storage: std::sync::Arc<Storage>,
205 event_bus: EventBus,
206 providers: ProviderRegistry,
207 plugins: PluginRegistry,
208 agents: AgentRegistry,
209 permissions: PermissionManager,
210 tools: ToolRegistry,
211 cancellations: CancellationRegistry,
212 host_runtime_context: HostRuntimeContext,
213 workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
214 session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
215 session_auto_approve_permissions: std::sync::Arc<RwLock<HashMap<String, bool>>>,
216 spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
217 tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
218 prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
219}
220
221impl EngineLoop {
222 #[allow(clippy::too_many_arguments)]
223 pub fn new(
224 storage: std::sync::Arc<Storage>,
225 event_bus: EventBus,
226 providers: ProviderRegistry,
227 plugins: PluginRegistry,
228 agents: AgentRegistry,
229 permissions: PermissionManager,
230 tools: ToolRegistry,
231 cancellations: CancellationRegistry,
232 host_runtime_context: HostRuntimeContext,
233 ) -> Self {
234 Self {
235 storage,
236 event_bus,
237 providers,
238 plugins,
239 agents,
240 permissions,
241 tools,
242 cancellations,
243 host_runtime_context,
244 workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
245 session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
246 session_auto_approve_permissions: std::sync::Arc::new(RwLock::new(HashMap::new())),
247 spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
248 tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
249 prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
250 }
251 }
252
253 pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
254 *self.spawn_agent_hook.write().await = Some(hook);
255 }
256
257 pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
258 *self.tool_policy_hook.write().await = Some(hook);
259 }
260
261 pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
262 *self.prompt_context_hook.write().await = Some(hook);
263 }
264
265 pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
266 let normalized = allowed_tools
267 .into_iter()
268 .map(|tool| normalize_tool_name(&tool))
269 .filter(|tool| !tool.trim().is_empty())
270 .collect::<Vec<_>>();
271 self.session_allowed_tools
272 .write()
273 .await
274 .insert(session_id.to_string(), normalized);
275 }
276
277 pub async fn clear_session_allowed_tools(&self, session_id: &str) {
278 self.session_allowed_tools.write().await.remove(session_id);
279 }
280
281 pub async fn set_session_auto_approve_permissions(&self, session_id: &str, enabled: bool) {
282 if enabled {
283 self.session_auto_approve_permissions
284 .write()
285 .await
286 .insert(session_id.to_string(), true);
287 } else {
288 self.session_auto_approve_permissions
289 .write()
290 .await
291 .remove(session_id);
292 }
293 }
294
295 pub async fn clear_session_auto_approve_permissions(&self, session_id: &str) {
296 self.session_auto_approve_permissions
297 .write()
298 .await
299 .remove(session_id);
300 }
301
302 pub async fn grant_workspace_override_for_session(
303 &self,
304 session_id: &str,
305 ttl_seconds: u64,
306 ) -> u64 {
307 const MAX_WORKSPACE_OVERRIDE_TTL_SECONDS: u64 = 600; let capped_ttl = ttl_seconds.min(MAX_WORKSPACE_OVERRIDE_TTL_SECONDS);
310 if capped_ttl < ttl_seconds {
311 tracing::warn!(
312 session_id = %session_id,
313 requested_ttl_s = %ttl_seconds,
314 capped_ttl_s = %capped_ttl,
315 "workspace override TTL capped to maximum allowed value"
316 );
317 }
318 let expires_at = chrono::Utc::now()
319 .timestamp_millis()
320 .max(0)
321 .saturating_add((capped_ttl as i64).saturating_mul(1000))
322 as u64;
323 self.workspace_overrides
324 .write()
325 .await
326 .insert(session_id.to_string(), expires_at);
327 self.event_bus.publish(EngineEvent::new(
328 "workspace.override.activated",
329 json!({
330 "sessionID": session_id,
331 "requestedTtlSeconds": ttl_seconds,
332 "cappedTtlSeconds": capped_ttl,
333 "expiresAt": expires_at,
334 }),
335 ));
336 expires_at
337 }
338
339 pub async fn run_prompt_async(
340 &self,
341 session_id: String,
342 req: SendMessageRequest,
343 ) -> anyhow::Result<()> {
344 self.run_prompt_async_with_context(session_id, req, None)
345 .await
346 }
347
348 pub async fn run_prompt_async_with_context(
349 &self,
350 session_id: String,
351 req: SendMessageRequest,
352 correlation_id: Option<String>,
353 ) -> anyhow::Result<()> {
354 let session_model = self
355 .storage
356 .get_session(&session_id)
357 .await
358 .and_then(|s| s.model);
359 let (provider_id, model_id_value) =
360 resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
361 anyhow::anyhow!(
362 "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
363 )
364 })?;
365 let correlation_ref = correlation_id.as_deref();
366 let model_id = Some(model_id_value.as_str());
367 let cancel = self.cancellations.create(&session_id).await;
368 emit_event(
369 Level::INFO,
370 ProcessKind::Engine,
371 ObservabilityEvent {
372 event: "provider.call.start",
373 component: "engine.loop",
374 correlation_id: correlation_ref,
375 session_id: Some(&session_id),
376 run_id: None,
377 message_id: None,
378 provider_id: Some(provider_id.as_str()),
379 model_id,
380 status: Some("start"),
381 error_code: None,
382 detail: Some("run_prompt_async dispatch"),
383 },
384 );
385 self.event_bus.publish(EngineEvent::new(
386 "session.status",
387 json!({"sessionID": session_id, "status":"running"}),
388 ));
389 let request_parts = req.parts.clone();
390 let requested_tool_mode = req.tool_mode.clone().unwrap_or(ToolMode::Auto);
391 let requested_context_mode = req.context_mode.clone().unwrap_or(ContextMode::Auto);
392 let requested_write_required = req.write_required.unwrap_or(false);
393 let requested_prewrite_requirements = req.prewrite_requirements.clone().unwrap_or_default();
394 let request_tool_allowlist = req
395 .tool_allowlist
396 .clone()
397 .unwrap_or_default()
398 .into_iter()
399 .map(|tool| normalize_tool_name(&tool))
400 .filter(|tool| !tool.trim().is_empty())
401 .collect::<HashSet<_>>();
402 let text = req
403 .parts
404 .iter()
405 .map(|p| match p {
406 MessagePartInput::Text { text } => text.clone(),
407 MessagePartInput::File {
408 mime,
409 filename,
410 url,
411 } => format!(
412 "[file mime={} name={} url={}]",
413 mime,
414 filename.clone().unwrap_or_else(|| "unknown".to_string()),
415 url
416 ),
417 })
418 .collect::<Vec<_>>()
419 .join("\n");
420 let runtime_attachments = build_runtime_attachments(&provider_id, &request_parts).await;
421 self.auto_rename_session_from_user_text(&session_id, &text)
422 .await;
423 let active_agent = self.agents.get(req.agent.as_deref()).await;
424 let mut user_message_id = self
425 .find_recent_matching_user_message_id(&session_id, &text)
426 .await;
427 if user_message_id.is_none() {
428 let user_message = Message::new(
429 MessageRole::User,
430 vec![MessagePart::Text { text: text.clone() }],
431 );
432 let created_message_id = user_message.id.clone();
433 self.storage
434 .append_message(&session_id, user_message)
435 .await?;
436
437 let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
438 self.event_bus.publish(EngineEvent::new(
439 "message.part.updated",
440 json!({
441 "part": user_part,
442 "delta": text,
443 "agent": active_agent.name
444 }),
445 ));
446 user_message_id = Some(created_message_id);
447 }
448 let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
449
450 if cancel.is_cancelled() {
451 self.event_bus.publish(EngineEvent::new(
452 "session.status",
453 json!({"sessionID": session_id, "status":"cancelled"}),
454 ));
455 self.cancellations.remove(&session_id).await;
456 return Ok(());
457 }
458
459 let mut question_tool_used = false;
460 let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
461 if normalize_tool_name(&tool) == "question" {
462 question_tool_used = true;
463 }
464 if !agent_can_use_tool(&active_agent, &tool) {
465 format!(
466 "Tool `{tool}` is not enabled for agent `{}`.",
467 active_agent.name
468 )
469 } else {
470 self.execute_tool_with_permission(
471 &session_id,
472 &user_message_id,
473 tool.clone(),
474 args,
475 None,
476 active_agent.skills.as_deref(),
477 &text,
478 requested_write_required,
479 None,
480 cancel.clone(),
481 )
482 .await?
483 .unwrap_or_default()
484 }
485 } else {
486 let mut completion = String::new();
487 let mut max_iterations = max_tool_iterations();
488 let mut followup_context: Option<String> = None;
489 let mut last_tool_outputs: Vec<String> = Vec::new();
490 let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
491 let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
492 let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
493 let mut mutable_signature_counts: HashMap<String, usize> = HashMap::new();
494 let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
495 let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
496 let mut websearch_query_blocked = false;
497 let websearch_duplicate_signature_limit = websearch_duplicate_signature_limit();
498 let mut pack_builder_executed = false;
499 let mut auto_workspace_probe_attempted = false;
500 let mut productive_tool_calls_total = 0usize;
501 let mut productive_write_tool_calls_total = 0usize;
502 let mut productive_workspace_inspection_total = 0usize;
503 let mut productive_web_research_total = 0usize;
504 let mut productive_concrete_read_total = 0usize;
505 let mut successful_web_research_total = 0usize;
506 let mut required_tool_retry_count = 0usize;
507 let mut required_write_retry_count = 0usize;
508 let mut unmet_prewrite_repair_retry_count = 0usize;
509 let mut empty_completion_retry_count = 0usize;
510 let mut prewrite_gate_waived = false;
511 let mut invalid_tool_args_retry_count = 0usize;
512 let strict_write_retry_max_attempts = strict_write_retry_max_attempts();
513 let mut required_tool_unsatisfied_emitted = false;
514 let mut latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
515 let email_delivery_requested = requires_email_delivery_prompt(&text);
516 let web_research_requested = requires_web_research_prompt(&text);
517 let code_workflow_requested = infer_code_workflow_from_text(&text);
518 let mut email_action_executed = false;
519 let mut latest_email_action_note: Option<String> = None;
520 let intent = classify_intent(&text);
521 let router_enabled = tool_router_enabled();
522 let retrieval_enabled = semantic_tool_retrieval_enabled();
523 let retrieval_k = semantic_tool_retrieval_k();
524 let mcp_server_names = if mcp_catalog_in_system_prompt_enabled() {
525 self.tools.mcp_server_names().await
526 } else {
527 Vec::new()
528 };
529 let mut auto_tools_escalated = matches!(requested_tool_mode, ToolMode::Required);
530 let context_is_auto_compact = matches!(requested_context_mode, ContextMode::Auto)
531 && runtime_attachments.is_empty()
532 && is_short_simple_prompt(&text)
533 && matches!(intent, ToolIntent::Chitchat | ToolIntent::Knowledge);
534
535 while max_iterations > 0 && !cancel.is_cancelled() {
536 let iteration = 26usize.saturating_sub(max_iterations);
537 max_iterations -= 1;
538 let context_profile = if matches!(requested_context_mode, ContextMode::Full) {
539 ChatHistoryProfile::Full
540 } else if matches!(requested_context_mode, ContextMode::Compact)
541 || context_is_auto_compact
542 {
543 ChatHistoryProfile::Compact
544 } else {
545 ChatHistoryProfile::Standard
546 };
547 let mut messages =
548 load_chat_history(self.storage.clone(), &session_id, context_profile).await;
549 if iteration == 1 && !runtime_attachments.is_empty() {
550 attach_to_last_user_message(&mut messages, &runtime_attachments);
551 }
552 let history_char_count = messages.iter().map(|m| m.content.len()).sum::<usize>();
553 self.event_bus.publish(EngineEvent::new(
554 "context.profile.selected",
555 json!({
556 "sessionID": session_id,
557 "messageID": user_message_id,
558 "iteration": iteration,
559 "contextMode": format_context_mode(&requested_context_mode, context_is_auto_compact),
560 "historyMessageCount": messages.len(),
561 "historyCharCount": history_char_count,
562 "memoryInjected": false
563 }),
564 ));
565 let mut system_parts = vec![tandem_runtime_system_prompt(
566 &self.host_runtime_context,
567 &mcp_server_names,
568 )];
569 if let Some(system) = active_agent.system_prompt.as_ref() {
570 system_parts.push(system.clone());
571 }
572 messages.insert(
573 0,
574 ChatMessage {
575 role: "system".to_string(),
576 content: system_parts.join("\n\n"),
577 attachments: Vec::new(),
578 },
579 );
580 if let Some(extra) = followup_context.take() {
581 messages.push(ChatMessage {
582 role: "user".to_string(),
583 content: extra,
584 attachments: Vec::new(),
585 });
586 }
587 if let Some(hook) = self.prompt_context_hook.read().await.clone() {
588 let ctx = PromptContextHookContext {
589 session_id: session_id.clone(),
590 message_id: user_message_id.clone(),
591 provider_id: provider_id.clone(),
592 model_id: model_id_value.clone(),
593 iteration,
594 };
595 let hook_timeout =
596 Duration::from_millis(prompt_context_hook_timeout_ms() as u64);
597 match tokio::time::timeout(
598 hook_timeout,
599 hook.augment_provider_messages(ctx, messages.clone()),
600 )
601 .await
602 {
603 Ok(Ok(augmented)) => {
604 messages = augmented;
605 }
606 Ok(Err(err)) => {
607 self.event_bus.publish(EngineEvent::new(
608 "memory.context.error",
609 json!({
610 "sessionID": session_id,
611 "messageID": user_message_id,
612 "iteration": iteration,
613 "error": truncate_text(&err.to_string(), 500),
614 }),
615 ));
616 }
617 Err(_) => {
618 self.event_bus.publish(EngineEvent::new(
619 "memory.context.error",
620 json!({
621 "sessionID": session_id,
622 "messageID": user_message_id,
623 "iteration": iteration,
624 "error": format!(
625 "prompt context hook timeout after {} ms",
626 hook_timeout.as_millis()
627 ),
628 }),
629 ));
630 }
631 }
632 }
633 let all_tools = self.tools.list().await;
634 let mut retrieval_fallback_reason: Option<&'static str> = None;
635 let mut candidate_tools = if retrieval_enabled {
636 self.tools.retrieve(&text, retrieval_k).await
637 } else {
638 all_tools.clone()
639 };
640 if retrieval_enabled {
641 if candidate_tools.is_empty() && !all_tools.is_empty() {
642 candidate_tools = all_tools.clone();
643 retrieval_fallback_reason = Some("retrieval_empty_result");
644 } else if web_research_requested
645 && has_web_research_tools(&all_tools)
646 && !has_web_research_tools(&candidate_tools)
647 && required_write_retry_count == 0
648 {
649 candidate_tools = all_tools.clone();
650 retrieval_fallback_reason = Some("missing_web_tools_for_research_prompt");
651 } else if email_delivery_requested
652 && has_email_action_tools(&all_tools)
653 && !has_email_action_tools(&candidate_tools)
654 {
655 candidate_tools = all_tools.clone();
656 retrieval_fallback_reason = Some("missing_email_tools_for_delivery_prompt");
657 }
658 }
659 let mut tool_schemas = if !router_enabled {
660 candidate_tools
661 } else {
662 match requested_tool_mode {
663 ToolMode::None => Vec::new(),
664 ToolMode::Required => select_tool_subset(
665 candidate_tools,
666 intent,
667 &request_tool_allowlist,
668 iteration > 1,
669 ),
670 ToolMode::Auto => {
671 if !auto_tools_escalated {
672 Vec::new()
673 } else {
674 select_tool_subset(
675 candidate_tools,
676 intent,
677 &request_tool_allowlist,
678 iteration > 1,
679 )
680 }
681 }
682 }
683 };
684 let mut policy_patterns =
685 request_tool_allowlist.iter().cloned().collect::<Vec<_>>();
686 if let Some(agent_tools) = active_agent.tools.as_ref() {
687 policy_patterns
688 .extend(agent_tools.iter().map(|tool| normalize_tool_name(tool)));
689 }
690 let session_allowed_tools = self
691 .session_allowed_tools
692 .read()
693 .await
694 .get(&session_id)
695 .cloned()
696 .unwrap_or_default();
697 policy_patterns.extend(session_allowed_tools.iter().cloned());
698 if !policy_patterns.is_empty() {
699 let mut included = tool_schemas
700 .iter()
701 .map(|schema| normalize_tool_name(&schema.name))
702 .collect::<HashSet<_>>();
703 for schema in &all_tools {
704 let normalized = normalize_tool_name(&schema.name);
705 if policy_patterns
706 .iter()
707 .any(|pattern| tool_name_matches_policy(pattern, &normalized))
708 && included.insert(normalized)
709 {
710 tool_schemas.push(schema.clone());
711 }
712 }
713 }
714 if !request_tool_allowlist.is_empty() {
715 tool_schemas.retain(|schema| {
716 let tool = normalize_tool_name(&schema.name);
717 request_tool_allowlist
718 .iter()
719 .any(|pattern| tool_name_matches_policy(pattern, &tool))
720 });
721 }
722 let prewrite_gate = evaluate_prewrite_gate(
723 requested_write_required,
724 &requested_prewrite_requirements,
725 PrewriteProgress {
726 productive_write_tool_calls_total,
727 productive_workspace_inspection_total,
728 productive_concrete_read_total,
729 productive_web_research_total,
730 successful_web_research_total,
731 required_write_retry_count,
732 unmet_prewrite_repair_retry_count,
733 prewrite_gate_waived,
734 },
735 );
736 let _prewrite_satisfied = prewrite_gate.prewrite_satisfied;
737 let prewrite_gate_write = prewrite_gate.gate_write;
738 let force_write_only_retry = prewrite_gate.force_write_only_retry;
739 let allow_repair_tools = prewrite_gate.allow_repair_tools;
740 if prewrite_gate_write {
741 tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
742 }
743 if requested_prewrite_requirements.repair_on_unmet_requirements
744 && productive_write_tool_calls_total >= 3
745 {
746 tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
747 }
748 if allow_repair_tools {
749 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
750 let repair_tools = tool_schemas
751 .iter()
752 .filter(|schema| {
753 tool_matches_unmet_prewrite_repair_requirement(
754 &schema.name,
755 &unmet_prewrite_codes,
756 )
757 })
758 .cloned()
759 .collect::<Vec<_>>();
760 if !repair_tools.is_empty() {
761 tool_schemas = repair_tools;
762 }
763 }
764 if force_write_only_retry && !allow_repair_tools {
765 tool_schemas.retain(|schema| is_workspace_write_tool(&schema.name));
766 }
767 if active_agent.tools.is_some() {
768 tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
769 }
770 tool_schemas.retain(|schema| {
771 let normalized = normalize_tool_name(&schema.name);
772 if let Some(server) = mcp_server_from_tool_name(&normalized) {
773 !blocked_mcp_servers.contains(server)
774 } else {
775 true
776 }
777 });
778 if let Some(allowed_tools) = self
779 .session_allowed_tools
780 .read()
781 .await
782 .get(&session_id)
783 .cloned()
784 {
785 if !allowed_tools.is_empty() {
786 tool_schemas.retain(|schema| {
787 let normalized = normalize_tool_name(&schema.name);
788 any_policy_matches(&allowed_tools, &normalized)
789 });
790 }
791 }
792 if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
793 let detail = validation_err.to_string();
794 emit_event(
795 Level::ERROR,
796 ProcessKind::Engine,
797 ObservabilityEvent {
798 event: "provider.call.error",
799 component: "engine.loop",
800 correlation_id: correlation_ref,
801 session_id: Some(&session_id),
802 run_id: None,
803 message_id: Some(&user_message_id),
804 provider_id: Some(provider_id.as_str()),
805 model_id,
806 status: Some("failed"),
807 error_code: Some("TOOL_SCHEMA_INVALID"),
808 detail: Some(&detail),
809 },
810 );
811 anyhow::bail!("{detail}");
812 }
813 let routing_decision = ToolRoutingDecision {
814 pass: if auto_tools_escalated { 2 } else { 1 },
815 mode: match requested_tool_mode {
816 ToolMode::Auto => default_mode_name(),
817 ToolMode::None => "none",
818 ToolMode::Required => "required",
819 },
820 intent,
821 selected_count: tool_schemas.len(),
822 total_available_count: all_tools.len(),
823 mcp_included: tool_schemas
824 .iter()
825 .any(|schema| normalize_tool_name(&schema.name).starts_with("mcp.")),
826 };
827 self.event_bus.publish(EngineEvent::new(
828 "tool.routing.decision",
829 json!({
830 "sessionID": session_id,
831 "messageID": user_message_id,
832 "iteration": iteration,
833 "pass": routing_decision.pass,
834 "mode": routing_decision.mode,
835 "intent": format!("{:?}", routing_decision.intent).to_ascii_lowercase(),
836 "selectedToolCount": routing_decision.selected_count,
837 "totalAvailableTools": routing_decision.total_available_count,
838 "mcpIncluded": routing_decision.mcp_included,
839 "retrievalEnabled": retrieval_enabled,
840 "retrievalK": retrieval_k,
841 "fallbackToFullTools": retrieval_fallback_reason.is_some(),
842 "fallbackReason": retrieval_fallback_reason
843 }),
844 ));
845 let allowed_tool_names = tool_schemas
846 .iter()
847 .map(|schema| normalize_tool_name(&schema.name))
848 .collect::<HashSet<_>>();
849 let offered_tool_preview = tool_schemas
850 .iter()
851 .take(8)
852 .map(|schema| normalize_tool_name(&schema.name))
853 .collect::<Vec<_>>()
854 .join(", ");
855 self.event_bus.publish(EngineEvent::new(
856 "provider.call.iteration.start",
857 json!({
858 "sessionID": session_id,
859 "messageID": user_message_id,
860 "iteration": iteration,
861 "selectedToolCount": allowed_tool_names.len(),
862 }),
863 ));
864 let provider_connect_timeout =
865 Duration::from_millis(provider_stream_connect_timeout_ms() as u64);
866 let stream_result = tokio::time::timeout(
867 provider_connect_timeout,
868 self.providers.stream_for_provider(
869 Some(provider_id.as_str()),
870 Some(model_id_value.as_str()),
871 messages,
872 requested_tool_mode.clone(),
873 Some(tool_schemas),
874 cancel.clone(),
875 ),
876 )
877 .await
878 .map_err(|_| {
879 anyhow::anyhow!(
880 "provider stream connect timeout after {} ms",
881 provider_connect_timeout.as_millis()
882 )
883 })
884 .and_then(|result| result);
885 let stream = match stream_result {
886 Ok(stream) => stream,
887 Err(err) => {
888 let error_text = err.to_string();
889 let error_code = provider_error_code(&error_text);
890 let detail = truncate_text(&error_text, 500);
891 emit_event(
892 Level::ERROR,
893 ProcessKind::Engine,
894 ObservabilityEvent {
895 event: "provider.call.error",
896 component: "engine.loop",
897 correlation_id: correlation_ref,
898 session_id: Some(&session_id),
899 run_id: None,
900 message_id: Some(&user_message_id),
901 provider_id: Some(provider_id.as_str()),
902 model_id,
903 status: Some("failed"),
904 error_code: Some(error_code),
905 detail: Some(&detail),
906 },
907 );
908 self.event_bus.publish(EngineEvent::new(
909 "provider.call.iteration.error",
910 json!({
911 "sessionID": session_id,
912 "messageID": user_message_id,
913 "iteration": iteration,
914 "error": detail,
915 }),
916 ));
917 return Err(err);
918 }
919 };
920 tokio::pin!(stream);
921 completion.clear();
922 let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
923 let mut provider_usage: Option<TokenUsage> = None;
924 let mut accepted_tool_calls_in_cycle = 0usize;
925 let provider_idle_timeout =
926 Duration::from_millis(provider_stream_idle_timeout_ms() as u64);
927 loop {
928 let next_chunk_result =
929 tokio::time::timeout(provider_idle_timeout, stream.next())
930 .await
931 .map_err(|_| {
932 anyhow::anyhow!(
933 "provider stream idle timeout after {} ms",
934 provider_idle_timeout.as_millis()
935 )
936 });
937 let next_chunk = match next_chunk_result {
938 Ok(next_chunk) => next_chunk,
939 Err(err) => {
940 self.event_bus.publish(EngineEvent::new(
941 "provider.call.iteration.error",
942 json!({
943 "sessionID": session_id,
944 "messageID": user_message_id,
945 "iteration": iteration,
946 "error": truncate_text(&err.to_string(), 500),
947 }),
948 ));
949 return Err(err);
950 }
951 };
952 let Some(chunk) = next_chunk else {
953 break;
954 };
955 let chunk = match chunk {
956 Ok(chunk) => chunk,
957 Err(err) => {
958 let error_text = err.to_string();
959 let error_code = provider_error_code(&error_text);
960 let detail = truncate_text(&error_text, 500);
961 emit_event(
962 Level::ERROR,
963 ProcessKind::Engine,
964 ObservabilityEvent {
965 event: "provider.call.error",
966 component: "engine.loop",
967 correlation_id: correlation_ref,
968 session_id: Some(&session_id),
969 run_id: None,
970 message_id: Some(&user_message_id),
971 provider_id: Some(provider_id.as_str()),
972 model_id,
973 status: Some("failed"),
974 error_code: Some(error_code),
975 detail: Some(&detail),
976 },
977 );
978 self.event_bus.publish(EngineEvent::new(
979 "provider.call.iteration.error",
980 json!({
981 "sessionID": session_id,
982 "messageID": user_message_id,
983 "iteration": iteration,
984 "error": detail,
985 }),
986 ));
987 return Err(anyhow::anyhow!(
988 "provider stream chunk error: {error_text}"
989 ));
990 }
991 };
992 match chunk {
993 StreamChunk::TextDelta(delta) => {
994 let delta = strip_model_control_markers(&delta);
995 if delta.trim().is_empty() {
996 continue;
997 }
998 if completion.is_empty() {
999 emit_event(
1000 Level::INFO,
1001 ProcessKind::Engine,
1002 ObservabilityEvent {
1003 event: "provider.call.first_byte",
1004 component: "engine.loop",
1005 correlation_id: correlation_ref,
1006 session_id: Some(&session_id),
1007 run_id: None,
1008 message_id: Some(&user_message_id),
1009 provider_id: Some(provider_id.as_str()),
1010 model_id,
1011 status: Some("streaming"),
1012 error_code: None,
1013 detail: Some("first text delta"),
1014 },
1015 );
1016 }
1017 completion.push_str(&delta);
1018 let delta = truncate_text(&delta, 4_000);
1019 let delta_part =
1020 WireMessagePart::text(&session_id, &user_message_id, delta.clone());
1021 self.event_bus.publish(EngineEvent::new(
1022 "message.part.updated",
1023 json!({"part": delta_part, "delta": delta}),
1024 ));
1025 }
1026 StreamChunk::ReasoningDelta(_reasoning) => {}
1027 StreamChunk::Done {
1028 finish_reason: _,
1029 usage,
1030 } => {
1031 if usage.is_some() {
1032 provider_usage = usage;
1033 }
1034 break;
1035 }
1036 StreamChunk::ToolCallStart { id, name } => {
1037 let entry = streamed_tool_calls.entry(id).or_default();
1038 if entry.name.is_empty() {
1039 entry.name = name;
1040 }
1041 }
1042 StreamChunk::ToolCallDelta { id, args_delta } => {
1043 let entry = streamed_tool_calls.entry(id.clone()).or_default();
1044 entry.args.push_str(&args_delta);
1045 let tool_name = if entry.name.trim().is_empty() {
1046 "tool".to_string()
1047 } else {
1048 normalize_tool_name(&entry.name)
1049 };
1050 let parsed_preview = if entry.name.trim().is_empty() {
1051 Value::String(truncate_text(&entry.args, 1_000))
1052 } else {
1053 parse_streamed_tool_args(&tool_name, &entry.args)
1054 };
1055 let mut tool_part = WireMessagePart::tool_invocation(
1056 &session_id,
1057 &user_message_id,
1058 tool_name.clone(),
1059 parsed_preview.clone(),
1060 );
1061 tool_part.id = Some(id.clone());
1062 if tool_name == "write" {
1063 tracing::info!(
1064 session_id = %session_id,
1065 message_id = %user_message_id,
1066 tool_call_id = %id,
1067 args_delta_len = args_delta.len(),
1068 accumulated_args_len = entry.args.len(),
1069 parsed_preview_empty = parsed_preview.is_null()
1070 || parsed_preview.as_object().is_some_and(|value| value.is_empty())
1071 || parsed_preview
1072 .as_str()
1073 .map(|value| value.trim().is_empty())
1074 .unwrap_or(false),
1075 "streamed write tool args delta received"
1076 );
1077 }
1078 self.event_bus.publish(EngineEvent::new(
1079 "message.part.updated",
1080 json!({
1081 "part": tool_part,
1082 "toolCallDelta": {
1083 "id": id,
1084 "tool": tool_name,
1085 "argsDelta": truncate_text(&args_delta, 1_000),
1086 "rawArgsPreview": truncate_text(&entry.args, 2_000),
1087 "parsedArgsPreview": parsed_preview
1088 }
1089 }),
1090 ));
1091 }
1092 StreamChunk::ToolCallEnd { id: _ } => {}
1093 }
1094 if cancel.is_cancelled() {
1095 break;
1096 }
1097 }
1098
1099 let streamed_tool_call_count = streamed_tool_calls.len();
1100 let streamed_tool_call_parse_failed = streamed_tool_calls
1101 .values()
1102 .any(|call| !call.args.trim().is_empty() && call.name.trim().is_empty());
1103 let mut tool_calls = streamed_tool_calls
1104 .into_iter()
1105 .filter_map(|(call_id, call)| {
1106 if call.name.trim().is_empty() {
1107 return None;
1108 }
1109 let tool_name = normalize_tool_name(&call.name);
1110 let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
1111 Some(ParsedToolCall {
1112 tool: tool_name,
1113 args: parsed_args,
1114 call_id: Some(call_id),
1115 })
1116 })
1117 .collect::<Vec<_>>();
1118 if tool_calls.is_empty() {
1119 tool_calls = parse_tool_invocations_from_response(&completion)
1120 .into_iter()
1121 .map(|(tool, args)| ParsedToolCall {
1122 tool,
1123 args,
1124 call_id: None,
1125 })
1126 .collect::<Vec<_>>();
1127 }
1128 let provider_tool_parse_failed = tool_calls.is_empty()
1129 && (streamed_tool_call_parse_failed
1130 || (streamed_tool_call_count > 0
1131 && looks_like_unparsed_tool_payload(&completion))
1132 || looks_like_unparsed_tool_payload(&completion));
1133 if provider_tool_parse_failed {
1134 latest_required_tool_failure_kind =
1135 RequiredToolFailureKind::ToolCallParseFailed;
1136 } else if tool_calls.is_empty() {
1137 latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
1138 }
1139 if router_enabled
1140 && matches!(requested_tool_mode, ToolMode::Auto)
1141 && !auto_tools_escalated
1142 && iteration == 1
1143 && should_escalate_auto_tools(intent, &text, &completion)
1144 {
1145 auto_tools_escalated = true;
1146 followup_context = Some(
1147 "Tool access is now enabled for this request. Use only necessary tools and then answer concisely."
1148 .to_string(),
1149 );
1150 self.event_bus.publish(EngineEvent::new(
1151 "provider.call.iteration.finish",
1152 json!({
1153 "sessionID": session_id,
1154 "messageID": user_message_id,
1155 "iteration": iteration,
1156 "finishReason": "auto_escalate",
1157 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1158 "rejectedToolCalls": 0,
1159 }),
1160 ));
1161 continue;
1162 }
1163 if tool_calls.is_empty()
1164 && !auto_workspace_probe_attempted
1165 && should_force_workspace_probe(&text, &completion)
1166 && allowed_tool_names.contains("glob")
1167 {
1168 auto_workspace_probe_attempted = true;
1169 tool_calls = vec![ParsedToolCall {
1170 tool: "glob".to_string(),
1171 args: json!({ "pattern": "*" }),
1172 call_id: None,
1173 }];
1174 }
1175 if !tool_calls.is_empty() {
1176 let saw_tool_call_candidate = true;
1177 let mut outputs = Vec::new();
1178 let mut executed_productive_tool = false;
1179 let mut write_tool_attempted_in_cycle = false;
1180 let mut auth_required_hit_in_cycle = false;
1181 let mut guard_budget_hit_in_cycle = false;
1182 let mut duplicate_signature_hit_in_cycle = false;
1183 let mut rejected_tool_call_in_cycle = false;
1184 for ParsedToolCall {
1185 tool,
1186 args,
1187 call_id,
1188 } in tool_calls
1189 {
1190 if !agent_can_use_tool(&active_agent, &tool) {
1191 rejected_tool_call_in_cycle = true;
1192 continue;
1193 }
1194 let tool_key = normalize_tool_name(&tool);
1195 if is_workspace_write_tool(&tool_key) {
1196 write_tool_attempted_in_cycle = true;
1197 }
1198 if !allowed_tool_names.contains(&tool_key) {
1199 rejected_tool_call_in_cycle = true;
1200 let note = if offered_tool_preview.is_empty() {
1201 format!(
1202 "Tool `{}` call skipped: it is not available in this turn.",
1203 tool_key
1204 )
1205 } else {
1206 format!(
1207 "Tool `{}` call skipped: it is not available in this turn. Available tools: {}.",
1208 tool_key, offered_tool_preview
1209 )
1210 };
1211 self.event_bus.publish(EngineEvent::new(
1212 "tool.call.rejected_unoffered",
1213 json!({
1214 "sessionID": session_id,
1215 "messageID": user_message_id,
1216 "iteration": iteration,
1217 "tool": tool_key,
1218 "offeredToolCount": allowed_tool_names.len()
1219 }),
1220 ));
1221 if tool_name_looks_like_email_action(&tool_key) {
1222 latest_email_action_note = Some(note.clone());
1223 }
1224 outputs.push(note);
1225 continue;
1226 }
1227 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1228 if blocked_mcp_servers.contains(server) {
1229 rejected_tool_call_in_cycle = true;
1230 outputs.push(format!(
1231 "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
1232 tool_key, server
1233 ));
1234 continue;
1235 }
1236 }
1237 if tool_key == "question" {
1238 question_tool_used = true;
1239 }
1240 if tool_key == "pack_builder" && pack_builder_executed {
1241 rejected_tool_call_in_cycle = true;
1242 outputs.push(
1243 "Tool `pack_builder` call skipped: already executed in this run. Provide a final response or ask any required follow-up question."
1244 .to_string(),
1245 );
1246 continue;
1247 }
1248 if websearch_query_blocked && tool_key == "websearch" {
1249 rejected_tool_call_in_cycle = true;
1250 outputs.push(
1251 "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
1252 .to_string(),
1253 );
1254 continue;
1255 }
1256 let mut effective_args = args.clone();
1257 if tool_key == "todo_write" {
1258 effective_args = normalize_todo_write_args(effective_args, &completion);
1259 if is_empty_todo_write_args(&effective_args) {
1260 rejected_tool_call_in_cycle = true;
1261 outputs.push(
1262 "Tool `todo_write` call skipped: empty todo payload."
1263 .to_string(),
1264 );
1265 continue;
1266 }
1267 }
1268 let signature = if tool_key == "batch" {
1269 batch_tool_signature(&args)
1270 .unwrap_or_else(|| tool_signature(&tool_key, &args))
1271 } else {
1272 tool_signature(&tool_key, &args)
1273 };
1274 if is_shell_tool_name(&tool_key)
1275 && shell_mismatch_signatures.contains(&signature)
1276 {
1277 rejected_tool_call_in_cycle = true;
1278 outputs.push(
1279 "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
1280 .to_string(),
1281 );
1282 continue;
1283 }
1284 let mut signature_count = 1usize;
1285 if is_read_only_tool(&tool_key)
1286 || (tool_key == "batch" && is_read_only_batch_call(&args))
1287 {
1288 let count = readonly_signature_counts
1289 .entry(signature.clone())
1290 .and_modify(|v| *v = v.saturating_add(1))
1291 .or_insert(1);
1292 signature_count = *count;
1293 if tool_key == "websearch" {
1294 if let Some(limit) = websearch_duplicate_signature_limit {
1295 if *count > limit {
1296 rejected_tool_call_in_cycle = true;
1297 self.event_bus.publish(EngineEvent::new(
1298 "tool.loop_guard.triggered",
1299 json!({
1300 "sessionID": session_id,
1301 "messageID": user_message_id,
1302 "tool": tool_key,
1303 "reason": "duplicate_signature_retry_exhausted",
1304 "duplicateLimit": limit,
1305 "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
1306 "loop_guard_triggered": true
1307 }),
1308 ));
1309 outputs.push(
1310 "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
1311 .to_string(),
1312 );
1313 continue;
1314 }
1315 }
1316 }
1317 if tool_key != "websearch" && *count > 1 {
1318 rejected_tool_call_in_cycle = true;
1319 if let Some(cached) = readonly_tool_cache.get(&signature) {
1320 outputs.push(cached.clone());
1321 } else {
1322 outputs.push(format!(
1323 "Tool `{}` call skipped: duplicate call signature detected.",
1324 tool_key
1325 ));
1326 }
1327 continue;
1328 }
1329 }
1330 let is_read_only_signature = is_read_only_tool(&tool_key)
1331 || (tool_key == "batch" && is_read_only_batch_call(&args));
1332 if !is_read_only_signature {
1333 let duplicate_limit = duplicate_signature_limit_for(&tool_key);
1334 let seen = mutable_signature_counts
1335 .entry(signature.clone())
1336 .and_modify(|v| *v = v.saturating_add(1))
1337 .or_insert(1);
1338 if *seen > duplicate_limit {
1339 rejected_tool_call_in_cycle = true;
1340 self.event_bus.publish(EngineEvent::new(
1341 "tool.loop_guard.triggered",
1342 json!({
1343 "sessionID": session_id,
1344 "messageID": user_message_id,
1345 "tool": tool_key,
1346 "reason": "duplicate_signature_retry_exhausted",
1347 "signatureHash": stable_hash(&signature),
1348 "duplicateLimit": duplicate_limit,
1349 "loop_guard_triggered": true
1350 }),
1351 ));
1352 outputs.push(format!(
1353 "Tool `{}` call skipped: duplicate call signature retry limit reached ({}).",
1354 tool_key, duplicate_limit
1355 ));
1356 duplicate_signature_hit_in_cycle = true;
1357 continue;
1358 }
1359 }
1360 let budget = tool_budget_for(&tool_key);
1361 let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
1362 if *entry >= budget {
1363 rejected_tool_call_in_cycle = true;
1364 outputs.push(format!(
1365 "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
1366 tool_key, budget
1367 ));
1368 guard_budget_hit_in_cycle = true;
1369 continue;
1370 }
1371 let mut finalized_part = WireMessagePart::tool_invocation(
1372 &session_id,
1373 &user_message_id,
1374 tool.clone(),
1375 effective_args.clone(),
1376 );
1377 if let Some(call_id) = call_id.clone() {
1378 finalized_part.id = Some(call_id);
1379 }
1380 finalized_part.state = Some("pending".to_string());
1381 self.event_bus.publish(EngineEvent::new(
1382 "message.part.updated",
1383 json!({"part": finalized_part}),
1384 ));
1385 *entry += 1;
1386 accepted_tool_calls_in_cycle =
1387 accepted_tool_calls_in_cycle.saturating_add(1);
1388 if let Some(output) = self
1389 .execute_tool_with_permission(
1390 &session_id,
1391 &user_message_id,
1392 tool,
1393 effective_args,
1394 call_id,
1395 active_agent.skills.as_deref(),
1396 &text,
1397 requested_write_required,
1398 Some(&completion),
1399 cancel.clone(),
1400 )
1401 .await?
1402 {
1403 let productive = is_productive_tool_output(&tool_key, &output);
1404 if output.contains("WEBSEARCH_QUERY_MISSING") {
1405 websearch_query_blocked = true;
1406 }
1407 if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
1408 {
1409 shell_mismatch_signatures.insert(signature.clone());
1410 }
1411 if is_read_only_tool(&tool_key)
1412 && tool_key != "websearch"
1413 && signature_count == 1
1414 {
1415 readonly_tool_cache.insert(signature, output.clone());
1416 }
1417 if productive {
1418 productive_tool_calls_total =
1419 productive_tool_calls_total.saturating_add(1);
1420 if is_workspace_write_tool(&tool_key) {
1421 productive_write_tool_calls_total =
1422 productive_write_tool_calls_total.saturating_add(1);
1423 }
1424 if is_workspace_inspection_tool(&tool_key) {
1425 productive_workspace_inspection_total =
1426 productive_workspace_inspection_total.saturating_add(1);
1427 }
1428 if tool_key == "read" {
1429 productive_concrete_read_total =
1430 productive_concrete_read_total.saturating_add(1);
1431 }
1432 if is_web_research_tool(&tool_key) {
1433 productive_web_research_total =
1434 productive_web_research_total.saturating_add(1);
1435 if is_successful_web_research_output(&tool_key, &output) {
1436 successful_web_research_total =
1437 successful_web_research_total.saturating_add(1);
1438 }
1439 }
1440 executed_productive_tool = true;
1441 if tool_key == "pack_builder" {
1442 pack_builder_executed = true;
1443 }
1444 }
1445 if tool_name_looks_like_email_action(&tool_key) {
1446 if productive {
1447 email_action_executed = true;
1448 } else {
1449 latest_email_action_note =
1450 Some(truncate_text(&output, 280).replace('\n', " "));
1451 }
1452 }
1453 if is_auth_required_tool_output(&output) {
1454 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1455 blocked_mcp_servers.insert(server.to_string());
1456 }
1457 auth_required_hit_in_cycle = true;
1458 }
1459 outputs.push(output);
1460 if auth_required_hit_in_cycle {
1461 break;
1462 }
1463 if guard_budget_hit_in_cycle {
1464 break;
1465 }
1466 }
1467 }
1468 if !outputs.is_empty() {
1469 last_tool_outputs = outputs.clone();
1470 if matches!(requested_tool_mode, ToolMode::Required)
1471 && productive_tool_calls_total == 0
1472 {
1473 latest_required_tool_failure_kind = classify_required_tool_failure(
1474 &outputs,
1475 saw_tool_call_candidate,
1476 accepted_tool_calls_in_cycle,
1477 provider_tool_parse_failed,
1478 rejected_tool_call_in_cycle,
1479 );
1480 if requested_write_required
1481 && write_tool_attempted_in_cycle
1482 && productive_write_tool_calls_total == 0
1483 && is_write_invalid_args_failure_kind(
1484 latest_required_tool_failure_kind,
1485 )
1486 {
1487 if required_write_retry_count + 1 < strict_write_retry_max_attempts
1488 {
1489 required_write_retry_count += 1;
1490 required_tool_retry_count += 1;
1491 followup_context = Some(build_write_required_retry_context(
1492 &offered_tool_preview,
1493 latest_required_tool_failure_kind,
1494 &text,
1495 &requested_prewrite_requirements,
1496 productive_workspace_inspection_total > 0,
1497 productive_concrete_read_total > 0,
1498 productive_web_research_total > 0,
1499 successful_web_research_total > 0,
1500 ));
1501 self.event_bus.publish(EngineEvent::new(
1502 "provider.call.iteration.finish",
1503 json!({
1504 "sessionID": session_id,
1505 "messageID": user_message_id,
1506 "iteration": iteration,
1507 "finishReason": "required_write_invalid_retry",
1508 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1509 "rejectedToolCalls": 0,
1510 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1511 }),
1512 ));
1513 continue;
1514 }
1515 }
1516 let progress_made_in_cycle = productive_workspace_inspection_total > 0
1517 || productive_concrete_read_total > 0
1518 || productive_web_research_total > 0
1519 || successful_web_research_total > 0;
1520 if should_retry_nonproductive_required_tool_cycle(
1521 requested_write_required,
1522 write_tool_attempted_in_cycle,
1523 progress_made_in_cycle,
1524 required_tool_retry_count,
1525 ) {
1526 required_tool_retry_count += 1;
1527 followup_context =
1528 Some(build_required_tool_retry_context_for_task(
1529 &offered_tool_preview,
1530 latest_required_tool_failure_kind,
1531 &text,
1532 ));
1533 self.event_bus.publish(EngineEvent::new(
1534 "provider.call.iteration.finish",
1535 json!({
1536 "sessionID": session_id,
1537 "messageID": user_message_id,
1538 "iteration": iteration,
1539 "finishReason": "required_tool_retry",
1540 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1541 "rejectedToolCalls": 0,
1542 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1543 }),
1544 ));
1545 continue;
1546 }
1547 completion = required_tool_mode_unsatisfied_completion(
1548 latest_required_tool_failure_kind,
1549 );
1550 if !required_tool_unsatisfied_emitted {
1551 required_tool_unsatisfied_emitted = true;
1552 self.event_bus.publish(EngineEvent::new(
1553 "tool.mode.required.unsatisfied",
1554 json!({
1555 "sessionID": session_id,
1556 "messageID": user_message_id,
1557 "iteration": iteration,
1558 "selectedToolCount": allowed_tool_names.len(),
1559 "offeredToolsPreview": offered_tool_preview,
1560 "reason": latest_required_tool_failure_kind.code(),
1561 }),
1562 ));
1563 }
1564 self.event_bus.publish(EngineEvent::new(
1565 "provider.call.iteration.finish",
1566 json!({
1567 "sessionID": session_id,
1568 "messageID": user_message_id,
1569 "iteration": iteration,
1570 "finishReason": "required_tool_unsatisfied",
1571 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1572 "rejectedToolCalls": 0,
1573 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1574 }),
1575 ));
1576 break;
1577 }
1578 let prewrite_gate = evaluate_prewrite_gate(
1579 requested_write_required,
1580 &requested_prewrite_requirements,
1581 PrewriteProgress {
1582 productive_write_tool_calls_total,
1583 productive_workspace_inspection_total,
1584 productive_concrete_read_total,
1585 productive_web_research_total,
1586 successful_web_research_total,
1587 required_write_retry_count,
1588 unmet_prewrite_repair_retry_count,
1589 prewrite_gate_waived,
1590 },
1591 );
1592 let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1593 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1594 if requested_write_required
1595 && productive_tool_calls_total > 0
1596 && productive_write_tool_calls_total == 0
1597 {
1598 if should_start_prewrite_repair_before_first_write(
1599 requested_prewrite_requirements.repair_on_unmet_requirements,
1600 productive_write_tool_calls_total,
1601 prewrite_satisfied,
1602 code_workflow_requested,
1603 ) {
1604 if unmet_prewrite_repair_retry_count
1605 < prewrite_repair_retry_max_attempts()
1606 {
1607 unmet_prewrite_repair_retry_count += 1;
1608 let repair_attempt = unmet_prewrite_repair_retry_count;
1609 let repair_attempts_remaining =
1610 prewrite_repair_retry_max_attempts()
1611 .saturating_sub(repair_attempt);
1612 followup_context = Some(build_prewrite_repair_retry_context(
1613 &offered_tool_preview,
1614 latest_required_tool_failure_kind,
1615 &text,
1616 &requested_prewrite_requirements,
1617 productive_workspace_inspection_total > 0,
1618 productive_concrete_read_total > 0,
1619 productive_web_research_total > 0,
1620 successful_web_research_total > 0,
1621 ));
1622 self.event_bus.publish(EngineEvent::new(
1623 "provider.call.iteration.finish",
1624 json!({
1625 "sessionID": session_id,
1626 "messageID": user_message_id,
1627 "iteration": iteration,
1628 "finishReason": "prewrite_repair_retry",
1629 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1630 "rejectedToolCalls": 0,
1631 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1632 "repair": prewrite_repair_event_payload(
1633 repair_attempt,
1634 repair_attempts_remaining,
1635 &unmet_prewrite_codes,
1636 false,
1637 ),
1638 }),
1639 ));
1640 continue;
1641 }
1642 if !prewrite_gate_waived {
1643 if prewrite_gate_strict_mode() {
1644 self.event_bus.publish(EngineEvent::new(
1647 "prewrite.gate.strict_mode.blocked",
1648 json!({
1649 "sessionID": session_id,
1650 "messageID": user_message_id,
1651 "iteration": iteration,
1652 "unmetCodes": unmet_prewrite_codes,
1653 }),
1654 ));
1655 continue;
1656 }
1657 prewrite_gate_waived = true;
1658 let repair_attempt = unmet_prewrite_repair_retry_count;
1659 let repair_attempts_remaining =
1660 prewrite_repair_retry_max_attempts()
1661 .saturating_sub(repair_attempt);
1662 followup_context = Some(build_prewrite_waived_write_context(
1663 &text,
1664 &unmet_prewrite_codes,
1665 ));
1666 self.event_bus.publish(EngineEvent::new(
1667 "prewrite.gate.waived.write_executed",
1668 json!({
1669 "sessionID": session_id,
1670 "messageID": user_message_id,
1671 "unmetCodes": unmet_prewrite_codes,
1672 }),
1673 ));
1674 self.event_bus.publish(EngineEvent::new(
1675 "provider.call.iteration.finish",
1676 json!({
1677 "sessionID": session_id,
1678 "messageID": user_message_id,
1679 "iteration": iteration,
1680 "finishReason": "prewrite_gate_waived",
1681 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1682 "rejectedToolCalls": 0,
1683 "prewriteGateWaived": true,
1684 "repair": prewrite_repair_event_payload(
1685 repair_attempt,
1686 repair_attempts_remaining,
1687 &unmet_prewrite_codes,
1688 true,
1689 ),
1690 }),
1691 ));
1692 continue;
1693 }
1694 }
1695 latest_required_tool_failure_kind =
1696 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1697 if required_write_retry_count + 1 < strict_write_retry_max_attempts {
1698 required_write_retry_count += 1;
1699 followup_context = Some(build_write_required_retry_context(
1700 &offered_tool_preview,
1701 latest_required_tool_failure_kind,
1702 &text,
1703 &requested_prewrite_requirements,
1704 productive_workspace_inspection_total > 0,
1705 productive_concrete_read_total > 0,
1706 productive_web_research_total > 0,
1707 successful_web_research_total > 0,
1708 ));
1709 self.event_bus.publish(EngineEvent::new(
1710 "provider.call.iteration.finish",
1711 json!({
1712 "sessionID": session_id,
1713 "messageID": user_message_id,
1714 "iteration": iteration,
1715 "finishReason": "required_write_retry",
1716 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1717 "rejectedToolCalls": 0,
1718 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1719 }),
1720 ));
1721 continue;
1722 }
1723 completion = required_tool_mode_unsatisfied_completion(
1724 latest_required_tool_failure_kind,
1725 );
1726 if !required_tool_unsatisfied_emitted {
1727 required_tool_unsatisfied_emitted = true;
1728 self.event_bus.publish(EngineEvent::new(
1729 "tool.mode.required.unsatisfied",
1730 json!({
1731 "sessionID": session_id,
1732 "messageID": user_message_id,
1733 "iteration": iteration,
1734 "selectedToolCount": allowed_tool_names.len(),
1735 "offeredToolsPreview": offered_tool_preview,
1736 "reason": latest_required_tool_failure_kind.code(),
1737 }),
1738 ));
1739 }
1740 self.event_bus.publish(EngineEvent::new(
1741 "provider.call.iteration.finish",
1742 json!({
1743 "sessionID": session_id,
1744 "messageID": user_message_id,
1745 "iteration": iteration,
1746 "finishReason": "required_write_unsatisfied",
1747 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1748 "rejectedToolCalls": 0,
1749 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1750 }),
1751 ));
1752 break;
1753 }
1754 if invalid_tool_args_retry_count < invalid_tool_args_retry_max_attempts() {
1755 if let Some(retry_context) =
1756 build_invalid_tool_args_retry_context_from_outputs(
1757 &outputs,
1758 invalid_tool_args_retry_count,
1759 )
1760 {
1761 invalid_tool_args_retry_count += 1;
1762 followup_context = Some(format!(
1763 "Previous tool call arguments were invalid. {}",
1764 retry_context
1765 ));
1766 self.event_bus.publish(EngineEvent::new(
1767 "provider.call.iteration.finish",
1768 json!({
1769 "sessionID": session_id,
1770 "messageID": user_message_id,
1771 "iteration": iteration,
1772 "finishReason": "invalid_tool_args_retry",
1773 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1774 "rejectedToolCalls": 0,
1775 }),
1776 ));
1777 continue;
1778 }
1779 }
1780 let guard_budget_hit =
1781 outputs.iter().any(|o| is_guard_budget_tool_output(o));
1782 if executed_productive_tool {
1783 let prewrite_gate = evaluate_prewrite_gate(
1784 requested_write_required,
1785 &requested_prewrite_requirements,
1786 PrewriteProgress {
1787 productive_write_tool_calls_total,
1788 productive_workspace_inspection_total,
1789 productive_concrete_read_total,
1790 productive_web_research_total,
1791 successful_web_research_total,
1792 required_write_retry_count,
1793 unmet_prewrite_repair_retry_count,
1794 prewrite_gate_waived,
1795 },
1796 );
1797 let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1798 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1799 if requested_write_required
1800 && productive_write_tool_calls_total > 0
1801 && requested_prewrite_requirements.repair_on_unmet_requirements
1802 && unmet_prewrite_repair_retry_count
1803 < prewrite_repair_retry_max_attempts()
1804 && !prewrite_satisfied
1805 {
1806 unmet_prewrite_repair_retry_count += 1;
1807 let repair_attempt = unmet_prewrite_repair_retry_count;
1808 let repair_attempts_remaining =
1809 prewrite_repair_retry_max_attempts()
1810 .saturating_sub(repair_attempt);
1811 followup_context = Some(build_prewrite_repair_retry_context(
1812 &offered_tool_preview,
1813 latest_required_tool_failure_kind,
1814 &text,
1815 &requested_prewrite_requirements,
1816 productive_workspace_inspection_total > 0,
1817 productive_concrete_read_total > 0,
1818 productive_web_research_total > 0,
1819 successful_web_research_total > 0,
1820 ));
1821 self.event_bus.publish(EngineEvent::new(
1822 "provider.call.iteration.finish",
1823 json!({
1824 "sessionID": session_id,
1825 "messageID": user_message_id,
1826 "iteration": iteration,
1827 "finishReason": "prewrite_repair_retry",
1828 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1829 "rejectedToolCalls": 0,
1830 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1831 "repair": prewrite_repair_event_payload(
1832 repair_attempt,
1833 repair_attempts_remaining,
1834 &unmet_prewrite_codes,
1835 false,
1836 ),
1837 }),
1838 ));
1839 continue;
1840 }
1841 followup_context = Some(format!(
1842 "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
1843 summarize_tool_outputs(&outputs)
1844 ));
1845 self.event_bus.publish(EngineEvent::new(
1846 "provider.call.iteration.finish",
1847 json!({
1848 "sessionID": session_id,
1849 "messageID": user_message_id,
1850 "iteration": iteration,
1851 "finishReason": "tool_followup",
1852 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1853 "rejectedToolCalls": 0,
1854 }),
1855 ));
1856 continue;
1857 }
1858 if guard_budget_hit {
1859 completion = summarize_guard_budget_outputs(&outputs)
1860 .unwrap_or_else(|| {
1861 "This run hit the per-run tool guard budget, so tool execution was paused to avoid retries. Send a new message to start a fresh run.".to_string()
1862 });
1863 } else if duplicate_signature_hit_in_cycle {
1864 completion = summarize_duplicate_signature_outputs(&outputs)
1865 .unwrap_or_else(|| {
1866 "This run paused because the same tool call kept repeating. Rephrase the request or provide a different command target and retry.".to_string()
1867 });
1868 } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
1869 completion = summary;
1870 } else {
1871 completion.clear();
1872 }
1873 self.event_bus.publish(EngineEvent::new(
1874 "provider.call.iteration.finish",
1875 json!({
1876 "sessionID": session_id,
1877 "messageID": user_message_id,
1878 "iteration": iteration,
1879 "finishReason": "tool_summary",
1880 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1881 "rejectedToolCalls": 0,
1882 }),
1883 ));
1884 break;
1885 } else if matches!(requested_tool_mode, ToolMode::Required) {
1886 latest_required_tool_failure_kind = classify_required_tool_failure(
1887 &outputs,
1888 saw_tool_call_candidate,
1889 accepted_tool_calls_in_cycle,
1890 provider_tool_parse_failed,
1891 rejected_tool_call_in_cycle,
1892 );
1893 }
1894 }
1895
1896 if let Some(usage) = provider_usage {
1897 self.event_bus.publish(EngineEvent::new(
1898 "provider.usage",
1899 json!({
1900 "sessionID": session_id,
1901 "messageID": user_message_id,
1902 "promptTokens": usage.prompt_tokens,
1903 "completionTokens": usage.completion_tokens,
1904 "totalTokens": usage.total_tokens,
1905 }),
1906 ));
1907 }
1908
1909 if matches!(requested_tool_mode, ToolMode::Required)
1910 && productive_tool_calls_total == 0
1911 {
1912 if requested_write_required
1913 && required_write_retry_count > 0
1914 && productive_write_tool_calls_total == 0
1915 && !is_write_invalid_args_failure_kind(latest_required_tool_failure_kind)
1916 {
1917 latest_required_tool_failure_kind =
1918 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1919 }
1920 if requested_write_required
1921 && required_write_retry_count + 1 < strict_write_retry_max_attempts
1922 {
1923 required_write_retry_count += 1;
1924 followup_context = Some(build_write_required_retry_context(
1925 &offered_tool_preview,
1926 latest_required_tool_failure_kind,
1927 &text,
1928 &requested_prewrite_requirements,
1929 productive_workspace_inspection_total > 0,
1930 productive_concrete_read_total > 0,
1931 productive_web_research_total > 0,
1932 successful_web_research_total > 0,
1933 ));
1934 continue;
1935 }
1936 let progress_made_in_cycle = productive_workspace_inspection_total > 0
1937 || productive_concrete_read_total > 0
1938 || productive_web_research_total > 0
1939 || successful_web_research_total > 0;
1940 if should_retry_nonproductive_required_tool_cycle(
1941 requested_write_required,
1942 false,
1943 progress_made_in_cycle,
1944 required_tool_retry_count,
1945 ) {
1946 required_tool_retry_count += 1;
1947 followup_context = Some(build_required_tool_retry_context_for_task(
1948 &offered_tool_preview,
1949 latest_required_tool_failure_kind,
1950 &text,
1951 ));
1952 continue;
1953 }
1954 completion = required_tool_mode_unsatisfied_completion(
1955 latest_required_tool_failure_kind,
1956 );
1957 if !required_tool_unsatisfied_emitted {
1958 required_tool_unsatisfied_emitted = true;
1959 self.event_bus.publish(EngineEvent::new(
1960 "tool.mode.required.unsatisfied",
1961 json!({
1962 "sessionID": session_id,
1963 "messageID": user_message_id,
1964 "iteration": iteration,
1965 "selectedToolCount": allowed_tool_names.len(),
1966 "offeredToolsPreview": offered_tool_preview,
1967 "reason": latest_required_tool_failure_kind.code(),
1968 }),
1969 ));
1970 }
1971 self.event_bus.publish(EngineEvent::new(
1972 "provider.call.iteration.finish",
1973 json!({
1974 "sessionID": session_id,
1975 "messageID": user_message_id,
1976 "iteration": iteration,
1977 "finishReason": "required_tool_unsatisfied",
1978 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1979 "rejectedToolCalls": 0,
1980 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1981 }),
1982 ));
1983 } else {
1984 if completion.trim().is_empty()
1985 && !last_tool_outputs.is_empty()
1986 && requested_write_required
1987 && empty_completion_retry_count == 0
1988 {
1989 empty_completion_retry_count += 1;
1990 followup_context = Some(build_empty_completion_retry_context(
1991 &offered_tool_preview,
1992 &text,
1993 &requested_prewrite_requirements,
1994 productive_workspace_inspection_total > 0,
1995 productive_concrete_read_total > 0,
1996 productive_web_research_total > 0,
1997 successful_web_research_total > 0,
1998 ));
1999 self.event_bus.publish(EngineEvent::new(
2000 "provider.call.iteration.finish",
2001 json!({
2002 "sessionID": session_id,
2003 "messageID": user_message_id,
2004 "iteration": iteration,
2005 "finishReason": "empty_completion_retry",
2006 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2007 "rejectedToolCalls": 0,
2008 }),
2009 ));
2010 continue;
2011 }
2012 let prewrite_gate = evaluate_prewrite_gate(
2013 requested_write_required,
2014 &requested_prewrite_requirements,
2015 PrewriteProgress {
2016 productive_write_tool_calls_total,
2017 productive_workspace_inspection_total,
2018 productive_concrete_read_total,
2019 productive_web_research_total,
2020 successful_web_research_total,
2021 required_write_retry_count,
2022 unmet_prewrite_repair_retry_count,
2023 prewrite_gate_waived,
2024 },
2025 );
2026 if should_start_prewrite_repair_before_first_write(
2027 requested_prewrite_requirements.repair_on_unmet_requirements,
2028 productive_write_tool_calls_total,
2029 prewrite_gate.prewrite_satisfied,
2030 code_workflow_requested,
2031 ) && !prewrite_gate_waived
2032 {
2033 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
2034 if unmet_prewrite_repair_retry_count < prewrite_repair_retry_max_attempts()
2035 {
2036 unmet_prewrite_repair_retry_count += 1;
2037 let repair_attempt = unmet_prewrite_repair_retry_count;
2038 let repair_attempts_remaining =
2039 prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
2040 followup_context = Some(build_prewrite_repair_retry_context(
2041 &offered_tool_preview,
2042 latest_required_tool_failure_kind,
2043 &text,
2044 &requested_prewrite_requirements,
2045 productive_workspace_inspection_total > 0,
2046 productive_concrete_read_total > 0,
2047 productive_web_research_total > 0,
2048 successful_web_research_total > 0,
2049 ));
2050 self.event_bus.publish(EngineEvent::new(
2051 "provider.call.iteration.finish",
2052 json!({
2053 "sessionID": session_id,
2054 "messageID": user_message_id,
2055 "iteration": iteration,
2056 "finishReason": "prewrite_repair_retry",
2057 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2058 "rejectedToolCalls": 0,
2059 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
2060 "repair": prewrite_repair_event_payload(
2061 repair_attempt,
2062 repair_attempts_remaining,
2063 &unmet_prewrite_codes,
2064 false,
2065 ),
2066 }),
2067 ));
2068 continue;
2069 }
2070 if prewrite_gate_strict_mode() {
2071 self.event_bus.publish(EngineEvent::new(
2072 "prewrite.gate.strict_mode.blocked",
2073 json!({
2074 "sessionID": session_id,
2075 "messageID": user_message_id,
2076 "iteration": iteration,
2077 "unmetCodes": unmet_prewrite_codes,
2078 }),
2079 ));
2080 continue;
2081 }
2082 prewrite_gate_waived = true;
2083 let repair_attempt = unmet_prewrite_repair_retry_count;
2084 let repair_attempts_remaining =
2085 prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
2086 followup_context = Some(build_prewrite_waived_write_context(
2087 &text,
2088 &unmet_prewrite_codes,
2089 ));
2090 self.event_bus.publish(EngineEvent::new(
2091 "prewrite.gate.waived.write_executed",
2092 json!({
2093 "sessionID": session_id,
2094 "messageID": user_message_id,
2095 "unmetCodes": unmet_prewrite_codes,
2096 }),
2097 ));
2098 self.event_bus.publish(EngineEvent::new(
2099 "provider.call.iteration.finish",
2100 json!({
2101 "sessionID": session_id,
2102 "messageID": user_message_id,
2103 "iteration": iteration,
2104 "finishReason": "prewrite_gate_waived",
2105 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2106 "rejectedToolCalls": 0,
2107 "prewriteGateWaived": true,
2108 "repair": prewrite_repair_event_payload(
2109 repair_attempt,
2110 repair_attempts_remaining,
2111 &unmet_prewrite_codes,
2112 true,
2113 ),
2114 }),
2115 ));
2116 continue;
2117 }
2118 if prewrite_gate_waived
2119 && requested_write_required
2120 && productive_write_tool_calls_total == 0
2121 && required_write_retry_count + 1 < strict_write_retry_max_attempts
2122 {
2123 required_write_retry_count += 1;
2124 followup_context = Some(build_write_required_retry_context(
2125 &offered_tool_preview,
2126 latest_required_tool_failure_kind,
2127 &text,
2128 &requested_prewrite_requirements,
2129 productive_workspace_inspection_total > 0,
2130 productive_concrete_read_total > 0,
2131 productive_web_research_total > 0,
2132 successful_web_research_total > 0,
2133 ));
2134 self.event_bus.publish(EngineEvent::new(
2135 "provider.call.iteration.finish",
2136 json!({
2137 "sessionID": session_id,
2138 "messageID": user_message_id,
2139 "iteration": iteration,
2140 "finishReason": "waived_write_retry",
2141 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2142 "rejectedToolCalls": 0,
2143 }),
2144 ));
2145 continue;
2146 }
2147 self.event_bus.publish(EngineEvent::new(
2148 "provider.call.iteration.finish",
2149 json!({
2150 "sessionID": session_id,
2151 "messageID": user_message_id,
2152 "iteration": iteration,
2153 "finishReason": "provider_completion",
2154 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2155 "rejectedToolCalls": 0,
2156 }),
2157 ));
2158 }
2159 break;
2160 }
2161 if matches!(requested_tool_mode, ToolMode::Required) && productive_tool_calls_total == 0
2162 {
2163 completion =
2164 required_tool_mode_unsatisfied_completion(latest_required_tool_failure_kind);
2165 if !required_tool_unsatisfied_emitted {
2166 self.event_bus.publish(EngineEvent::new(
2167 "tool.mode.required.unsatisfied",
2168 json!({
2169 "sessionID": session_id,
2170 "messageID": user_message_id,
2171 "selectedToolCount": tool_call_counts.len(),
2172 "reason": latest_required_tool_failure_kind.code(),
2173 }),
2174 ));
2175 }
2176 }
2177 if completion.trim().is_empty()
2178 && !last_tool_outputs.is_empty()
2179 && requested_write_required
2180 && productive_write_tool_calls_total > 0
2181 {
2182 let final_prewrite_satisfied = evaluate_prewrite_gate(
2183 requested_write_required,
2184 &requested_prewrite_requirements,
2185 PrewriteProgress {
2186 productive_write_tool_calls_total,
2187 productive_workspace_inspection_total,
2188 productive_concrete_read_total,
2189 productive_web_research_total,
2190 successful_web_research_total,
2191 required_write_retry_count,
2192 unmet_prewrite_repair_retry_count,
2193 prewrite_gate_waived,
2194 },
2195 )
2196 .prewrite_satisfied;
2197 completion = synthesize_artifact_write_completion_from_tool_state(
2198 &text,
2199 final_prewrite_satisfied,
2200 prewrite_gate_waived,
2201 );
2202 }
2203 if completion.trim().is_empty()
2204 && !last_tool_outputs.is_empty()
2205 && should_generate_post_tool_final_narrative(
2206 requested_tool_mode,
2207 productive_tool_calls_total,
2208 )
2209 {
2210 if let Some(narrative) = self
2211 .generate_final_narrative_without_tools(
2212 &session_id,
2213 &active_agent,
2214 Some(provider_id.as_str()),
2215 Some(model_id_value.as_str()),
2216 cancel.clone(),
2217 &last_tool_outputs,
2218 )
2219 .await
2220 {
2221 completion = narrative;
2222 }
2223 }
2224 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
2225 let preview = last_tool_outputs
2226 .iter()
2227 .take(3)
2228 .map(|o| truncate_text(o, 240))
2229 .collect::<Vec<_>>()
2230 .join("\n");
2231 completion = format!(
2232 "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
2233 preview
2234 );
2235 }
2236 if completion.trim().is_empty() {
2237 completion =
2238 "I couldn't produce a final response for that run. Please retry your request."
2239 .to_string();
2240 }
2241 if email_delivery_requested && !email_action_executed {
2246 let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
2247 .to_string();
2248 if let Some(note) = latest_email_action_note.as_ref() {
2249 fallback.push_str("\n\nLast email tool status: ");
2250 fallback.push_str(note);
2251 }
2252 fallback.push_str(
2253 "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
2254 );
2255 completion = fallback;
2256 }
2257 completion = strip_model_control_markers(&completion);
2258 truncate_text(&completion, 16_000)
2259 };
2260 emit_event(
2261 Level::INFO,
2262 ProcessKind::Engine,
2263 ObservabilityEvent {
2264 event: "provider.call.finish",
2265 component: "engine.loop",
2266 correlation_id: correlation_ref,
2267 session_id: Some(&session_id),
2268 run_id: None,
2269 message_id: Some(&user_message_id),
2270 provider_id: Some(provider_id.as_str()),
2271 model_id,
2272 status: Some("ok"),
2273 error_code: None,
2274 detail: Some("provider stream complete"),
2275 },
2276 );
2277 if active_agent.name.eq_ignore_ascii_case("plan") {
2278 emit_plan_todo_fallback(
2279 self.storage.clone(),
2280 &self.event_bus,
2281 &session_id,
2282 &user_message_id,
2283 &completion,
2284 )
2285 .await;
2286 let todos_after_fallback = self.storage.get_todos(&session_id).await;
2287 if todos_after_fallback.is_empty() && !question_tool_used {
2288 emit_plan_question_fallback(
2289 self.storage.clone(),
2290 &self.event_bus,
2291 &session_id,
2292 &user_message_id,
2293 &completion,
2294 )
2295 .await;
2296 }
2297 }
2298 if cancel.is_cancelled() {
2299 self.event_bus.publish(EngineEvent::new(
2300 "session.status",
2301 json!({"sessionID": session_id, "status":"cancelled"}),
2302 ));
2303 self.cancellations.remove(&session_id).await;
2304 return Ok(());
2305 }
2306 let assistant = Message::new(
2307 MessageRole::Assistant,
2308 vec![MessagePart::Text {
2309 text: completion.clone(),
2310 }],
2311 );
2312 let assistant_message_id = assistant.id.clone();
2313 self.storage.append_message(&session_id, assistant).await?;
2314 let final_part = WireMessagePart::text(
2315 &session_id,
2316 &assistant_message_id,
2317 truncate_text(&completion, 16_000),
2318 );
2319 self.event_bus.publish(EngineEvent::new(
2320 "message.part.updated",
2321 json!({"part": final_part}),
2322 ));
2323 self.event_bus.publish(EngineEvent::new(
2324 "session.updated",
2325 json!({"sessionID": session_id, "status":"idle"}),
2326 ));
2327 self.event_bus.publish(EngineEvent::new(
2328 "session.status",
2329 json!({"sessionID": session_id, "status":"idle"}),
2330 ));
2331 self.cancellations.remove(&session_id).await;
2332 Ok(())
2333 }
2334
2335 pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
2336 self.providers.default_complete(&prompt).await
2337 }
2338
2339 pub async fn run_oneshot_for_provider(
2340 &self,
2341 prompt: String,
2342 provider_id: Option<&str>,
2343 ) -> anyhow::Result<String> {
2344 self.providers
2345 .complete_for_provider(provider_id, &prompt, None)
2346 .await
2347 }
2348
2349 #[allow(clippy::too_many_arguments)]
2350 async fn execute_tool_with_permission(
2351 &self,
2352 session_id: &str,
2353 message_id: &str,
2354 tool: String,
2355 args: Value,
2356 initial_tool_call_id: Option<String>,
2357 equipped_skills: Option<&[String]>,
2358 latest_user_text: &str,
2359 write_required: bool,
2360 latest_assistant_context: Option<&str>,
2361 cancel: CancellationToken,
2362 ) -> anyhow::Result<Option<String>> {
2363 let tool = normalize_tool_name(&tool);
2364 let raw_args = args.clone();
2365 let publish_tool_effect = |tool_call_id: Option<&str>,
2366 phase: ToolEffectLedgerPhase,
2367 status: ToolEffectLedgerStatus,
2368 args: &Value,
2369 metadata: Option<&Value>,
2370 output: Option<&str>,
2371 error: Option<&str>| {
2372 self.event_bus
2373 .publish(tool_effect_ledger_event(build_tool_effect_ledger_record(
2374 session_id,
2375 message_id,
2376 tool_call_id,
2377 &tool,
2378 phase,
2379 status,
2380 args,
2381 metadata,
2382 output,
2383 error,
2384 )));
2385 };
2386 let normalized = normalize_tool_args_with_mode(
2387 &tool,
2388 args,
2389 latest_user_text,
2390 latest_assistant_context.unwrap_or_default(),
2391 if write_required {
2392 WritePathRecoveryMode::OutputTargetOnly
2393 } else {
2394 WritePathRecoveryMode::Heuristic
2395 },
2396 );
2397 let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
2398 let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
2399 self.event_bus.publish(EngineEvent::new(
2400 "tool.args.normalized",
2401 json!({
2402 "sessionID": session_id,
2403 "messageID": message_id,
2404 "tool": tool,
2405 "argsSource": normalized.args_source,
2406 "argsIntegrity": normalized.args_integrity,
2407 "rawArgsState": normalized.raw_args_state.as_str(),
2408 "rawArgsPreview": raw_args_preview,
2409 "normalizedArgsPreview": normalized_args_preview,
2410 "query": normalized.query,
2411 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2412 "requestID": Value::Null
2413 }),
2414 ));
2415 if normalized.args_integrity == "recovered" {
2416 self.event_bus.publish(EngineEvent::new(
2417 "tool.args.recovered",
2418 json!({
2419 "sessionID": session_id,
2420 "messageID": message_id,
2421 "tool": tool,
2422 "argsSource": normalized.args_source,
2423 "rawArgsPreview": raw_args_preview,
2424 "normalizedArgsPreview": normalized_args_preview,
2425 "query": normalized.query,
2426 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2427 "requestID": Value::Null
2428 }),
2429 ));
2430 }
2431 if normalized.missing_terminal {
2432 let missing_reason = normalized
2433 .missing_terminal_reason
2434 .clone()
2435 .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
2436 let latest_user_preview = truncate_text(latest_user_text, 500);
2437 let latest_assistant_preview =
2438 truncate_text(latest_assistant_context.unwrap_or_default(), 500);
2439 self.event_bus.publish(EngineEvent::new(
2440 "tool.args.missing_terminal",
2441 json!({
2442 "sessionID": session_id,
2443 "messageID": message_id,
2444 "tool": tool,
2445 "argsSource": normalized.args_source,
2446 "argsIntegrity": normalized.args_integrity,
2447 "rawArgsState": normalized.raw_args_state.as_str(),
2448 "requestID": Value::Null,
2449 "error": missing_reason,
2450 "rawArgsPreview": raw_args_preview,
2451 "normalizedArgsPreview": normalized_args_preview,
2452 "latestUserPreview": latest_user_preview,
2453 "latestAssistantPreview": latest_assistant_preview,
2454 }),
2455 ));
2456 if tool == "write" {
2457 tracing::warn!(
2458 session_id = %session_id,
2459 message_id = %message_id,
2460 tool = %tool,
2461 reason = %missing_reason,
2462 args_source = %normalized.args_source,
2463 args_integrity = %normalized.args_integrity,
2464 raw_args_state = %normalized.raw_args_state.as_str(),
2465 raw_args = %raw_args_preview,
2466 normalized_args = %normalized_args_preview,
2467 latest_user = %latest_user_preview,
2468 latest_assistant = %latest_assistant_preview,
2469 "write tool arguments missing terminal field"
2470 );
2471 }
2472 let best_effort_args = persisted_failed_tool_args(&raw_args, &normalized.args);
2473 let mut failed_part = WireMessagePart::tool_result(
2474 session_id,
2475 message_id,
2476 tool.clone(),
2477 Some(best_effort_args),
2478 json!(null),
2479 );
2480 failed_part.state = Some("failed".to_string());
2481 let surfaced_reason =
2482 provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
2483 .unwrap_or_else(|| missing_reason.clone());
2484 failed_part.error = Some(surfaced_reason.clone());
2485 self.event_bus.publish(EngineEvent::new(
2486 "message.part.updated",
2487 json!({"part": failed_part}),
2488 ));
2489 publish_tool_effect(
2490 None,
2491 ToolEffectLedgerPhase::Outcome,
2492 ToolEffectLedgerStatus::Blocked,
2493 &normalized.args,
2494 None,
2495 None,
2496 Some(&surfaced_reason),
2497 );
2498 return Ok(Some(surfaced_reason));
2499 }
2500
2501 let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
2502 Ok(args) => args,
2503 Err(message) => {
2504 publish_tool_effect(
2505 None,
2506 ToolEffectLedgerPhase::Outcome,
2507 ToolEffectLedgerStatus::Blocked,
2508 &raw_args,
2509 None,
2510 None,
2511 Some(&message),
2512 );
2513 return Ok(Some(message));
2514 }
2515 };
2516 if let Some(allowed_tools) = self
2517 .session_allowed_tools
2518 .read()
2519 .await
2520 .get(session_id)
2521 .cloned()
2522 {
2523 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
2524 let reason = format!("Tool `{tool}` is not allowed for this run.");
2525 publish_tool_effect(
2526 None,
2527 ToolEffectLedgerPhase::Outcome,
2528 ToolEffectLedgerStatus::Blocked,
2529 &args,
2530 None,
2531 None,
2532 Some(&reason),
2533 );
2534 return Ok(Some(reason));
2535 }
2536 }
2537 if let Some(hook) = self.tool_policy_hook.read().await.clone() {
2538 let decision = hook
2539 .evaluate_tool(ToolPolicyContext {
2540 session_id: session_id.to_string(),
2541 message_id: message_id.to_string(),
2542 tool: tool.clone(),
2543 args: args.clone(),
2544 })
2545 .await?;
2546 if !decision.allowed {
2547 let reason = decision
2548 .reason
2549 .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
2550 let mut blocked_part = WireMessagePart::tool_result(
2551 session_id,
2552 message_id,
2553 tool.clone(),
2554 Some(args.clone()),
2555 json!(null),
2556 );
2557 blocked_part.state = Some("failed".to_string());
2558 blocked_part.error = Some(reason.clone());
2559 self.event_bus.publish(EngineEvent::new(
2560 "message.part.updated",
2561 json!({"part": blocked_part}),
2562 ));
2563 publish_tool_effect(
2564 None,
2565 ToolEffectLedgerPhase::Outcome,
2566 ToolEffectLedgerStatus::Blocked,
2567 &args,
2568 None,
2569 None,
2570 Some(&reason),
2571 );
2572 return Ok(Some(reason));
2573 }
2574 }
2575 let mut tool_call_id: Option<String> = initial_tool_call_id;
2576 if let Some(violation) = self
2577 .workspace_sandbox_violation(session_id, &tool, &args)
2578 .await
2579 {
2580 let mut blocked_part = WireMessagePart::tool_result(
2581 session_id,
2582 message_id,
2583 tool.clone(),
2584 Some(args.clone()),
2585 json!(null),
2586 );
2587 blocked_part.state = Some("failed".to_string());
2588 blocked_part.error = Some(violation.clone());
2589 self.event_bus.publish(EngineEvent::new(
2590 "message.part.updated",
2591 json!({"part": blocked_part}),
2592 ));
2593 publish_tool_effect(
2594 tool_call_id.as_deref(),
2595 ToolEffectLedgerPhase::Outcome,
2596 ToolEffectLedgerStatus::Blocked,
2597 &args,
2598 None,
2599 None,
2600 Some(&violation),
2601 );
2602 return Ok(Some(violation));
2603 }
2604 let rule = self
2605 .plugins
2606 .permission_override(&tool)
2607 .await
2608 .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
2609 if matches!(rule, PermissionAction::Deny) {
2610 let reason = format!("Permission denied for tool `{tool}` by policy.");
2611 publish_tool_effect(
2612 tool_call_id.as_deref(),
2613 ToolEffectLedgerPhase::Outcome,
2614 ToolEffectLedgerStatus::Blocked,
2615 &args,
2616 None,
2617 None,
2618 Some(&reason),
2619 );
2620 return Ok(Some(reason));
2621 }
2622
2623 let mut effective_args = args.clone();
2624 if matches!(rule, PermissionAction::Ask) {
2625 let auto_approve_permissions = self
2626 .session_auto_approve_permissions
2627 .read()
2628 .await
2629 .get(session_id)
2630 .copied()
2631 .unwrap_or(false);
2632 if auto_approve_permissions {
2633 if normalized.args_integrity == "recovered" && is_workspace_write_tool(&tool) {
2638 tracing::warn!(
2639 session_id = %session_id,
2640 message_id = %message_id,
2641 tool = %tool,
2642 args_source = %normalized.args_source,
2643 "auto-approve granted for mutating tool with recovered args; verify intent"
2644 );
2645 self.event_bus.publish(EngineEvent::new(
2646 "tool.args.recovered_write_auto_approved",
2647 json!({
2648 "sessionID": session_id,
2649 "messageID": message_id,
2650 "tool": tool,
2651 "argsSource": normalized.args_source,
2652 "argsIntegrity": normalized.args_integrity,
2653 }),
2654 ));
2655 }
2656 self.event_bus.publish(EngineEvent::new(
2657 "permission.auto_approved",
2658 json!({
2659 "sessionID": session_id,
2660 "messageID": message_id,
2661 "tool": tool,
2662 }),
2663 ));
2664 effective_args = args;
2665 } else {
2666 let pending = self
2667 .permissions
2668 .ask_for_session_with_context(
2669 Some(session_id),
2670 &tool,
2671 args.clone(),
2672 Some(crate::PermissionArgsContext {
2673 args_source: normalized.args_source.clone(),
2674 args_integrity: normalized.args_integrity.clone(),
2675 query: normalized.query.clone(),
2676 }),
2677 )
2678 .await;
2679 let mut pending_part = WireMessagePart::tool_invocation(
2680 session_id,
2681 message_id,
2682 tool.clone(),
2683 args.clone(),
2684 );
2685 pending_part.id = Some(pending.id.clone());
2686 tool_call_id = Some(pending.id.clone());
2687 pending_part.state = Some("pending".to_string());
2688 self.event_bus.publish(EngineEvent::new(
2689 "message.part.updated",
2690 json!({"part": pending_part}),
2691 ));
2692 let reply = self
2693 .permissions
2694 .wait_for_reply_with_timeout(
2695 &pending.id,
2696 cancel.clone(),
2697 Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2698 )
2699 .await;
2700 let (reply, timed_out) = reply;
2701 if cancel.is_cancelled() {
2702 return Ok(None);
2703 }
2704 if timed_out {
2705 let timeout_ms = permission_wait_timeout_ms();
2706 self.event_bus.publish(EngineEvent::new(
2707 "permission.wait.timeout",
2708 json!({
2709 "sessionID": session_id,
2710 "messageID": message_id,
2711 "tool": tool,
2712 "requestID": pending.id,
2713 "timeoutMs": timeout_ms,
2714 }),
2715 ));
2716 let mut timeout_part = WireMessagePart::tool_result(
2717 session_id,
2718 message_id,
2719 tool.clone(),
2720 Some(args.clone()),
2721 json!(null),
2722 );
2723 timeout_part.id = Some(pending.id);
2724 timeout_part.state = Some("failed".to_string());
2725 timeout_part.error = Some(format!(
2726 "Permission request timed out after {} ms",
2727 timeout_ms
2728 ));
2729 self.event_bus.publish(EngineEvent::new(
2730 "message.part.updated",
2731 json!({"part": timeout_part}),
2732 ));
2733 let timeout_reason = format!(
2734 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2735 );
2736 publish_tool_effect(
2737 tool_call_id.as_deref(),
2738 ToolEffectLedgerPhase::Outcome,
2739 ToolEffectLedgerStatus::Blocked,
2740 &args,
2741 None,
2742 None,
2743 Some(&timeout_reason),
2744 );
2745 return Ok(Some(format!(
2746 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2747 )));
2748 }
2749 let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2750 if !approved {
2751 let mut denied_part = WireMessagePart::tool_result(
2752 session_id,
2753 message_id,
2754 tool.clone(),
2755 Some(args.clone()),
2756 json!(null),
2757 );
2758 denied_part.id = Some(pending.id);
2759 denied_part.state = Some("denied".to_string());
2760 denied_part.error = Some("Permission denied by user".to_string());
2761 self.event_bus.publish(EngineEvent::new(
2762 "message.part.updated",
2763 json!({"part": denied_part}),
2764 ));
2765 let denied_reason = format!("Permission denied for tool `{tool}` by user.");
2766 publish_tool_effect(
2767 tool_call_id.as_deref(),
2768 ToolEffectLedgerPhase::Outcome,
2769 ToolEffectLedgerStatus::Blocked,
2770 &args,
2771 None,
2772 None,
2773 Some(&denied_reason),
2774 );
2775 return Ok(Some(format!(
2776 "Permission denied for tool `{tool}` by user."
2777 )));
2778 }
2779 effective_args = args;
2780 }
2781 }
2782
2783 let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2784 let session = self.storage.get_session(session_id).await;
2785 if let (Some(obj), Some(session)) = (args.as_object_mut(), session.as_ref()) {
2786 obj.insert(
2787 "__session_id".to_string(),
2788 Value::String(session_id.to_string()),
2789 );
2790 if let Some(project_id) = session.project_id.clone() {
2791 obj.insert(
2792 "__project_id".to_string(),
2793 Value::String(project_id.clone()),
2794 );
2795 if project_id.starts_with("channel-public::") {
2796 obj.insert(
2797 "__memory_max_visible_scope".to_string(),
2798 Value::String("project".to_string()),
2799 );
2800 }
2801 }
2802 }
2803 let tool_context = self.resolve_tool_execution_context(session_id).await;
2804 if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2805 args = rewrite_workspace_alias_tool_args(&tool, args, workspace_root);
2806 if let Some(obj) = args.as_object_mut() {
2807 obj.insert(
2808 "__workspace_root".to_string(),
2809 Value::String(workspace_root.clone()),
2810 );
2811 obj.insert(
2812 "__effective_cwd".to_string(),
2813 Value::String(effective_cwd.clone()),
2814 );
2815 obj.insert(
2816 "__session_id".to_string(),
2817 Value::String(session_id.to_string()),
2818 );
2819 if let Some(project_id) = project_id.clone() {
2820 obj.insert("__project_id".to_string(), Value::String(project_id));
2821 }
2822 }
2823 tracing::info!(
2824 "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2825 session_id,
2826 tool,
2827 workspace_root,
2828 effective_cwd,
2829 project_id.clone().unwrap_or_default()
2830 );
2831 }
2832 let mut invoke_part =
2833 WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2834 if let Some(call_id) = tool_call_id.clone() {
2835 invoke_part.id = Some(call_id);
2836 }
2837 let invoke_part_id = invoke_part.id.clone();
2838 self.event_bus.publish(EngineEvent::new(
2839 "message.part.updated",
2840 json!({"part": invoke_part}),
2841 ));
2842 let args_for_side_events = args.clone();
2843 let mutation_checkpoint = prepare_mutation_checkpoint(&tool, &args_for_side_events);
2844 let progress_sink: SharedToolProgressSink = std::sync::Arc::new(EngineToolProgressSink {
2845 event_bus: self.event_bus.clone(),
2846 session_id: session_id.to_string(),
2847 message_id: message_id.to_string(),
2848 tool_call_id: invoke_part_id.clone(),
2849 source_tool: tool.clone(),
2850 });
2851 publish_tool_effect(
2852 invoke_part_id.as_deref(),
2853 ToolEffectLedgerPhase::Invocation,
2854 ToolEffectLedgerStatus::Started,
2855 &args_for_side_events,
2856 None,
2857 None,
2858 None,
2859 );
2860 let publish_mutation_checkpoint =
2861 |tool_call_id: Option<&str>, outcome: MutationCheckpointOutcome| {
2862 if let Some(baseline) = mutation_checkpoint.as_ref() {
2863 self.event_bus.publish(mutation_checkpoint_event(
2864 finalize_mutation_checkpoint_record(
2865 session_id,
2866 message_id,
2867 tool_call_id,
2868 baseline,
2869 outcome,
2870 ),
2871 ));
2872 }
2873 };
2874 if tool == "spawn_agent" {
2875 let hook = self.spawn_agent_hook.read().await.clone();
2876 if let Some(hook) = hook {
2877 let spawned = hook
2878 .spawn_agent(SpawnAgentToolContext {
2879 session_id: session_id.to_string(),
2880 message_id: message_id.to_string(),
2881 tool_call_id: invoke_part_id.clone(),
2882 args: args_for_side_events.clone(),
2883 })
2884 .await?;
2885 let output = self.plugins.transform_tool_output(spawned.output).await;
2886 let output = truncate_text(&output, 16_000);
2887 emit_tool_side_events(
2888 self.storage.clone(),
2889 &self.event_bus,
2890 ToolSideEventContext {
2891 session_id,
2892 message_id,
2893 tool: &tool,
2894 args: &args_for_side_events,
2895 metadata: &spawned.metadata,
2896 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2897 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2898 },
2899 )
2900 .await;
2901 let mut result_part = WireMessagePart::tool_result(
2902 session_id,
2903 message_id,
2904 tool.clone(),
2905 Some(args_for_side_events.clone()),
2906 json!(output.clone()),
2907 );
2908 result_part.id = invoke_part_id.clone();
2909 self.event_bus.publish(EngineEvent::new(
2910 "message.part.updated",
2911 json!({"part": result_part}),
2912 ));
2913 publish_tool_effect(
2914 invoke_part_id.as_deref(),
2915 ToolEffectLedgerPhase::Outcome,
2916 ToolEffectLedgerStatus::Succeeded,
2917 &args_for_side_events,
2918 Some(&spawned.metadata),
2919 Some(&output),
2920 None,
2921 );
2922 publish_mutation_checkpoint(
2923 invoke_part_id.as_deref(),
2924 MutationCheckpointOutcome::Succeeded,
2925 );
2926 return Ok(Some(truncate_text(
2927 &format!("Tool `{tool}` result:\n{output}"),
2928 16_000,
2929 )));
2930 }
2931 let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2932 let mut failed_part = WireMessagePart::tool_result(
2933 session_id,
2934 message_id,
2935 tool.clone(),
2936 Some(args_for_side_events.clone()),
2937 json!(null),
2938 );
2939 failed_part.id = invoke_part_id.clone();
2940 failed_part.state = Some("failed".to_string());
2941 failed_part.error = Some(output.to_string());
2942 self.event_bus.publish(EngineEvent::new(
2943 "message.part.updated",
2944 json!({"part": failed_part}),
2945 ));
2946 publish_tool_effect(
2947 invoke_part_id.as_deref(),
2948 ToolEffectLedgerPhase::Outcome,
2949 ToolEffectLedgerStatus::Failed,
2950 &args_for_side_events,
2951 None,
2952 None,
2953 Some(output),
2954 );
2955 publish_mutation_checkpoint(
2956 invoke_part_id.as_deref(),
2957 MutationCheckpointOutcome::Failed,
2958 );
2959 return Ok(Some(output.to_string()));
2960 }
2961 if tool == "batch" {
2968 let allowed_tools = self
2969 .session_allowed_tools
2970 .read()
2971 .await
2972 .get(session_id)
2973 .cloned()
2974 .unwrap_or_default();
2975
2976 let ctx_workspace_root = args
2978 .get("__workspace_root")
2979 .and_then(|v| v.as_str())
2980 .map(ToString::to_string);
2981 let ctx_effective_cwd = args
2982 .get("__effective_cwd")
2983 .and_then(|v| v.as_str())
2984 .map(ToString::to_string);
2985 let ctx_session_id = args
2986 .get("__session_id")
2987 .and_then(|v| v.as_str())
2988 .map(ToString::to_string);
2989 let ctx_project_id = args
2990 .get("__project_id")
2991 .and_then(|v| v.as_str())
2992 .map(ToString::to_string);
2993
2994 let raw_calls = args
2996 .get("tool_calls")
2997 .and_then(|v| v.as_array())
2998 .cloned()
2999 .unwrap_or_default();
3000
3001 let mut governed_calls: Vec<Value> = Vec::new();
3002 for mut call in raw_calls {
3003 let (sub_tool, mut sub_args) = {
3004 let obj = match call.as_object() {
3005 Some(o) => o,
3006 None => {
3007 governed_calls.push(call);
3008 continue;
3009 }
3010 };
3011 let tool_raw = non_empty_string_at(obj, "tool")
3012 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3013 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3014 .or_else(|| non_empty_string_at(obj, "name"));
3015 let sub_tool = match tool_raw {
3016 Some(t) => normalize_tool_name(t),
3017 None => {
3018 governed_calls.push(call);
3019 continue;
3020 }
3021 };
3022 let sub_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3023 (sub_tool, sub_args)
3024 };
3025
3026 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &sub_tool) {
3028 if let Some(obj) = call.as_object_mut() {
3030 obj.insert(
3031 "_blocked".to_string(),
3032 Value::String(format!(
3033 "batch sub-call skipped: tool `{sub_tool}` is not in the allowed list for this run"
3034 )),
3035 );
3036 }
3037 governed_calls.push(call);
3038 continue;
3039 }
3040
3041 if let Some(violation) = self
3043 .workspace_sandbox_violation(session_id, &sub_tool, &sub_args)
3044 .await
3045 {
3046 if let Some(obj) = call.as_object_mut() {
3047 obj.insert(
3048 "_blocked".to_string(),
3049 Value::String(format!("batch sub-call skipped: {violation}")),
3050 );
3051 }
3052 governed_calls.push(call);
3053 continue;
3054 }
3055
3056 if let Some(sub_obj) = sub_args.as_object_mut() {
3058 if let Some(ref v) = ctx_workspace_root {
3059 sub_obj
3060 .entry("__workspace_root")
3061 .or_insert_with(|| Value::String(v.clone()));
3062 }
3063 if let Some(ref v) = ctx_effective_cwd {
3064 sub_obj
3065 .entry("__effective_cwd")
3066 .or_insert_with(|| Value::String(v.clone()));
3067 }
3068 if let Some(ref v) = ctx_session_id {
3069 sub_obj
3070 .entry("__session_id")
3071 .or_insert_with(|| Value::String(v.clone()));
3072 }
3073 if let Some(ref v) = ctx_project_id {
3074 sub_obj
3075 .entry("__project_id")
3076 .or_insert_with(|| Value::String(v.clone()));
3077 }
3078 }
3079
3080 if let Some(obj) = call.as_object_mut() {
3082 obj.insert("args".to_string(), sub_args);
3083 }
3084 governed_calls.push(call);
3085 }
3086
3087 if let Some(obj) = args.as_object_mut() {
3089 obj.insert("tool_calls".to_string(), Value::Array(governed_calls));
3090 }
3091 }
3092 let result = match self
3093 .execute_tool_with_timeout(&tool, args, cancel.clone(), Some(progress_sink))
3094 .await
3095 {
3096 Ok(result) => result,
3097 Err(err) => {
3098 let err_text = err.to_string();
3099 if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
3100 let timeout_ms = tool_exec_timeout_ms();
3101 let timeout_output = format!(
3102 "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
3103 );
3104 let mut failed_part = WireMessagePart::tool_result(
3105 session_id,
3106 message_id,
3107 tool.clone(),
3108 Some(args_for_side_events.clone()),
3109 json!(null),
3110 );
3111 failed_part.id = invoke_part_id.clone();
3112 failed_part.state = Some("failed".to_string());
3113 failed_part.error = Some(timeout_output.clone());
3114 self.event_bus.publish(EngineEvent::new(
3115 "message.part.updated",
3116 json!({"part": failed_part}),
3117 ));
3118 publish_tool_effect(
3119 invoke_part_id.as_deref(),
3120 ToolEffectLedgerPhase::Outcome,
3121 ToolEffectLedgerStatus::Failed,
3122 &args_for_side_events,
3123 None,
3124 None,
3125 Some(&timeout_output),
3126 );
3127 publish_mutation_checkpoint(
3128 invoke_part_id.as_deref(),
3129 MutationCheckpointOutcome::Failed,
3130 );
3131 return Ok(Some(timeout_output));
3132 }
3133 if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
3134 self.event_bus.publish(EngineEvent::new(
3135 "mcp.auth.required",
3136 json!({
3137 "sessionID": session_id,
3138 "messageID": message_id,
3139 "tool": tool.clone(),
3140 "server": auth.server,
3141 "authorizationUrl": auth.authorization_url,
3142 "message": auth.message,
3143 "challengeId": auth.challenge_id
3144 }),
3145 ));
3146 let auth_output = format!(
3147 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3148 tool, auth.message, auth.authorization_url
3149 );
3150 let mut result_part = WireMessagePart::tool_result(
3151 session_id,
3152 message_id,
3153 tool.clone(),
3154 Some(args_for_side_events.clone()),
3155 json!(auth_output.clone()),
3156 );
3157 result_part.id = invoke_part_id.clone();
3158 self.event_bus.publish(EngineEvent::new(
3159 "message.part.updated",
3160 json!({"part": result_part}),
3161 ));
3162 publish_tool_effect(
3163 invoke_part_id.as_deref(),
3164 ToolEffectLedgerPhase::Outcome,
3165 ToolEffectLedgerStatus::Blocked,
3166 &args_for_side_events,
3167 None,
3168 Some(&auth_output),
3169 Some(&auth.message),
3170 );
3171 publish_mutation_checkpoint(
3172 invoke_part_id.as_deref(),
3173 MutationCheckpointOutcome::Blocked,
3174 );
3175 return Ok(Some(truncate_text(
3176 &format!("Tool `{tool}` result:\n{auth_output}"),
3177 16_000,
3178 )));
3179 }
3180 let mut failed_part = WireMessagePart::tool_result(
3181 session_id,
3182 message_id,
3183 tool.clone(),
3184 Some(args_for_side_events.clone()),
3185 json!(null),
3186 );
3187 failed_part.id = invoke_part_id.clone();
3188 failed_part.state = Some("failed".to_string());
3189 failed_part.error = Some(err_text.clone());
3190 self.event_bus.publish(EngineEvent::new(
3191 "message.part.updated",
3192 json!({"part": failed_part}),
3193 ));
3194 publish_tool_effect(
3195 invoke_part_id.as_deref(),
3196 ToolEffectLedgerPhase::Outcome,
3197 ToolEffectLedgerStatus::Failed,
3198 &args_for_side_events,
3199 None,
3200 None,
3201 Some(&err_text),
3202 );
3203 publish_mutation_checkpoint(
3204 invoke_part_id.as_deref(),
3205 MutationCheckpointOutcome::Failed,
3206 );
3207 return Err(err);
3208 }
3209 };
3210 if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3211 let event_name = if auth.pending && auth.blocked {
3212 "mcp.auth.pending"
3213 } else {
3214 "mcp.auth.required"
3215 };
3216 self.event_bus.publish(EngineEvent::new(
3217 event_name,
3218 json!({
3219 "sessionID": session_id,
3220 "messageID": message_id,
3221 "tool": tool.clone(),
3222 "server": auth.server,
3223 "authorizationUrl": auth.authorization_url,
3224 "message": auth.message,
3225 "challengeId": auth.challenge_id,
3226 "pending": auth.pending,
3227 "blocked": auth.blocked,
3228 "retryAfterMs": auth.retry_after_ms
3229 }),
3230 ));
3231 }
3232 emit_tool_side_events(
3233 self.storage.clone(),
3234 &self.event_bus,
3235 ToolSideEventContext {
3236 session_id,
3237 message_id,
3238 tool: &tool,
3239 args: &args_for_side_events,
3240 metadata: &result.metadata,
3241 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
3242 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
3243 },
3244 )
3245 .await;
3246 let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3247 if auth.pending && auth.blocked {
3248 let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
3249 format!(
3250 "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
3251 tool, auth.message, auth.authorization_url, retry_after_secs
3252 )
3253 } else {
3254 format!(
3255 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3256 tool, auth.message, auth.authorization_url
3257 )
3258 }
3259 } else {
3260 self.plugins.transform_tool_output(result.output).await
3261 };
3262 let output = truncate_text(&output, 16_000);
3263 let mut result_part = WireMessagePart::tool_result(
3264 session_id,
3265 message_id,
3266 tool.clone(),
3267 Some(args_for_side_events.clone()),
3268 json!(output.clone()),
3269 );
3270 result_part.id = invoke_part_id.clone();
3271 self.event_bus.publish(EngineEvent::new(
3272 "message.part.updated",
3273 json!({"part": result_part}),
3274 ));
3275 publish_tool_effect(
3276 invoke_part_id.as_deref(),
3277 ToolEffectLedgerPhase::Outcome,
3278 ToolEffectLedgerStatus::Succeeded,
3279 &args_for_side_events,
3280 Some(&result.metadata),
3281 Some(&output),
3282 None,
3283 );
3284 publish_mutation_checkpoint(
3285 invoke_part_id.as_deref(),
3286 MutationCheckpointOutcome::Succeeded,
3287 );
3288 Ok(Some(truncate_text(
3289 &format!("Tool `{tool}` result:\n{output}"),
3290 16_000,
3291 )))
3292 }
3293
3294 async fn execute_tool_with_timeout(
3295 &self,
3296 tool: &str,
3297 args: Value,
3298 cancel: CancellationToken,
3299 progress: Option<SharedToolProgressSink>,
3300 ) -> anyhow::Result<tandem_types::ToolResult> {
3301 let timeout_ms = tool_exec_timeout_ms() as u64;
3302 match tokio::time::timeout(
3303 Duration::from_millis(timeout_ms),
3304 self.tools
3305 .execute_with_cancel_and_progress(tool, args, cancel, progress),
3306 )
3307 .await
3308 {
3309 Ok(result) => result,
3310 Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
3311 }
3312 }
3313
3314 async fn find_recent_matching_user_message_id(
3315 &self,
3316 session_id: &str,
3317 text: &str,
3318 ) -> Option<String> {
3319 let session = self.storage.get_session(session_id).await?;
3320 let last = session.messages.last()?;
3321 if !matches!(last.role, MessageRole::User) {
3322 return None;
3323 }
3324 let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
3325 if age_ms > 10_000 {
3326 return None;
3327 }
3328 let last_text = last
3329 .parts
3330 .iter()
3331 .filter_map(|part| match part {
3332 MessagePart::Text { text } => Some(text.clone()),
3333 _ => None,
3334 })
3335 .collect::<Vec<_>>()
3336 .join("\n");
3337 if last_text == text {
3338 return Some(last.id.clone());
3339 }
3340 None
3341 }
3342
3343 async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
3344 let Some(mut session) = self.storage.get_session(session_id).await else {
3345 return;
3346 };
3347 if !title_needs_repair(&session.title) {
3348 return;
3349 }
3350
3351 let first_user_text = session.messages.iter().find_map(|message| {
3352 if !matches!(message.role, MessageRole::User) {
3353 return None;
3354 }
3355 message.parts.iter().find_map(|part| match part {
3356 MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
3357 _ => None,
3358 })
3359 });
3360
3361 let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
3362 let Some(title) = derive_session_title_from_prompt(&source, 60) else {
3363 return;
3364 };
3365
3366 session.title = title;
3367 session.time.updated = Utc::now();
3368 let _ = self.storage.save_session(session).await;
3369 }
3370
3371 async fn workspace_sandbox_violation(
3372 &self,
3373 session_id: &str,
3374 tool: &str,
3375 args: &Value,
3376 ) -> Option<String> {
3377 if self.workspace_override_active(session_id).await {
3378 return None;
3379 }
3380 if is_mcp_tool_name(tool) {
3384 if let Some(server) = mcp_server_from_tool_name(tool) {
3385 if is_mcp_sandbox_exempt_server(server) {
3386 return None;
3387 }
3388 }
3389 let candidate_paths = extract_tool_candidate_paths(tool, args);
3390 if candidate_paths.is_empty() {
3391 return None;
3393 }
3394 let session = self.storage.get_session(session_id).await?;
3396 let workspace = session
3397 .workspace_root
3398 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3399 let workspace_path = PathBuf::from(&workspace);
3400 if let Some(sensitive) = candidate_paths.iter().find(|path| {
3401 let raw = Path::new(path);
3402 let resolved = if raw.is_absolute() {
3403 raw.to_path_buf()
3404 } else {
3405 workspace_path.join(raw)
3406 };
3407 is_sensitive_path_candidate(&resolved)
3408 }) {
3409 return Some(format!(
3410 "Sandbox blocked MCP tool `{tool}` path `{sensitive}` (sensitive path policy)."
3411 ));
3412 }
3413 let outside = candidate_paths.iter().find(|path| {
3414 let raw = Path::new(path);
3415 let resolved = if raw.is_absolute() {
3416 raw.to_path_buf()
3417 } else {
3418 workspace_path.join(raw)
3419 };
3420 !crate::is_within_workspace_root(&resolved, &workspace_path)
3421 })?;
3422 return Some(format!(
3423 "Sandbox blocked MCP tool `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3424 ));
3425 }
3426 let session = self.storage.get_session(session_id).await?;
3427 let workspace = session
3428 .workspace_root
3429 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3430 let workspace_path = PathBuf::from(&workspace);
3431 let candidate_paths = extract_tool_candidate_paths(tool, args);
3432 if candidate_paths.is_empty() {
3433 if is_shell_tool_name(tool) {
3434 if let Some(command) = extract_shell_command(args) {
3435 if shell_command_targets_sensitive_path(&command) {
3436 return Some(format!(
3437 "Sandbox blocked `{tool}` command targeting sensitive paths."
3438 ));
3439 }
3440 }
3441 }
3442 return None;
3443 }
3444 if let Some(sensitive) = candidate_paths.iter().find(|path| {
3445 let raw = Path::new(path);
3446 let resolved = if raw.is_absolute() {
3447 raw.to_path_buf()
3448 } else {
3449 workspace_path.join(raw)
3450 };
3451 is_sensitive_path_candidate(&resolved)
3452 }) {
3453 return Some(format!(
3454 "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
3455 ));
3456 }
3457
3458 let outside = candidate_paths.iter().find(|path| {
3459 let raw = Path::new(path);
3460 let resolved = if raw.is_absolute() {
3461 raw.to_path_buf()
3462 } else {
3463 workspace_path.join(raw)
3464 };
3465 !crate::is_within_workspace_root(&resolved, &workspace_path)
3466 })?;
3467 Some(format!(
3468 "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3469 ))
3470 }
3471
3472 async fn resolve_tool_execution_context(
3473 &self,
3474 session_id: &str,
3475 ) -> Option<(String, String, Option<String>)> {
3476 let session = self.storage.get_session(session_id).await?;
3477 let workspace_root = session
3478 .workspace_root
3479 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3480 let effective_cwd = if session.directory.trim().is_empty()
3481 || session.directory.trim() == "."
3482 {
3483 workspace_root.clone()
3484 } else {
3485 crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
3486 };
3487 let project_id = session
3488 .project_id
3489 .clone()
3490 .or_else(|| crate::workspace_project_id(&workspace_root));
3491 Some((workspace_root, effective_cwd, project_id))
3492 }
3493
3494 async fn workspace_override_active(&self, session_id: &str) -> bool {
3495 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
3496 let mut overrides = self.workspace_overrides.write().await;
3497 let expired: Vec<String> = overrides
3499 .iter()
3500 .filter_map(|(id, &exp)| if exp <= now { Some(id.clone()) } else { None })
3501 .collect();
3502 overrides.retain(|_, expires_at| *expires_at > now);
3503 drop(overrides);
3504 for expired_id in expired {
3505 self.event_bus.publish(EngineEvent::new(
3506 "workspace.override.expired",
3507 json!({ "sessionID": expired_id }),
3508 ));
3509 }
3510 self.workspace_overrides
3511 .read()
3512 .await
3513 .get(session_id)
3514 .map(|expires_at| *expires_at > now)
3515 .unwrap_or(false)
3516 }
3517
3518 async fn generate_final_narrative_without_tools(
3519 &self,
3520 session_id: &str,
3521 active_agent: &AgentDefinition,
3522 provider_hint: Option<&str>,
3523 model_id: Option<&str>,
3524 cancel: CancellationToken,
3525 tool_outputs: &[String],
3526 ) -> Option<String> {
3527 if cancel.is_cancelled() {
3528 return None;
3529 }
3530 let mut messages = load_chat_history(
3531 self.storage.clone(),
3532 session_id,
3533 ChatHistoryProfile::Standard,
3534 )
3535 .await;
3536 let mut system_parts = vec![tandem_runtime_system_prompt(
3537 &self.host_runtime_context,
3538 &[],
3539 )];
3540 if let Some(system) = active_agent.system_prompt.as_ref() {
3541 system_parts.push(system.clone());
3542 }
3543 messages.insert(
3544 0,
3545 ChatMessage {
3546 role: "system".to_string(),
3547 content: system_parts.join("\n\n"),
3548 attachments: Vec::new(),
3549 },
3550 );
3551 messages.push(ChatMessage {
3552 role: "user".to_string(),
3553 content: build_post_tool_final_narrative_prompt(tool_outputs),
3554 attachments: Vec::new(),
3555 });
3556 let stream = self
3557 .providers
3558 .stream_for_provider(
3559 provider_hint,
3560 model_id,
3561 messages,
3562 ToolMode::None,
3563 None,
3564 cancel.clone(),
3565 )
3566 .await
3567 .ok()?;
3568 tokio::pin!(stream);
3569 let mut completion = String::new();
3570 while let Some(chunk) = stream.next().await {
3571 if cancel.is_cancelled() {
3572 return None;
3573 }
3574 match chunk {
3575 Ok(StreamChunk::TextDelta(delta)) => {
3576 let delta = strip_model_control_markers(&delta);
3577 if !delta.trim().is_empty() {
3578 completion.push_str(&delta);
3579 }
3580 }
3581 Ok(StreamChunk::Done { .. }) => break,
3582 Ok(_) => {}
3583 Err(_) => return None,
3584 }
3585 }
3586 let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
3587 if completion.trim().is_empty() {
3588 None
3589 } else {
3590 Some(completion)
3591 }
3592 }
3593}
3594
3595fn resolve_model_route(
3596 request_model: Option<&ModelSpec>,
3597 session_model: Option<&ModelSpec>,
3598) -> Option<(String, String)> {
3599 fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
3600 let provider_id = spec.provider_id.trim();
3601 let model_id = spec.model_id.trim();
3602 if provider_id.is_empty() || model_id.is_empty() {
3603 return None;
3604 }
3605 Some((provider_id.to_string(), model_id.to_string()))
3606 }
3607
3608 request_model
3609 .and_then(normalize)
3610 .or_else(|| session_model.and_then(normalize))
3611}
3612
3613fn strip_model_control_markers(input: &str) -> String {
3614 let mut cleaned = input.to_string();
3615 for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
3616 if cleaned.contains(marker) {
3617 cleaned = cleaned.replace(marker, "");
3618 }
3619 }
3620 cleaned
3621}
3622
3623fn truncate_text(input: &str, max_len: usize) -> String {
3624 if input.len() <= max_len {
3625 return input.to_string();
3626 }
3627 let mut out = input[..max_len].to_string();
3628 out.push_str("...<truncated>");
3629 out
3630}
3631
3632fn build_post_tool_final_narrative_prompt(tool_outputs: &[String]) -> String {
3633 format!(
3634 "Tool observations:\n{}\n\nUsing the tool observations and the existing conversation instructions, provide the required final answer now. Preserve any requested output contract, required JSON structure, required handoff fields, and required final status object from the original task. Do not call tools. Do not stop at a tool summary if the task requires a structured final response.",
3635 summarize_tool_outputs(tool_outputs)
3636 )
3637}
3638
3639fn provider_error_code(error_text: &str) -> &'static str {
3640 let lower = error_text.to_lowercase();
3641 if lower.contains("invalid_function_parameters")
3642 || lower.contains("array schema missing items")
3643 || lower.contains("tool schema")
3644 {
3645 return "TOOL_SCHEMA_INVALID";
3646 }
3647 if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
3648 {
3649 return "RATE_LIMIT_EXCEEDED";
3650 }
3651 if lower.contains("context length")
3652 || lower.contains("max tokens")
3653 || lower.contains("token limit")
3654 {
3655 return "CONTEXT_LENGTH_EXCEEDED";
3656 }
3657 if lower.contains("unauthorized")
3658 || lower.contains("authentication")
3659 || lower.contains("401")
3660 || lower.contains("403")
3661 {
3662 return "AUTHENTICATION_ERROR";
3663 }
3664 if lower.contains("timeout") || lower.contains("timed out") {
3665 return "TIMEOUT";
3666 }
3667 if lower.contains("server error")
3668 || lower.contains("500")
3669 || lower.contains("502")
3670 || lower.contains("503")
3671 || lower.contains("504")
3672 {
3673 return "PROVIDER_SERVER_ERROR";
3674 }
3675 "PROVIDER_REQUEST_FAILED"
3676}
3677
3678fn normalize_tool_name(name: &str) -> String {
3679 let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
3680 for prefix in [
3681 "default_api:",
3682 "default_api.",
3683 "functions.",
3684 "function.",
3685 "tools.",
3686 "tool.",
3687 "builtin:",
3688 "builtin.",
3689 ] {
3690 if let Some(rest) = normalized.strip_prefix(prefix) {
3691 let trimmed = rest.trim();
3692 if !trimmed.is_empty() {
3693 normalized = trimmed.to_string();
3694 break;
3695 }
3696 }
3697 }
3698 match normalized.as_str() {
3699 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
3700 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
3701 other => other.to_string(),
3702 }
3703}
3704
3705fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
3706 let mut parts = tool_name.split('.');
3707 let prefix = parts.next()?;
3708 if prefix != "mcp" {
3709 return None;
3710 }
3711 parts.next().filter(|server| !server.is_empty())
3712}
3713
3714fn requires_web_research_prompt(input: &str) -> bool {
3715 let lower = input.to_ascii_lowercase();
3716 [
3717 "research",
3718 "top news",
3719 "today's news",
3720 "todays news",
3721 "with links",
3722 "latest headlines",
3723 "current events",
3724 ]
3725 .iter()
3726 .any(|needle| lower.contains(needle))
3727}
3728
3729fn requires_email_delivery_prompt(input: &str) -> bool {
3730 let lower = input.to_ascii_lowercase();
3731 (lower.contains("send") && lower.contains("email"))
3732 || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
3733 || lower.contains("email to")
3734}
3735
3736fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
3737 schemas.iter().any(|schema| {
3738 let name = normalize_tool_name(&schema.name);
3739 name == "websearch" || name == "webfetch" || name == "webfetch_html"
3740 })
3741}
3742
3743fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
3744 schemas
3745 .iter()
3746 .map(|schema| normalize_tool_name(&schema.name))
3747 .any(|name| tool_name_looks_like_email_action(&name))
3748}
3749
3750fn tool_name_looks_like_email_action(name: &str) -> bool {
3751 let normalized = normalize_tool_name(name);
3752 if normalized.starts_with("mcp.") {
3753 return normalized.contains("gmail")
3754 || normalized.contains("mail")
3755 || normalized.contains("email");
3756 }
3757 normalized.contains("mail") || normalized.contains("email")
3758}
3759
3760fn completion_claims_email_sent(text: &str) -> bool {
3761 let lower = text.to_ascii_lowercase();
3762 let has_email_marker = lower.contains("email status")
3763 || lower.contains("emailed")
3764 || lower.contains("email sent")
3765 || lower.contains("sent to");
3766 has_email_marker
3767 && (lower.contains("sent")
3768 || lower.contains("delivered")
3769 || lower.contains("has been sent"))
3770}
3771
3772fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
3773 let Some(obj) = args.as_object() else {
3774 return Vec::new();
3775 };
3776 let mcp_path_keys: &[&str] = &[
3778 "path",
3779 "file_path",
3780 "filePath",
3781 "filepath",
3782 "filename",
3783 "directory",
3784 "dir",
3785 "cwd",
3786 "target",
3787 "source",
3788 "dest",
3789 "destination",
3790 ];
3791 let keys: &[&str] = if tool.starts_with("mcp.") {
3792 mcp_path_keys
3793 } else {
3794 match tool {
3795 "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
3796 "glob" => &["pattern"],
3797 "lsp" => &["filePath", "path"],
3798 "bash" => &["cwd"],
3799 "apply_patch" => &[],
3800 _ => &["path", "cwd"],
3801 }
3802 };
3803 keys.iter()
3804 .filter_map(|key| obj.get(*key))
3805 .filter_map(|value| value.as_str())
3806 .filter(|s| {
3807 let t = s.trim();
3808 !t.is_empty()
3810 && (t.starts_with('/')
3811 || t.starts_with('.')
3812 || t.starts_with('~')
3813 || t.contains('/'))
3814 })
3815 .map(ToString::to_string)
3816 .collect()
3817}
3818
3819fn is_mcp_sandbox_exempt_server(server_name: &str) -> bool {
3823 let Ok(raw) = std::env::var("TANDEM_MCP_SANDBOX_EXEMPT_SERVERS") else {
3824 return false;
3825 };
3826 raw.split(',')
3827 .any(|s| s.trim().eq_ignore_ascii_case(server_name))
3828}
3829
3830fn is_mcp_tool_name(tool_name: &str) -> bool {
3831 let normalized = normalize_tool_name(tool_name);
3832 normalized == "mcp_list" || normalized.starts_with("mcp.")
3833}
3834
3835fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
3836 let target = normalize_tool_name(tool_name);
3837 match agent.tools.as_ref() {
3838 None => true,
3839 Some(list) => {
3840 let normalized = list
3841 .iter()
3842 .map(|t| normalize_tool_name(t))
3843 .collect::<Vec<_>>();
3844 any_policy_matches(&normalized, &target)
3845 }
3846 }
3847}
3848
3849fn enforce_skill_scope(
3850 tool_name: &str,
3851 args: Value,
3852 equipped_skills: Option<&[String]>,
3853) -> Result<Value, String> {
3854 if normalize_tool_name(tool_name) != "skill" {
3855 return Ok(args);
3856 }
3857 let Some(configured) = equipped_skills else {
3858 return Ok(args);
3859 };
3860
3861 let mut allowed = configured
3862 .iter()
3863 .map(|s| s.trim().to_string())
3864 .filter(|s| !s.is_empty())
3865 .collect::<Vec<_>>();
3866 if allowed
3867 .iter()
3868 .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
3869 {
3870 return Ok(args);
3871 }
3872 allowed.sort();
3873 allowed.dedup();
3874 if allowed.is_empty() {
3875 return Err("No skills are equipped for this agent.".to_string());
3876 }
3877
3878 let requested = args
3879 .get("name")
3880 .and_then(|v| v.as_str())
3881 .map(|v| v.trim().to_string())
3882 .unwrap_or_default();
3883 if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
3884 return Err(format!(
3885 "Skill '{}' is not equipped for this agent. Equipped skills: {}",
3886 requested,
3887 allowed.join(", ")
3888 ));
3889 }
3890
3891 let mut out = if let Some(obj) = args.as_object() {
3892 Value::Object(obj.clone())
3893 } else {
3894 json!({})
3895 };
3896 if let Some(obj) = out.as_object_mut() {
3897 obj.insert("allowed_skills".to_string(), json!(allowed));
3898 }
3899 Ok(out)
3900}
3901
3902fn is_read_only_tool(tool_name: &str) -> bool {
3903 matches!(
3904 normalize_tool_name(tool_name).as_str(),
3905 "glob"
3906 | "read"
3907 | "grep"
3908 | "search"
3909 | "codesearch"
3910 | "list"
3911 | "ls"
3912 | "lsp"
3913 | "websearch"
3914 | "webfetch"
3915 | "webfetch_html"
3916 )
3917}
3918
3919fn is_workspace_write_tool(tool_name: &str) -> bool {
3920 matches!(
3921 normalize_tool_name(tool_name).as_str(),
3922 "write" | "edit" | "apply_patch"
3923 )
3924}
3925
3926fn should_start_prewrite_repair_before_first_write(
3927 repair_on_unmet_requirements: bool,
3928 productive_write_tool_calls_total: usize,
3929 prewrite_satisfied: bool,
3930 code_workflow_requested: bool,
3931) -> bool {
3932 (repair_on_unmet_requirements || code_workflow_requested)
3933 && productive_write_tool_calls_total == 0
3934 && !prewrite_satisfied
3935}
3936
3937fn is_batch_wrapper_tool_name(name: &str) -> bool {
3938 matches!(
3939 normalize_tool_name(name).as_str(),
3940 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
3941 )
3942}
3943
3944fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
3945 obj.get(key)
3946 .and_then(|v| v.as_str())
3947 .map(str::trim)
3948 .filter(|s| !s.is_empty())
3949}
3950
3951fn nested_non_empty_string_at<'a>(
3952 obj: &'a Map<String, Value>,
3953 parent: &str,
3954 key: &str,
3955) -> Option<&'a str> {
3956 obj.get(parent)
3957 .and_then(|v| v.as_object())
3958 .and_then(|nested| nested.get(key))
3959 .and_then(|v| v.as_str())
3960 .map(str::trim)
3961 .filter(|s| !s.is_empty())
3962}
3963
3964fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
3965 let calls = args
3966 .get("tool_calls")
3967 .and_then(|v| v.as_array())
3968 .cloned()
3969 .unwrap_or_default();
3970 calls
3971 .into_iter()
3972 .filter_map(|call| {
3973 let obj = call.as_object()?;
3974 let tool_raw = non_empty_string_at(obj, "tool")
3975 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3976 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
3977 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
3978 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
3979 let name_raw = non_empty_string_at(obj, "name")
3980 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3981 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
3982 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
3983 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
3984 let effective = match (tool_raw, name_raw) {
3985 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
3986 (Some(t), _) => t,
3987 (None, Some(n)) => n,
3988 (None, None) => return None,
3989 };
3990 let normalized = normalize_tool_name(effective);
3991 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3992 Some((normalized, call_args))
3993 })
3994 .collect()
3995}
3996
3997fn is_read_only_batch_call(args: &Value) -> bool {
3998 let calls = extract_batch_calls(args);
3999 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
4000}
4001
4002fn batch_tool_signature(args: &Value) -> Option<String> {
4003 let calls = extract_batch_calls(args);
4004 if calls.is_empty() {
4005 return None;
4006 }
4007 let parts = calls
4008 .into_iter()
4009 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
4010 .collect::<Vec<_>>();
4011 Some(format!("batch:{}", parts.join("|")))
4012}
4013
4014fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
4015 let normalized_tool = normalize_tool_name(tool_name);
4016 if normalized_tool == "batch" && is_non_productive_batch_output(output) {
4017 return false;
4018 }
4019 if is_auth_required_tool_output(output) {
4020 return false;
4021 }
4022 if normalized_tool == "glob" {
4023 return true;
4024 }
4025 let Some(result_body) = extract_tool_result_body(output) else {
4026 return false;
4027 };
4028 !is_non_productive_tool_result_body(result_body)
4029}
4030
4031fn is_successful_web_research_output(tool_name: &str, output: &str) -> bool {
4032 if !is_web_research_tool(tool_name) {
4033 return false;
4034 }
4035 let Some(result_body) = extract_tool_result_body(output) else {
4036 return false;
4037 };
4038 if is_non_productive_tool_result_body(result_body) {
4039 return false;
4040 }
4041 let lower = result_body.to_ascii_lowercase();
4042 !(lower.contains("search timed out")
4043 || lower.contains("timed out")
4044 || lower.contains("no results received")
4045 || lower.contains("no search results")
4046 || lower.contains("no relevant results"))
4047}
4048
4049fn extract_tool_result_body(output: &str) -> Option<&str> {
4050 let trimmed = output.trim();
4051 let rest = trimmed.strip_prefix("Tool `")?;
4052 let (_, result_body) = rest.split_once("` result:")?;
4053 Some(result_body.trim())
4054}
4055
4056fn is_non_productive_tool_result_body(output: &str) -> bool {
4057 let trimmed = output.trim();
4058 if trimmed.is_empty() {
4059 return true;
4060 }
4061 let lower = trimmed.to_ascii_lowercase();
4062 lower.starts_with("unknown tool:")
4063 || lower.contains("call skipped")
4064 || lower.contains("guard budget exceeded")
4065 || lower.contains("invalid_function_parameters")
4066 || is_terminal_tool_error_reason(trimmed)
4067}
4068
4069fn is_terminal_tool_error_reason(output: &str) -> bool {
4070 let first_line = output.lines().next().unwrap_or_default().trim();
4071 if first_line.is_empty() {
4072 return false;
4073 }
4074 let normalized = first_line.to_ascii_uppercase();
4075 matches!(
4076 normalized.as_str(),
4077 "TOOL_ARGUMENTS_MISSING"
4078 | "WEBSEARCH_QUERY_MISSING"
4079 | "BASH_COMMAND_MISSING"
4080 | "FILE_PATH_MISSING"
4081 | "WRITE_CONTENT_MISSING"
4082 | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
4083 | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
4084 | "WEBFETCH_URL_MISSING"
4085 | "PACK_BUILDER_PLAN_ID_MISSING"
4086 | "PACK_BUILDER_GOAL_MISSING"
4087 | "PROVIDER_REQUEST_FAILED"
4088 | "AUTHENTICATION_ERROR"
4089 | "CONTEXT_LENGTH_EXCEEDED"
4090 | "RATE_LIMIT_EXCEEDED"
4091 ) || normalized.ends_with("_MISSING")
4092 || normalized.ends_with("_ERROR")
4093}
4094
4095fn is_non_productive_batch_output(output: &str) -> bool {
4096 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
4097 return false;
4098 };
4099 let Some(items) = value.as_array() else {
4100 return false;
4101 };
4102 if items.is_empty() {
4103 return true;
4104 }
4105 items.iter().all(|item| {
4106 let text = item
4107 .get("output")
4108 .and_then(|v| v.as_str())
4109 .map(str::trim)
4110 .unwrap_or_default()
4111 .to_ascii_lowercase();
4112 text.is_empty()
4113 || text.starts_with("unknown tool:")
4114 || text.contains("call skipped")
4115 || text.contains("guard budget exceeded")
4116 })
4117}
4118
4119fn is_auth_required_tool_output(output: &str) -> bool {
4120 let lower = output.to_ascii_lowercase();
4121 (lower.contains("authorization required")
4122 || lower.contains("requires authorization")
4123 || lower.contains("authorization pending"))
4124 && (lower.contains("authorize here") || lower.contains("http"))
4125}
4126
4127#[derive(Debug, Clone)]
4128struct McpAuthRequiredMetadata {
4129 challenge_id: String,
4130 authorization_url: String,
4131 message: String,
4132 server: Option<String>,
4133 pending: bool,
4134 blocked: bool,
4135 retry_after_ms: Option<u64>,
4136}
4137
4138fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
4139 let auth = metadata.get("mcpAuth")?;
4140 if !auth
4141 .get("required")
4142 .and_then(|v| v.as_bool())
4143 .unwrap_or(false)
4144 {
4145 return None;
4146 }
4147 let authorization_url = auth
4148 .get("authorizationUrl")
4149 .and_then(|v| v.as_str())
4150 .map(str::trim)
4151 .filter(|v| !v.is_empty())?
4152 .to_string();
4153 let message = auth
4154 .get("message")
4155 .and_then(|v| v.as_str())
4156 .map(str::trim)
4157 .filter(|v| !v.is_empty())
4158 .unwrap_or("This tool requires authorization before it can run.")
4159 .to_string();
4160 let challenge_id = auth
4161 .get("challengeId")
4162 .and_then(|v| v.as_str())
4163 .map(str::trim)
4164 .filter(|v| !v.is_empty())
4165 .unwrap_or("unknown")
4166 .to_string();
4167 let server = metadata
4168 .get("server")
4169 .and_then(|v| v.as_str())
4170 .map(str::trim)
4171 .filter(|v| !v.is_empty())
4172 .map(ToString::to_string);
4173 let pending = auth
4174 .get("pending")
4175 .and_then(|v| v.as_bool())
4176 .unwrap_or(false);
4177 let blocked = auth
4178 .get("blocked")
4179 .and_then(|v| v.as_bool())
4180 .unwrap_or(false);
4181 let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
4182 Some(McpAuthRequiredMetadata {
4183 challenge_id,
4184 authorization_url,
4185 message,
4186 server,
4187 pending,
4188 blocked,
4189 retry_after_ms,
4190 })
4191}
4192
4193fn extract_mcp_auth_required_from_error_text(
4194 tool_name: &str,
4195 error_text: &str,
4196) -> Option<McpAuthRequiredMetadata> {
4197 let lower = error_text.to_ascii_lowercase();
4198 let auth_hint = lower.contains("authorization")
4199 || lower.contains("oauth")
4200 || lower.contains("invalid oauth token")
4201 || lower.contains("requires authorization");
4202 if !auth_hint {
4203 return None;
4204 }
4205 let authorization_url = find_first_url(error_text)?;
4206 let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
4207 let server = tool_name
4208 .strip_prefix("mcp.")
4209 .and_then(|rest| rest.split('.').next())
4210 .filter(|s| !s.is_empty())
4211 .map(ToString::to_string);
4212 Some(McpAuthRequiredMetadata {
4213 challenge_id,
4214 authorization_url,
4215 message: "This integration requires authorization before this action can run.".to_string(),
4216 server,
4217 pending: false,
4218 blocked: false,
4219 retry_after_ms: None,
4220 })
4221}
4222
4223fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
4224 if outputs.is_empty()
4225 || !outputs
4226 .iter()
4227 .all(|output| is_auth_required_tool_output(output))
4228 {
4229 return None;
4230 }
4231 let mut auth_lines = outputs
4232 .iter()
4233 .filter_map(|output| {
4234 let trimmed = output.trim();
4235 if trimmed.is_empty() {
4236 None
4237 } else {
4238 Some(trimmed.to_string())
4239 }
4240 })
4241 .collect::<Vec<_>>();
4242 auth_lines.sort();
4243 auth_lines.dedup();
4244 if auth_lines.is_empty() {
4245 return None;
4246 }
4247 Some(format!(
4248 "Authorization is required before I can continue with this action.\n\n{}",
4249 auth_lines.join("\n\n")
4250 ))
4251}
4252
4253fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
4254 if outputs.is_empty()
4255 || !outputs
4256 .iter()
4257 .all(|output| is_guard_budget_tool_output(output))
4258 {
4259 return None;
4260 }
4261 let mut lines = outputs
4262 .iter()
4263 .filter_map(|output| {
4264 let trimmed = output.trim();
4265 if trimmed.is_empty() {
4266 None
4267 } else {
4268 Some(trimmed.to_string())
4269 }
4270 })
4271 .collect::<Vec<_>>();
4272 lines.sort();
4273 lines.dedup();
4274 if lines.is_empty() {
4275 return None;
4276 }
4277 Some(format!(
4278 "This run hit the per-run tool guard budget, so I paused tool execution to avoid runaway retries.\n\n{}\n\nSend a new message to start a fresh run.",
4279 lines.join("\n")
4280 ))
4281}
4282
4283fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
4284 if outputs.is_empty()
4285 || !outputs
4286 .iter()
4287 .all(|output| is_duplicate_signature_limit_output(output))
4288 {
4289 return None;
4290 }
4291 let mut lines = outputs
4292 .iter()
4293 .filter_map(|output| {
4294 let trimmed = output.trim();
4295 if trimmed.is_empty() {
4296 None
4297 } else {
4298 Some(trimmed.to_string())
4299 }
4300 })
4301 .collect::<Vec<_>>();
4302 lines.sort();
4303 lines.dedup();
4304 if lines.is_empty() {
4305 return None;
4306 }
4307 Some(format!(
4308 "This run paused because the same tool call kept repeating.\n\n{}\n\nRephrase the request or start a new message with a clearer command target.",
4309 lines.join("\n")
4310 ))
4311}
4312
4313const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
4314
4315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4316enum RequiredToolFailureKind {
4317 NoToolCallEmitted,
4318 ToolCallParseFailed,
4319 ToolCallInvalidArgs,
4320 WriteArgsEmptyFromProvider,
4321 WriteArgsUnparseableFromProvider,
4322 ToolCallRejectedByPolicy,
4323 ToolCallExecutedNonProductive,
4324 WriteRequiredNotSatisfied,
4325 PrewriteRequirementsExhausted,
4326}
4327
4328impl RequiredToolFailureKind {
4329 fn code(self) -> &'static str {
4330 match self {
4331 Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
4332 Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
4333 Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
4334 Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
4335 Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
4336 Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
4337 Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
4338 Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
4339 Self::PrewriteRequirementsExhausted => "PREWRITE_REQUIREMENTS_EXHAUSTED",
4340 }
4341 }
4342}
4343
4344fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
4345 format!(
4346 "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
4347 reason.code()
4348 )
4349}
4350
4351#[allow(dead_code)]
4352fn prewrite_requirements_exhausted_completion(
4353 unmet_codes: &[&'static str],
4354 repair_attempt: usize,
4355 repair_attempts_remaining: usize,
4356) -> String {
4357 let unmet = if unmet_codes.is_empty() {
4358 "none".to_string()
4359 } else {
4360 unmet_codes.join(", ")
4361 };
4362 format!(
4363 "TOOL_MODE_REQUIRED_NOT_SATISFIED: PREWRITE_REQUIREMENTS_EXHAUSTED: unmet prewrite requirements: {unmet}\n\n{{\"status\":\"blocked\",\"reason\":\"prewrite requirements exhausted before final artifact validation\",\"failureCode\":\"PREWRITE_REQUIREMENTS_EXHAUSTED\",\"repairAttempt\":{},\"repairAttemptsRemaining\":{},\"repairExhausted\":true,\"unmetRequirements\":{:?}}}",
4364 repair_attempt,
4365 repair_attempts_remaining,
4366 unmet_codes,
4367 )
4368}
4369
4370fn prewrite_repair_event_payload(
4371 repair_attempt: usize,
4372 repair_attempts_remaining: usize,
4373 unmet_codes: &[&'static str],
4374 repair_exhausted: bool,
4375) -> Value {
4376 json!({
4377 "repairAttempt": repair_attempt,
4378 "repairAttemptsRemaining": repair_attempts_remaining,
4379 "unmetRequirements": unmet_codes,
4380 "repairActive": repair_attempt > 0 && !repair_exhausted,
4381 "repairExhausted": repair_exhausted,
4382 })
4383}
4384
4385fn build_required_tool_retry_context(
4386 offered_tool_preview: &str,
4387 previous_reason: RequiredToolFailureKind,
4388) -> String {
4389 let offered = offered_tool_preview.trim();
4390 let available_tools = if offered.is_empty() {
4391 "Use one of the tools offered in this turn before you produce final text.".to_string()
4392 } else {
4393 format!("Use one of these offered tools before you produce final text: {offered}.")
4394 };
4395 let execution_instruction = if previous_reason
4396 == RequiredToolFailureKind::WriteRequiredNotSatisfied
4397 {
4398 "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
4399 } else if is_write_invalid_args_failure_kind(previous_reason) {
4400 "Previous tool call arguments were invalid. If you use write, include both `path` and the full `content`. If inspection is already complete, use write, edit, or apply_patch now.".to_string()
4401 } else {
4402 available_tools
4403 };
4404 format!(
4405 "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
4406 previous_reason.code(),
4407 execution_instruction
4408 )
4409}
4410
4411fn looks_like_code_target_path(path: &str) -> bool {
4412 let trimmed = path.trim();
4413 if trimmed.is_empty() {
4414 return false;
4415 }
4416 let normalized = trimmed.replace('\\', "/");
4417 let file_name = normalized
4418 .rsplit('/')
4419 .next()
4420 .unwrap_or(normalized.as_str())
4421 .to_ascii_lowercase();
4422 if matches!(
4423 file_name.as_str(),
4424 "cargo.toml"
4425 | "cargo.lock"
4426 | "package.json"
4427 | "pnpm-lock.yaml"
4428 | "package-lock.json"
4429 | "yarn.lock"
4430 | "makefile"
4431 | "dockerfile"
4432 | ".gitignore"
4433 | ".editorconfig"
4434 | "tsconfig.json"
4435 | "pyproject.toml"
4436 | "requirements.txt"
4437 ) {
4438 return true;
4439 }
4440 let extension = file_name.rsplit('.').next().unwrap_or_default();
4441 matches!(
4442 extension,
4443 "rs" | "ts"
4444 | "tsx"
4445 | "js"
4446 | "jsx"
4447 | "py"
4448 | "go"
4449 | "java"
4450 | "kt"
4451 | "kts"
4452 | "c"
4453 | "cc"
4454 | "cpp"
4455 | "h"
4456 | "hpp"
4457 | "cs"
4458 | "rb"
4459 | "php"
4460 | "swift"
4461 | "scala"
4462 | "sh"
4463 | "bash"
4464 | "zsh"
4465 | "toml"
4466 | "yaml"
4467 | "yml"
4468 | "json"
4469 )
4470}
4471
4472fn infer_code_workflow_from_text(text: &str) -> bool {
4473 let lowered = text.to_ascii_lowercase();
4474 if lowered.contains("code agent contract")
4475 || lowered.contains("inspect -> patch -> apply -> test -> repair")
4476 || lowered.contains("task kind: `code_change`")
4477 || lowered.contains("task kind: code_change")
4478 || lowered.contains("output contract kind: code_patch")
4479 || lowered.contains("verification expectation:")
4480 || lowered.contains("verification command:")
4481 {
4482 return true;
4483 }
4484 infer_required_output_target_path_from_text(text)
4485 .is_some_and(|path| looks_like_code_target_path(&path))
4486}
4487
4488fn infer_verification_command_from_text(text: &str) -> Option<String> {
4489 for marker in ["Verification expectation:", "verification expectation:"] {
4490 let Some(start) = text.find(marker) else {
4491 continue;
4492 };
4493 let remainder = text[start + marker.len()..].trim_start();
4494 let line = remainder.lines().next().unwrap_or_default().trim();
4495 if line.is_empty() {
4496 continue;
4497 }
4498 let cleaned = line
4499 .trim_matches('`')
4500 .trim_end_matches('.')
4501 .trim()
4502 .to_string();
4503 if !cleaned.is_empty() {
4504 return Some(cleaned);
4505 }
4506 }
4507 None
4508}
4509
4510fn build_required_tool_retry_context_for_task(
4511 offered_tool_preview: &str,
4512 previous_reason: RequiredToolFailureKind,
4513 latest_user_text: &str,
4514) -> String {
4515 let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
4516 if !infer_code_workflow_from_text(latest_user_text) {
4517 return prompt;
4518 }
4519 let output_target = infer_required_output_target_path_from_text(latest_user_text)
4520 .unwrap_or_else(|| "the declared source target".to_string());
4521 let verification = infer_verification_command_from_text(latest_user_text)
4522 .unwrap_or_else(|| "run the declared verification command with `bash`".to_string());
4523 prompt.push(' ');
4524 prompt.push_str(
4525 "This is a code workflow: follow inspect -> patch -> apply -> test -> repair before finalizing.",
4526 );
4527 prompt.push(' ');
4528 prompt.push_str(&format!(
4529 "Patch `{output_target}` using `apply_patch` (or `edit` for local edits); use `write` only when creating a brand-new file."
4530 ));
4531 prompt.push(' ');
4532 prompt.push_str(&format!(
4533 "After patching, run verification with `bash` (`{verification}`). If verification fails, repair the smallest root cause and re-run verification."
4534 ));
4535 prompt
4536}
4537
4538fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
4539 matches!(
4540 reason,
4541 RequiredToolFailureKind::ToolCallInvalidArgs
4542 | RequiredToolFailureKind::WriteArgsEmptyFromProvider
4543 | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
4544 )
4545}
4546
4547fn should_retry_nonproductive_required_tool_cycle(
4548 requested_write_required: bool,
4549 write_tool_attempted_in_cycle: bool,
4550 progress_made_in_cycle: bool,
4551 required_tool_retry_count: usize,
4552) -> bool {
4553 if write_tool_attempted_in_cycle {
4554 return required_tool_retry_count == 0 && !requested_write_required;
4555 }
4556 if progress_made_in_cycle {
4557 return required_tool_retry_count < 2;
4558 }
4559 required_tool_retry_count == 0 && (!requested_write_required || !write_tool_attempted_in_cycle)
4560}
4561
4562fn build_write_required_retry_context(
4563 offered_tool_preview: &str,
4564 previous_reason: RequiredToolFailureKind,
4565 latest_user_text: &str,
4566 prewrite_requirements: &PrewriteRequirements,
4567 workspace_inspection_satisfied: bool,
4568 concrete_read_satisfied: bool,
4569 web_research_satisfied: bool,
4570 successful_web_research_satisfied: bool,
4571) -> String {
4572 let mut prompt = build_required_tool_retry_context_for_task(
4573 offered_tool_preview,
4574 previous_reason,
4575 latest_user_text,
4576 );
4577 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4578 prewrite_requirements,
4579 workspace_inspection_satisfied,
4580 concrete_read_satisfied,
4581 web_research_satisfied,
4582 successful_web_research_satisfied,
4583 );
4584 if !unmet.is_empty() {
4585 prompt.push(' ');
4586 prompt.push_str(&format!(
4587 "Before the final write, you still need to {}.",
4588 unmet.join(" and ")
4589 ));
4590 }
4591 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4592 prompt.push(' ');
4593 prompt.push_str(&format!(
4594 "The required output target for this task is `{path}`. Write or update that file now."
4595 ));
4596 prompt.push(' ');
4597 prompt.push_str(
4598 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4599 );
4600 prompt.push(' ');
4601 prompt.push_str(
4602 "You have already gathered research in this session. Now write the output file using the information from your previous tool calls. You may re-read a specific file if needed for accuracy.",
4603 );
4604 }
4605 prompt
4606}
4607
4608fn build_prewrite_repair_retry_context(
4609 offered_tool_preview: &str,
4610 previous_reason: RequiredToolFailureKind,
4611 latest_user_text: &str,
4612 prewrite_requirements: &PrewriteRequirements,
4613 workspace_inspection_satisfied: bool,
4614 concrete_read_satisfied: bool,
4615 web_research_satisfied: bool,
4616 successful_web_research_satisfied: bool,
4617) -> String {
4618 let mut prompt = build_required_tool_retry_context_for_task(
4619 offered_tool_preview,
4620 previous_reason,
4621 latest_user_text,
4622 );
4623 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4624 prewrite_requirements,
4625 workspace_inspection_satisfied,
4626 concrete_read_satisfied,
4627 web_research_satisfied,
4628 successful_web_research_satisfied,
4629 );
4630 if !unmet.is_empty() {
4631 prompt.push(' ');
4632 prompt.push_str(&format!(
4633 "Before the final write, you still need to {}.",
4634 unmet.join(" and ")
4635 ));
4636 }
4637 let mut repair_notes = Vec::new();
4638 if prewrite_requirements.concrete_read_required && !concrete_read_satisfied {
4639 repair_notes.push(
4640 "This task requires concrete `read` calls on relevant workspace files before you can write the output. Call `read` now on the files you discovered.",
4641 );
4642 }
4643 if prewrite_requirements.successful_web_research_required && !successful_web_research_satisfied
4644 {
4645 repair_notes.push(
4646 "Timed out or empty websearch attempts do not satisfy external-research requirements; call `websearch` with a concrete query now.",
4647 );
4648 }
4649 if !matches!(
4650 prewrite_requirements.coverage_mode,
4651 PrewriteCoverageMode::None
4652 ) {
4653 repair_notes.push(
4654 "Every path listed under `Files reviewed` must have been actually read in this run, and any relevant discovered file you did not read must appear under `Files not reviewed` with a reason.",
4655 );
4656 }
4657 if !repair_notes.is_empty() {
4658 prompt.push(' ');
4659 prompt.push_str("Do not skip this step. ");
4660 prompt.push_str(&repair_notes.join(" "));
4661 }
4662 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4663 if infer_code_workflow_from_text(latest_user_text) {
4664 prompt.push(' ');
4665 prompt.push_str(&format!(
4666 "Use `read` to confirm the concrete code context, then patch `{path}` with `apply_patch` or `edit` and run verification before finalizing."
4667 ));
4668 prompt.push(' ');
4669 prompt.push_str(
4670 "Do not return a prose-only completion before patch + verification steps run.",
4671 );
4672 } else {
4673 prompt.push(' ');
4674 prompt.push_str(&format!(
4675 "Use `read` and `websearch` now to gather evidence, then write the artifact to `{path}`."
4676 ));
4677 prompt.push(' ');
4678 prompt.push_str(&format!(
4679 "Do not declare the output blocked while `read` and `websearch` remain available. Call them now."
4680 ));
4681 }
4682 }
4683 prompt
4684}
4685
4686fn build_prewrite_waived_write_context(
4687 latest_user_text: &str,
4688 unmet_codes: &[&'static str],
4689) -> String {
4690 let mut prompt = String::from(
4691 "Research prerequisites could not be fully satisfied after multiple repair attempts. \
4692 You must still write the output file using whatever information you have gathered so far. \
4693 Do not write a blocked or placeholder file. Write the best possible output with the evidence available.",
4694 );
4695 if !unmet_codes.is_empty() {
4696 prompt.push_str(&format!(
4697 " (Unmet prerequisites waived: {}.)",
4698 unmet_codes.join(", ")
4699 ));
4700 }
4701 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4702 prompt.push_str(&format!(
4703 " The required output file is `{path}`. Call the `write` tool now to create it."
4704 ));
4705 }
4706 prompt
4707}
4708
4709fn build_empty_completion_retry_context(
4710 offered_tool_preview: &str,
4711 latest_user_text: &str,
4712 prewrite_requirements: &PrewriteRequirements,
4713 workspace_inspection_satisfied: bool,
4714 concrete_read_satisfied: bool,
4715 web_research_satisfied: bool,
4716 successful_web_research_satisfied: bool,
4717) -> String {
4718 let mut prompt = String::from(
4719 "You already used tools in this session, but returned no final output. Do not stop now.",
4720 );
4721 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4722 prewrite_requirements,
4723 workspace_inspection_satisfied,
4724 concrete_read_satisfied,
4725 web_research_satisfied,
4726 successful_web_research_satisfied,
4727 );
4728 if !unmet.is_empty() {
4729 prompt.push(' ');
4730 prompt.push_str(&format!(
4731 "You still need to {} before the final write.",
4732 unmet.join(" and ")
4733 ));
4734 prompt.push(' ');
4735 prompt.push_str(&build_required_tool_retry_context_for_task(
4736 offered_tool_preview,
4737 RequiredToolFailureKind::WriteRequiredNotSatisfied,
4738 latest_user_text,
4739 ));
4740 }
4741 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4742 prompt.push(' ');
4743 prompt.push_str(&format!("The required output target is `{path}`."));
4744 if unmet.is_empty() {
4745 prompt.push(' ');
4746 prompt.push_str(
4747 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4748 );
4749 } else {
4750 prompt.push(' ');
4751 prompt.push_str(
4752 "After completing the missing requirement, immediately write that file instead of ending with prose.",
4753 );
4754 }
4755 }
4756 prompt
4757}
4758
4759fn synthesize_artifact_write_completion_from_tool_state(
4760 latest_user_text: &str,
4761 prewrite_satisfied: bool,
4762 prewrite_gate_waived: bool,
4763) -> String {
4764 let target = infer_required_output_target_path_from_text(latest_user_text)
4765 .unwrap_or_else(|| "the declared output artifact".to_string());
4766 let mut completion = format!("Completed the requested tool actions and wrote `{target}`.");
4767 if prewrite_gate_waived && !prewrite_satisfied {
4768 completion.push_str(
4769 "\n\nRuntime validation will decide whether the artifact can be accepted because some evidence requirements were waived in-run."
4770 );
4771 } else {
4772 completion
4773 .push_str("\n\nRuntime validation will verify the artifact and finalize node status.");
4774 }
4775 completion.push_str("\n\n{\"status\":\"completed\"}");
4776 completion
4777}
4778
4779fn should_generate_post_tool_final_narrative(
4780 requested_tool_mode: ToolMode,
4781 productive_tool_calls_total: usize,
4782) -> bool {
4783 !matches!(requested_tool_mode, ToolMode::Required) || productive_tool_calls_total > 0
4784}
4785
4786fn is_workspace_inspection_tool(tool_name: &str) -> bool {
4787 matches!(
4788 normalize_tool_name(tool_name).as_str(),
4789 "glob" | "read" | "grep" | "search" | "codesearch" | "ls" | "list"
4790 )
4791}
4792
4793fn is_web_research_tool(tool_name: &str) -> bool {
4794 matches!(
4795 normalize_tool_name(tool_name).as_str(),
4796 "websearch" | "webfetch" | "webfetch_html"
4797 )
4798}
4799
4800fn tool_matches_unmet_prewrite_repair_requirement(tool_name: &str, unmet_codes: &[&str]) -> bool {
4801 if is_workspace_write_tool(tool_name) {
4802 return false;
4803 }
4804 let normalized = normalize_tool_name(tool_name);
4805 let needs_workspace_inspection = unmet_codes.contains(&"workspace_inspection_required");
4806 let needs_concrete_read =
4807 unmet_codes.contains(&"concrete_read_required") || unmet_codes.contains(&"coverage_mode");
4808 let needs_web_research = unmet_codes.iter().any(|code| {
4809 matches!(
4810 *code,
4811 "web_research_required" | "successful_web_research_required"
4812 )
4813 });
4814 (needs_concrete_read && (normalized == "read" || normalized == "glob"))
4815 || (needs_workspace_inspection && is_workspace_inspection_tool(&normalized))
4816 || (needs_web_research && is_web_research_tool(&normalized))
4817}
4818
4819fn invalid_tool_args_retry_max_attempts() -> usize {
4820 2
4821}
4822
4823pub fn prewrite_repair_retry_max_attempts() -> usize {
4824 5
4825}
4826
4827pub(super) fn prewrite_gate_strict_mode() -> bool {
4831 std::env::var("TANDEM_PREWRITE_GATE_STRICT")
4832 .ok()
4833 .map(|v| {
4834 matches!(
4835 v.trim().to_ascii_lowercase().as_str(),
4836 "1" | "true" | "yes" | "on"
4837 )
4838 })
4839 .unwrap_or(false)
4840}
4841
4842fn build_invalid_tool_args_retry_context_from_outputs(
4843 outputs: &[String],
4844 previous_attempts: usize,
4845) -> Option<String> {
4846 if outputs
4847 .iter()
4848 .any(|output| output.contains("BASH_COMMAND_MISSING"))
4849 {
4850 let emphasis = if previous_attempts > 0 {
4851 "You already tried `bash` without a valid command. Do not repeat an empty bash call."
4852 } else {
4853 "If you use `bash`, include a full non-empty command string."
4854 };
4855 return Some(format!(
4856 "Previous bash tool call was invalid because it did not include the required `command` field. {emphasis} Good examples: `pwd`, `ls -la`, `find docs -maxdepth 2 -type f`, or `rg -n \"workflow\" docs src`. Prefer `ls`, `glob`, `search`, and `read` for repository inspection when they are sufficient."
4857 ));
4858 }
4859 if outputs
4860 .iter()
4861 .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
4862 {
4863 return Some(
4864 "Previous websearch tool call was invalid because it did not include a query. If you use `websearch`, include a specific non-empty search query.".to_string(),
4865 );
4866 }
4867 if outputs
4868 .iter()
4869 .any(|output| output.contains("WEBFETCH_URL_MISSING"))
4870 {
4871 return Some(
4872 "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
4873 );
4874 }
4875 if outputs
4876 .iter()
4877 .any(|output| output.contains("FILE_PATH_MISSING"))
4878 {
4879 return Some(
4880 "Previous file tool call was invalid because it did not include a `path`. If you use `read`, `write`, or `edit`, include the exact workspace-relative file path.".to_string(),
4881 );
4882 }
4883 if outputs
4884 .iter()
4885 .any(|output| output.contains("WRITE_CONTENT_MISSING"))
4886 {
4887 return Some(
4888 "Previous write tool call was invalid because it did not include `content`. If you use `write`, include both `path` and the full `content`.".to_string(),
4889 );
4890 }
4891 None
4892}
4893
4894fn looks_like_unparsed_tool_payload(output: &str) -> bool {
4895 let trimmed = output.trim();
4896 if trimmed.is_empty() {
4897 return false;
4898 }
4899 let lower = trimmed.to_ascii_lowercase();
4900 lower.contains("\"tool_calls\"")
4901 || lower.contains("\"function_call\"")
4902 || lower.contains("\"function\":{")
4903 || lower.contains("\"type\":\"tool_call\"")
4904 || lower.contains("\"type\":\"function_call\"")
4905 || lower.contains("\"type\":\"tool_use\"")
4906}
4907
4908fn is_policy_rejection_output(output: &str) -> bool {
4909 let lower = output.trim().to_ascii_lowercase();
4910 lower.contains("call skipped")
4911 || lower.contains("authorization required")
4912 || lower.contains("not allowed")
4913 || lower.contains("permission denied")
4914}
4915
4916fn classify_required_tool_failure(
4917 outputs: &[String],
4918 saw_tool_call_candidate: bool,
4919 accepted_tool_calls: usize,
4920 parse_failed: bool,
4921 rejected_by_policy: bool,
4922) -> RequiredToolFailureKind {
4923 if parse_failed {
4924 return RequiredToolFailureKind::ToolCallParseFailed;
4925 }
4926 if !saw_tool_call_candidate {
4927 return RequiredToolFailureKind::NoToolCallEmitted;
4928 }
4929 if accepted_tool_calls == 0 || rejected_by_policy {
4930 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4931 }
4932 if outputs
4933 .iter()
4934 .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
4935 {
4936 return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
4937 }
4938 if outputs
4939 .iter()
4940 .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
4941 {
4942 return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
4943 }
4944 if outputs
4945 .iter()
4946 .any(|output| is_terminal_tool_error_reason(output))
4947 {
4948 return RequiredToolFailureKind::ToolCallInvalidArgs;
4949 }
4950 if outputs
4951 .iter()
4952 .any(|output| is_policy_rejection_output(output))
4953 {
4954 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4955 }
4956 RequiredToolFailureKind::ToolCallExecutedNonProductive
4957}
4958
4959fn find_first_url(text: &str) -> Option<String> {
4960 text.split_whitespace().find_map(|token| {
4961 if token.starts_with("https://") || token.starts_with("http://") {
4962 let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
4963 if cleaned.len() > "https://".len() {
4964 return Some(cleaned.to_string());
4965 }
4966 }
4967 None
4968 })
4969}
4970
4971fn max_tool_iterations() -> usize {
4972 let default_iterations = 25usize;
4973 std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
4974 .ok()
4975 .and_then(|raw| raw.trim().parse::<usize>().ok())
4976 .filter(|value| *value > 0)
4977 .unwrap_or(default_iterations)
4978}
4979
4980fn strict_write_retry_max_attempts() -> usize {
4981 std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
4982 .ok()
4983 .and_then(|raw| raw.trim().parse::<usize>().ok())
4984 .filter(|value| *value > 0)
4985 .unwrap_or(3)
4986}
4987
4988fn provider_stream_connect_timeout_ms() -> usize {
4989 std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
4990 .ok()
4991 .and_then(|raw| raw.trim().parse::<usize>().ok())
4992 .filter(|value| *value > 0)
4993 .unwrap_or(90_000)
4994}
4995
4996fn provider_stream_idle_timeout_ms() -> usize {
4997 std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
4998 .ok()
4999 .and_then(|raw| raw.trim().parse::<usize>().ok())
5000 .filter(|value| *value > 0)
5001 .unwrap_or(90_000)
5002}
5003
5004fn prompt_context_hook_timeout_ms() -> usize {
5005 std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
5006 .ok()
5007 .and_then(|raw| raw.trim().parse::<usize>().ok())
5008 .filter(|value| *value > 0)
5009 .unwrap_or(5_000)
5010}
5011
5012fn permission_wait_timeout_ms() -> usize {
5013 std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
5014 .ok()
5015 .and_then(|raw| raw.trim().parse::<usize>().ok())
5016 .filter(|value| *value > 0)
5017 .unwrap_or(15_000)
5018}
5019
5020fn tool_exec_timeout_ms() -> usize {
5021 std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
5022 .ok()
5023 .and_then(|raw| raw.trim().parse::<usize>().ok())
5024 .filter(|value| *value > 0)
5025 .unwrap_or(45_000)
5026}
5027
5028fn is_guard_budget_tool_output(output: &str) -> bool {
5029 output
5030 .to_ascii_lowercase()
5031 .contains("per-run guard budget exceeded")
5032}
5033
5034fn is_duplicate_signature_limit_output(output: &str) -> bool {
5035 output
5036 .to_ascii_lowercase()
5037 .contains("duplicate call signature retry limit reached")
5038}
5039
5040fn is_sensitive_path_candidate(path: &Path) -> bool {
5041 let lowered = path.to_string_lossy().to_ascii_lowercase();
5042
5043 if lowered.contains("/.ssh/") || lowered.ends_with("/.ssh") {
5045 return true;
5046 }
5047 if lowered.contains("/.gnupg/") || lowered.ends_with("/.gnupg") {
5048 return true;
5049 }
5050
5051 if lowered.contains("/.aws/credentials")
5053 || lowered.contains("/.config/gcloud/")
5054 || lowered.contains("/.docker/config.json")
5055 || lowered.contains("/.kube/config")
5056 || lowered.contains("/.git-credentials")
5057 {
5058 return true;
5059 }
5060
5061 if lowered.ends_with("/.npmrc") || lowered.ends_with("/.netrc") || lowered.ends_with("/.pypirc")
5063 {
5064 return true;
5065 }
5066
5067 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
5069 let n = name.to_ascii_lowercase();
5070 if n == ".env"
5072 || n.starts_with(".env.") && !n.ends_with(".example") && !n.ends_with(".sample")
5073 {
5074 return true;
5075 }
5076 if n.starts_with("id_rsa")
5078 || n.starts_with("id_ed25519")
5079 || n.starts_with("id_ecdsa")
5080 || n.starts_with("id_dsa")
5081 {
5082 return true;
5083 }
5084 }
5085
5086 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
5089 let ext_lower = ext.to_ascii_lowercase();
5090 if matches!(
5091 ext_lower.as_str(),
5092 "pem" | "p12" | "pfx" | "key" | "keystore" | "jks"
5093 ) {
5094 return true;
5095 }
5096 }
5097
5098 false
5099}
5100
5101fn shell_command_targets_sensitive_path(command: &str) -> bool {
5102 let lower = command.to_ascii_lowercase();
5103 let patterns = [
5104 "/.ssh/",
5105 "/.gnupg/",
5106 "/.aws/credentials",
5107 "/.config/gcloud/",
5108 "/.docker/config.json",
5109 "/.kube/config",
5110 "/.git-credentials",
5111 "id_rsa",
5112 "id_ed25519",
5113 "id_ecdsa",
5114 "id_dsa",
5115 ".npmrc",
5116 ".netrc",
5117 ".pypirc",
5118 ];
5119 if patterns.iter().any(|p| lower.contains(p)) {
5121 return true;
5122 }
5123 if let Some(pos) = lower.find(".env") {
5125 let after = &lower[pos + 4..];
5126 if after.is_empty() || after.starts_with(' ') || after.starts_with('/') {
5127 return true;
5128 }
5129 }
5130 false
5131}
5132
5133#[derive(Debug, Clone)]
5134struct NormalizedToolArgs {
5135 args: Value,
5136 args_source: String,
5137 args_integrity: String,
5138 raw_args_state: RawToolArgsState,
5139 query: Option<String>,
5140 missing_terminal: bool,
5141 missing_terminal_reason: Option<String>,
5142}
5143
5144#[derive(Debug, Clone)]
5145struct ParsedToolCall {
5146 tool: String,
5147 args: Value,
5148 call_id: Option<String>,
5149}
5150
5151#[cfg(test)]
5152fn normalize_tool_args(
5153 tool_name: &str,
5154 raw_args: Value,
5155 latest_user_text: &str,
5156 latest_assistant_context: &str,
5157) -> NormalizedToolArgs {
5158 normalize_tool_args_with_mode(
5159 tool_name,
5160 raw_args,
5161 latest_user_text,
5162 latest_assistant_context,
5163 WritePathRecoveryMode::Heuristic,
5164 )
5165}
5166
5167fn normalize_tool_args_with_mode(
5168 tool_name: &str,
5169 raw_args: Value,
5170 latest_user_text: &str,
5171 latest_assistant_context: &str,
5172 write_path_recovery_mode: WritePathRecoveryMode,
5173) -> NormalizedToolArgs {
5174 let normalized_tool = normalize_tool_name(tool_name);
5175 let original_args = raw_args.clone();
5176 let mut args = raw_args;
5177 let mut args_source = if args.is_string() {
5178 "provider_string".to_string()
5179 } else {
5180 "provider_json".to_string()
5181 };
5182 let mut args_integrity = "ok".to_string();
5183 let raw_args_state = classify_raw_tool_args_state(&args);
5184 let mut query = None;
5185 let mut missing_terminal = false;
5186 let mut missing_terminal_reason = None;
5187
5188 if normalized_tool == "websearch" {
5189 if let Some(found) = extract_websearch_query(&args) {
5190 query = Some(found);
5191 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
5192 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
5193 args_source = "inferred_from_user".to_string();
5194 args_integrity = "recovered".to_string();
5195 query = Some(inferred);
5196 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
5197 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
5198 args_source = "recovered_from_context".to_string();
5199 args_integrity = "recovered".to_string();
5200 query = Some(recovered);
5201 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
5202 } else {
5203 args_source = "missing".to_string();
5204 args_integrity = "empty".to_string();
5205 missing_terminal = true;
5206 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
5207 }
5208 } else if is_shell_tool_name(&normalized_tool) {
5209 if let Some(command) = extract_shell_command(&args) {
5210 args = set_shell_command(args, command);
5211 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
5212 args_source = "inferred_from_context".to_string();
5213 args_integrity = "recovered".to_string();
5214 args = set_shell_command(args, inferred);
5215 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
5216 args_source = "inferred_from_user".to_string();
5217 args_integrity = "recovered".to_string();
5218 args = set_shell_command(args, inferred);
5219 } else {
5220 args_source = "missing".to_string();
5221 args_integrity = "empty".to_string();
5222 missing_terminal = true;
5223 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
5224 }
5225 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5226 if let Some(path) = extract_file_path_arg(&args) {
5227 args = set_file_path_arg(args, path);
5228 } else if normalized_tool == "write" || normalized_tool == "edit" {
5229 let model_explicit_path_value = args
5237 .as_object()
5238 .and_then(|obj| obj.get("path"))
5239 .and_then(Value::as_str)
5240 .map(str::trim)
5241 .filter(|p| !p.is_empty());
5242 let path_is_trivial_placeholder = model_explicit_path_value
5243 .is_some_and(|p| matches!(p, "./" | "." | ".." | "/" | "~"));
5244 let model_explicitly_set_nontrivial_path = model_explicit_path_value
5245 .is_some_and(|p| p.len() > 2)
5246 && !path_is_trivial_placeholder;
5247 if model_explicitly_set_nontrivial_path {
5248 args_source = "rejected".to_string();
5249 args_integrity = "rejected_path".to_string();
5250 missing_terminal = true;
5251 missing_terminal_reason = Some("WRITE_PATH_REJECTED".to_string());
5252 } else if let Some(inferred) =
5253 infer_required_output_target_path_from_text(latest_user_text).or_else(|| {
5254 infer_required_output_target_path_from_text(latest_assistant_context)
5255 })
5256 {
5257 args_source = "recovered_from_context".to_string();
5258 args_integrity = "recovered".to_string();
5259 args = set_file_path_arg(args, inferred);
5260 } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
5261 if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
5262 args_source = "inferred_from_user".to_string();
5263 args_integrity = "recovered".to_string();
5264 args = set_file_path_arg(args, inferred);
5265 } else {
5266 args_source = "missing".to_string();
5267 args_integrity = "empty".to_string();
5268 missing_terminal = true;
5269 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5270 }
5271 } else {
5272 args_source = "missing".to_string();
5273 args_integrity = "empty".to_string();
5274 missing_terminal = true;
5275 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5276 }
5277 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
5278 args_source = "inferred_from_user".to_string();
5279 args_integrity = "recovered".to_string();
5280 args = set_file_path_arg(args, inferred);
5281 } else {
5282 args_source = "missing".to_string();
5283 args_integrity = "empty".to_string();
5284 missing_terminal = true;
5285 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5286 }
5287
5288 if !missing_terminal && normalized_tool == "write" {
5289 if let Some(content) = extract_write_content_arg(&args) {
5290 args = set_write_content_arg(args, content);
5291 } else if let Some(recovered) =
5292 infer_write_content_from_assistant_context(latest_assistant_context)
5293 {
5294 args_source = "recovered_from_context".to_string();
5295 args_integrity = "recovered".to_string();
5296 args = set_write_content_arg(args, recovered);
5297 } else {
5298 args_source = "missing".to_string();
5299 args_integrity = "empty".to_string();
5300 missing_terminal = true;
5301 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
5302 }
5303 }
5304 } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
5305 if let Some(url) = extract_webfetch_url_arg(&args) {
5306 args = set_webfetch_url_arg(args, url);
5307 } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
5308 args_source = "inferred_from_context".to_string();
5309 args_integrity = "recovered".to_string();
5310 args = set_webfetch_url_arg(args, inferred);
5311 } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
5312 args_source = "inferred_from_user".to_string();
5313 args_integrity = "recovered".to_string();
5314 args = set_webfetch_url_arg(args, inferred);
5315 } else {
5316 args_source = "missing".to_string();
5317 args_integrity = "empty".to_string();
5318 missing_terminal = true;
5319 missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
5320 }
5321 } else if normalized_tool == "pack_builder" {
5322 let mode = extract_pack_builder_mode_arg(&args);
5323 let plan_id = extract_pack_builder_plan_id_arg(&args);
5324 if mode.as_deref() == Some("apply") && plan_id.is_none() {
5325 if let Some(inferred_plan) =
5326 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5327 {
5328 args_source = "recovered_from_context".to_string();
5329 args_integrity = "recovered".to_string();
5330 args = set_pack_builder_apply_args(args, inferred_plan);
5331 } else {
5332 args_source = "missing".to_string();
5333 args_integrity = "empty".to_string();
5334 missing_terminal = true;
5335 missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
5336 }
5337 } else if mode.as_deref() == Some("apply") {
5338 args = ensure_pack_builder_default_mode(args);
5339 } else if let Some(inferred_plan) =
5340 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5341 {
5342 args_source = "recovered_from_context".to_string();
5343 args_integrity = "recovered".to_string();
5344 args = set_pack_builder_apply_args(args, inferred_plan);
5345 } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
5346 args = set_pack_builder_goal_arg(args, goal);
5347 } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
5348 args_source = "inferred_from_user".to_string();
5349 args_integrity = "recovered".to_string();
5350 args = set_pack_builder_goal_arg(args, inferred);
5351 } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
5352 {
5353 args_source = "recovered_from_context".to_string();
5354 args_integrity = "recovered".to_string();
5355 args = set_pack_builder_goal_arg(args, recovered);
5356 } else {
5357 args_source = "missing".to_string();
5358 args_integrity = "empty".to_string();
5359 missing_terminal = true;
5360 missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
5361 }
5362 args = ensure_pack_builder_default_mode(args);
5363 } else if is_email_delivery_tool_name(&normalized_tool) {
5364 let sanitized = sanitize_email_attachment_args(args);
5365 if sanitized != original_args {
5366 args_source = "sanitized_attachment".to_string();
5367 args_integrity = "recovered".to_string();
5368 }
5369 args = sanitized;
5370 }
5371
5372 NormalizedToolArgs {
5373 args,
5374 args_source,
5375 args_integrity,
5376 raw_args_state,
5377 query,
5378 missing_terminal,
5379 missing_terminal_reason,
5380 }
5381}
5382
5383fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
5384 match raw_args {
5385 Value::Null => RawToolArgsState::Empty,
5386 Value::Object(obj) => {
5387 if obj.is_empty() {
5388 RawToolArgsState::Empty
5389 } else {
5390 RawToolArgsState::Present
5391 }
5392 }
5393 Value::Array(items) => {
5394 if items.is_empty() {
5395 RawToolArgsState::Empty
5396 } else {
5397 RawToolArgsState::Present
5398 }
5399 }
5400 Value::String(raw) => {
5401 let trimmed = raw.trim();
5402 if trimmed.is_empty() {
5403 return RawToolArgsState::Empty;
5404 }
5405 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5406 return classify_raw_tool_args_state(&parsed);
5407 }
5408 if parse_function_style_args(trimmed).is_empty() {
5409 return RawToolArgsState::Unparseable;
5410 }
5411 RawToolArgsState::Present
5412 }
5413 _ => RawToolArgsState::Present,
5414 }
5415}
5416
5417fn args_missing_or_empty(args: &Value) -> bool {
5418 match args {
5419 Value::Null => true,
5420 Value::Object(obj) => obj.is_empty(),
5421 Value::Array(items) => items.is_empty(),
5422 Value::String(raw) => raw.trim().is_empty(),
5423 _ => false,
5424 }
5425}
5426
5427fn persisted_failed_tool_args(raw_args: &Value, normalized_args: &Value) -> Value {
5428 if args_missing_or_empty(raw_args) && !args_missing_or_empty(normalized_args) {
5429 normalized_args.clone()
5430 } else {
5431 raw_args.clone()
5432 }
5433}
5434
5435fn provider_specific_write_reason(
5436 tool: &str,
5437 missing_reason: &str,
5438 raw_args_state: RawToolArgsState,
5439) -> Option<String> {
5440 if tool != "write"
5441 || !matches!(
5442 missing_reason,
5443 "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
5444 )
5445 {
5446 return None;
5447 }
5448 match raw_args_state {
5449 RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
5450 RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
5451 RawToolArgsState::Present => None,
5452 }
5453}
5454
5455fn is_shell_tool_name(tool_name: &str) -> bool {
5456 matches!(
5457 tool_name.trim().to_ascii_lowercase().as_str(),
5458 "bash" | "shell" | "powershell" | "cmd"
5459 )
5460}
5461
5462fn email_tool_name_tokens(tool_name: &str) -> Vec<String> {
5463 tool_name
5464 .trim()
5465 .to_ascii_lowercase()
5466 .chars()
5467 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
5468 .collect::<String>()
5469 .split_whitespace()
5470 .map(str::to_string)
5471 .collect::<Vec<_>>()
5472}
5473
5474fn email_tool_name_compact(tool_name: &str) -> String {
5475 tool_name
5476 .trim()
5477 .to_ascii_lowercase()
5478 .chars()
5479 .filter(|ch| ch.is_ascii_alphanumeric())
5480 .collect::<String>()
5481}
5482
5483fn is_email_delivery_tool_name(tool_name: &str) -> bool {
5484 let tokens = email_tool_name_tokens(tool_name);
5485 let compact = email_tool_name_compact(tool_name);
5486 let looks_like_email_provider = tokens.iter().any(|token| {
5487 matches!(
5488 token.as_str(),
5489 "email"
5490 | "mail"
5491 | "gmail"
5492 | "outlook"
5493 | "smtp"
5494 | "imap"
5495 | "inbox"
5496 | "mailbox"
5497 | "mailer"
5498 | "exchange"
5499 | "sendgrid"
5500 | "mailgun"
5501 | "postmark"
5502 | "resend"
5503 | "ses"
5504 )
5505 });
5506 if !looks_like_email_provider {
5507 return false;
5508 }
5509 tokens.iter().any(|token| {
5510 matches!(
5511 token.as_str(),
5512 "send" | "deliver" | "reply" | "draft" | "compose" | "create"
5513 )
5514 }) || compact.contains("sendemail")
5515 || compact.contains("emailsend")
5516 || compact.contains("replyemail")
5517 || compact.contains("emailreply")
5518 || compact.contains("draftemail")
5519 || compact.contains("emaildraft")
5520 || compact.contains("composeemail")
5521 || compact.contains("emailcompose")
5522 || compact.contains("createemaildraft")
5523 || compact.contains("emailcreatedraft")
5524}
5525
5526fn sanitize_email_attachment_args(args: Value) -> Value {
5527 let mut obj = match args {
5528 Value::Object(map) => map,
5529 other => return other,
5530 };
5531 if let Some(Value::Object(attachment)) = obj.get("attachment") {
5532 let s3key = attachment
5533 .get("s3key")
5534 .and_then(Value::as_str)
5535 .map(str::trim)
5536 .unwrap_or("");
5537 if s3key.is_empty() {
5538 obj.remove("attachment");
5539 }
5540 } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
5541 obj.remove("attachment");
5542 }
5543 if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
5544 attachments.retain(|entry| {
5545 entry
5546 .get("s3key")
5547 .and_then(Value::as_str)
5548 .map(str::trim)
5549 .map(|value| !value.is_empty())
5550 .unwrap_or(false)
5551 });
5552 if attachments.is_empty() {
5553 obj.remove("attachments");
5554 }
5555 }
5556 Value::Object(obj)
5557}
5558
5559fn set_file_path_arg(args: Value, path: String) -> Value {
5560 let mut obj = args.as_object().cloned().unwrap_or_default();
5561 obj.insert("path".to_string(), Value::String(path));
5562 Value::Object(obj)
5563}
5564
5565fn normalize_workspace_alias_path(path: &str, workspace_root: &str) -> Option<String> {
5566 let trimmed = path.trim();
5567 if trimmed.is_empty() {
5568 return None;
5569 }
5570 let normalized = trimmed.replace('\\', "/");
5571 if normalized == "/workspace" {
5572 return Some(workspace_root.to_string());
5573 }
5574 if let Some(rest) = normalized.strip_prefix("/workspace/") {
5575 if rest.trim().is_empty() {
5576 return Some(workspace_root.to_string());
5577 }
5578 return Some(rest.trim().to_string());
5579 }
5580 None
5581}
5582
5583fn rewrite_workspace_alias_tool_args(tool: &str, args: Value, workspace_root: &str) -> Value {
5584 let normalized_tool = normalize_tool_name(tool);
5585 if !matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5586 return args;
5587 }
5588 let Some(path) = extract_file_path_arg(&args) else {
5589 return args;
5590 };
5591 let Some(rewritten) = normalize_workspace_alias_path(&path, workspace_root) else {
5592 return args;
5593 };
5594 set_file_path_arg(args, rewritten)
5595}
5596
5597fn set_write_content_arg(args: Value, content: String) -> Value {
5598 let mut obj = args.as_object().cloned().unwrap_or_default();
5599 obj.insert("content".to_string(), Value::String(content));
5600 Value::Object(obj)
5601}
5602
5603fn extract_file_path_arg(args: &Value) -> Option<String> {
5604 extract_file_path_arg_internal(args, 0)
5605}
5606
5607fn extract_write_content_arg(args: &Value) -> Option<String> {
5608 extract_write_content_arg_internal(args, 0)
5609}
5610
5611fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
5612 if depth > 5 {
5613 return None;
5614 }
5615
5616 match args {
5617 Value::String(raw) => {
5618 let trimmed = raw.trim();
5619 if trimmed.is_empty() {
5620 return None;
5621 }
5622 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5624 return sanitize_path_candidate(trimmed);
5625 }
5626 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5627 return extract_file_path_arg_internal(&parsed, depth + 1);
5628 }
5629 sanitize_path_candidate(trimmed)
5630 }
5631 Value::Array(items) => items
5632 .iter()
5633 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
5634 Value::Object(obj) => {
5635 for key in FILE_PATH_KEYS {
5636 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5637 if let Some(path) = sanitize_path_candidate(raw) {
5638 return Some(path);
5639 }
5640 }
5641 }
5642 for container in NESTED_ARGS_KEYS {
5643 if let Some(nested) = obj.get(container) {
5644 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
5645 return Some(path);
5646 }
5647 }
5648 }
5649 None
5650 }
5651 _ => None,
5652 }
5653}
5654
5655fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
5656 if depth > 5 {
5657 return None;
5658 }
5659
5660 match args {
5661 Value::String(raw) => {
5662 let trimmed = raw.trim();
5663 if trimmed.is_empty() {
5664 return None;
5665 }
5666 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5667 return extract_write_content_arg_internal(&parsed, depth + 1);
5668 }
5669 if sanitize_path_candidate(trimmed).is_some()
5672 && !trimmed.contains('\n')
5673 && trimmed.split_whitespace().count() <= 3
5674 {
5675 return None;
5676 }
5677 Some(trimmed.to_string())
5678 }
5679 Value::Array(items) => items
5680 .iter()
5681 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
5682 Value::Object(obj) => {
5683 for key in WRITE_CONTENT_KEYS {
5684 if let Some(value) = obj.get(key) {
5685 if let Some(raw) = value.as_str() {
5686 if !raw.is_empty() {
5687 return Some(raw.to_string());
5688 }
5689 } else if let Some(recovered) =
5690 extract_write_content_arg_internal(value, depth + 1)
5691 {
5692 return Some(recovered);
5693 }
5694 }
5695 }
5696 for container in NESTED_ARGS_KEYS {
5697 if let Some(nested) = obj.get(container) {
5698 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
5699 return Some(content);
5700 }
5701 }
5702 }
5703 None
5704 }
5705 _ => None,
5706 }
5707}
5708
5709fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
5710 let text = latest_assistant_context.trim();
5711 if text.len() < 32 {
5712 return None;
5713 }
5714 Some(text.to_string())
5715}
5716
5717fn set_shell_command(args: Value, command: String) -> Value {
5718 let mut obj = args.as_object().cloned().unwrap_or_default();
5719 obj.insert("command".to_string(), Value::String(command));
5720 Value::Object(obj)
5721}
5722
5723fn extract_shell_command(args: &Value) -> Option<String> {
5724 extract_shell_command_internal(args, 0)
5725}
5726
5727fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
5728 if depth > 5 {
5729 return None;
5730 }
5731
5732 match args {
5733 Value::String(raw) => {
5734 let trimmed = raw.trim();
5735 if trimmed.is_empty() {
5736 return None;
5737 }
5738 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5739 return sanitize_shell_command_candidate(trimmed);
5740 }
5741 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5742 return extract_shell_command_internal(&parsed, depth + 1);
5743 }
5744 sanitize_shell_command_candidate(trimmed)
5745 }
5746 Value::Array(items) => items
5747 .iter()
5748 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
5749 Value::Object(obj) => {
5750 for key in SHELL_COMMAND_KEYS {
5751 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5752 if let Some(command) = sanitize_shell_command_candidate(raw) {
5753 return Some(command);
5754 }
5755 }
5756 }
5757 for container in NESTED_ARGS_KEYS {
5758 if let Some(nested) = obj.get(container) {
5759 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
5760 return Some(command);
5761 }
5762 }
5763 }
5764 None
5765 }
5766 _ => None,
5767 }
5768}
5769
5770fn infer_shell_command_from_text(text: &str) -> Option<String> {
5771 let trimmed = text.trim();
5772 if trimmed.is_empty() {
5773 return None;
5774 }
5775
5776 let mut in_tick = false;
5778 let mut tick_buf = String::new();
5779 for ch in trimmed.chars() {
5780 if ch == '`' {
5781 if in_tick {
5782 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
5783 if looks_like_shell_command(&candidate) {
5784 return Some(candidate);
5785 }
5786 }
5787 tick_buf.clear();
5788 }
5789 in_tick = !in_tick;
5790 continue;
5791 }
5792 if in_tick {
5793 tick_buf.push(ch);
5794 }
5795 }
5796
5797 for line in trimmed.lines() {
5798 let line = line.trim();
5799 if line.is_empty() {
5800 continue;
5801 }
5802 let lower = line.to_ascii_lowercase();
5803 for prefix in [
5804 "run ",
5805 "execute ",
5806 "call ",
5807 "use bash ",
5808 "use shell ",
5809 "bash ",
5810 "shell ",
5811 "powershell ",
5812 "pwsh ",
5813 ] {
5814 if lower.starts_with(prefix) {
5815 let candidate = line[prefix.len()..].trim();
5816 if let Some(command) = sanitize_shell_command_candidate(candidate) {
5817 if looks_like_shell_command(&command) {
5818 return Some(command);
5819 }
5820 }
5821 }
5822 }
5823 }
5824
5825 None
5826}
5827
5828fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
5829 let mut obj = args.as_object().cloned().unwrap_or_default();
5830 if let Some(q) = query {
5831 obj.insert("query".to_string(), Value::String(q));
5832 }
5833 obj.insert(
5834 "__query_source".to_string(),
5835 Value::String(query_source.to_string()),
5836 );
5837 Value::Object(obj)
5838}
5839
5840fn set_webfetch_url_arg(args: Value, url: String) -> Value {
5841 let mut obj = args.as_object().cloned().unwrap_or_default();
5842 obj.insert("url".to_string(), Value::String(url));
5843 Value::Object(obj)
5844}
5845
5846fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
5847 let mut obj = args.as_object().cloned().unwrap_or_default();
5848 obj.insert("goal".to_string(), Value::String(goal));
5849 Value::Object(obj)
5850}
5851
5852fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
5853 let mut obj = args.as_object().cloned().unwrap_or_default();
5854 obj.insert("mode".to_string(), Value::String("apply".to_string()));
5855 obj.insert("plan_id".to_string(), Value::String(plan_id));
5856 obj.insert(
5857 "approve_connector_registration".to_string(),
5858 Value::Bool(true),
5859 );
5860 obj.insert("approve_pack_install".to_string(), Value::Bool(true));
5861 obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
5862 Value::Object(obj)
5863}
5864
5865fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
5866 for key in ["mode"] {
5867 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5868 let mode = value.trim().to_ascii_lowercase();
5869 if !mode.is_empty() {
5870 return Some(mode);
5871 }
5872 }
5873 }
5874 for container in ["arguments", "args", "input", "params"] {
5875 if let Some(obj) = args.get(container) {
5876 if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
5877 let mode = value.trim().to_ascii_lowercase();
5878 if !mode.is_empty() {
5879 return Some(mode);
5880 }
5881 }
5882 }
5883 }
5884 None
5885}
5886
5887fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
5888 for key in ["plan_id", "planId"] {
5889 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5890 let plan_id = value.trim();
5891 if !plan_id.is_empty() {
5892 return Some(plan_id.to_string());
5893 }
5894 }
5895 }
5896 for container in ["arguments", "args", "input", "params"] {
5897 if let Some(obj) = args.get(container) {
5898 for key in ["plan_id", "planId"] {
5899 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5900 let plan_id = value.trim();
5901 if !plan_id.is_empty() {
5902 return Some(plan_id.to_string());
5903 }
5904 }
5905 }
5906 }
5907 }
5908 None
5909}
5910
5911fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
5912 if text.trim().is_empty() {
5913 return None;
5914 }
5915 let bytes = text.as_bytes();
5916 let mut idx = 0usize;
5917 while idx + 5 <= bytes.len() {
5918 if &bytes[idx..idx + 5] != b"plan-" {
5919 idx += 1;
5920 continue;
5921 }
5922 let mut end = idx + 5;
5923 while end < bytes.len() {
5924 let ch = bytes[end] as char;
5925 if ch.is_ascii_alphanumeric() || ch == '-' {
5926 end += 1;
5927 } else {
5928 break;
5929 }
5930 }
5931 if end > idx + 5 {
5932 let candidate = &text[idx..end];
5933 if candidate.len() >= 10 {
5934 return Some(candidate.to_string());
5935 }
5936 }
5937 idx = end.saturating_add(1);
5938 }
5939 None
5940}
5941
5942fn is_pack_builder_confirmation_text(text: &str) -> bool {
5943 let trimmed = text.trim();
5944 if trimmed.is_empty() {
5945 return false;
5946 }
5947 let lower = trimmed.to_ascii_lowercase();
5948 matches!(
5949 lower.as_str(),
5950 "confirm"
5951 | "confirmed"
5952 | "approve"
5953 | "approved"
5954 | "yes"
5955 | "y"
5956 | "ok"
5957 | "okay"
5958 | "go"
5959 | "go ahead"
5960 | "ship it"
5961 | "do it"
5962 | "apply"
5963 | "run it"
5964 | "✅"
5965 | "👍"
5966 )
5967}
5968
5969fn infer_pack_builder_apply_plan_id(
5970 latest_user_text: &str,
5971 latest_assistant_context: &str,
5972) -> Option<String> {
5973 if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
5974 return Some(plan_id);
5975 }
5976 if !is_pack_builder_confirmation_text(latest_user_text) {
5977 return None;
5978 }
5979 extract_pack_builder_plan_id_from_text(latest_assistant_context)
5980}
5981
5982fn ensure_pack_builder_default_mode(args: Value) -> Value {
5983 let mut obj = args.as_object().cloned().unwrap_or_default();
5984 let has_mode = obj
5985 .get("mode")
5986 .and_then(Value::as_str)
5987 .map(str::trim)
5988 .is_some_and(|v| !v.is_empty());
5989 if !has_mode {
5990 obj.insert("mode".to_string(), Value::String("preview".to_string()));
5991 }
5992 Value::Object(obj)
5993}
5994
5995fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
5996 const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
5997 for key in URL_KEYS {
5998 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5999 if let Some(url) = sanitize_url_candidate(value) {
6000 return Some(url);
6001 }
6002 }
6003 }
6004 for container in ["arguments", "args", "input", "params"] {
6005 if let Some(obj) = args.get(container) {
6006 for key in URL_KEYS {
6007 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6008 if let Some(url) = sanitize_url_candidate(value) {
6009 return Some(url);
6010 }
6011 }
6012 }
6013 }
6014 }
6015 args.as_str().and_then(sanitize_url_candidate)
6016}
6017
6018fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
6019 const GOAL_KEYS: [&str; 1] = ["goal"];
6020 for key in GOAL_KEYS {
6021 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6022 let trimmed = value.trim();
6023 if !trimmed.is_empty() {
6024 return Some(trimmed.to_string());
6025 }
6026 }
6027 }
6028 for container in ["arguments", "args", "input", "params"] {
6029 if let Some(obj) = args.get(container) {
6030 for key in GOAL_KEYS {
6031 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6032 let trimmed = value.trim();
6033 if !trimmed.is_empty() {
6034 return Some(trimmed.to_string());
6035 }
6036 }
6037 }
6038 }
6039 }
6040 args.as_str()
6041 .map(str::trim)
6042 .filter(|v| !v.is_empty())
6043 .map(ToString::to_string)
6044}
6045
6046fn extract_websearch_query(args: &Value) -> Option<String> {
6047 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6048 for key in QUERY_KEYS {
6049 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6050 if let Some(query) = sanitize_websearch_query_candidate(value) {
6051 return Some(query);
6052 }
6053 }
6054 }
6055 for container in ["arguments", "args", "input", "params"] {
6056 if let Some(obj) = args.get(container) {
6057 for key in QUERY_KEYS {
6058 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6059 if let Some(query) = sanitize_websearch_query_candidate(value) {
6060 return Some(query);
6061 }
6062 }
6063 }
6064 }
6065 }
6066 args.as_str().and_then(sanitize_websearch_query_candidate)
6067}
6068
6069fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
6070 let trimmed = raw.trim();
6071 if trimmed.is_empty() {
6072 return None;
6073 }
6074
6075 let lower = trimmed.to_ascii_lowercase();
6076 if let Some(start) = lower.find("<arg_value>") {
6077 let value_start = start + "<arg_value>".len();
6078 let tail = &trimmed[value_start..];
6079 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
6080 &tail[..end]
6081 } else {
6082 tail
6083 };
6084 let cleaned = value.trim();
6085 if !cleaned.is_empty() {
6086 return Some(cleaned.to_string());
6087 }
6088 }
6089
6090 let without_wrappers = trimmed
6091 .replace("<arg_key>", " ")
6092 .replace("</arg_key>", " ")
6093 .replace("<arg_value>", " ")
6094 .replace("</arg_value>", " ");
6095 let collapsed = without_wrappers
6096 .split_whitespace()
6097 .collect::<Vec<_>>()
6098 .join(" ");
6099 if collapsed.is_empty() {
6100 return None;
6101 }
6102
6103 let collapsed_lower = collapsed.to_ascii_lowercase();
6104 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
6105 let offset = collapsed.len() - rest.len();
6106 let q = collapsed[offset..].trim();
6107 if !q.is_empty() {
6108 return Some(q.to_string());
6109 }
6110 }
6111 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
6112 let offset = collapsed.len() - rest.len();
6113 let q = collapsed[offset..].trim();
6114 if !q.is_empty() {
6115 return Some(q.to_string());
6116 }
6117 }
6118
6119 Some(collapsed)
6120}
6121
6122fn infer_websearch_query_from_text(text: &str) -> Option<String> {
6123 let trimmed = text.trim();
6124 if trimmed.is_empty() {
6125 return None;
6126 }
6127
6128 let lower = trimmed.to_lowercase();
6129 const PREFIXES: [&str; 11] = [
6130 "web search",
6131 "websearch",
6132 "search web for",
6133 "search web",
6134 "search for",
6135 "search",
6136 "look up",
6137 "lookup",
6138 "find",
6139 "web lookup",
6140 "query",
6141 ];
6142
6143 let mut candidate = trimmed;
6144 for prefix in PREFIXES {
6145 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
6146 let remainder = trimmed[prefix.len()..]
6147 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
6148 candidate = remainder;
6149 break;
6150 }
6151 }
6152
6153 let normalized = candidate
6154 .trim()
6155 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
6156 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
6157 .trim()
6158 .to_string();
6159
6160 if normalized.split_whitespace().count() < 2 {
6161 return None;
6162 }
6163 Some(normalized)
6164}
6165
6166fn infer_file_path_from_text(text: &str) -> Option<String> {
6167 let trimmed = text.trim();
6168 if trimmed.is_empty() {
6169 return None;
6170 }
6171
6172 let mut candidates: Vec<String> = Vec::new();
6173
6174 let mut in_tick = false;
6176 let mut tick_buf = String::new();
6177 for ch in trimmed.chars() {
6178 if ch == '`' {
6179 if in_tick {
6180 let cand = sanitize_path_candidate(&tick_buf);
6181 if let Some(path) = cand {
6182 candidates.push(path);
6183 }
6184 tick_buf.clear();
6185 }
6186 in_tick = !in_tick;
6187 continue;
6188 }
6189 if in_tick {
6190 tick_buf.push(ch);
6191 }
6192 }
6193
6194 for raw in trimmed.split_whitespace() {
6196 if let Some(path) = sanitize_path_candidate(raw) {
6197 candidates.push(path);
6198 }
6199 }
6200
6201 let mut deduped = Vec::new();
6202 let mut seen = HashSet::new();
6203 for candidate in candidates {
6204 if seen.insert(candidate.clone()) {
6205 deduped.push(candidate);
6206 }
6207 }
6208
6209 deduped.into_iter().next()
6210}
6211
6212fn infer_workspace_root_from_text(text: &str) -> Option<String> {
6213 text.lines().find_map(|line| {
6214 let trimmed = line.trim();
6215 let value = trimmed.strip_prefix("Workspace:")?.trim();
6216 sanitize_path_candidate(value)
6217 })
6218}
6219
6220fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
6221 let marker = "Required output target:";
6224 if let Some(idx) = text.find(marker) {
6225 let tail = text[idx + marker.len()..].trim_start();
6226 if let Some(start) = tail.find('{') {
6227 let json_candidate = tail[start..]
6228 .lines()
6229 .take_while(|line| {
6230 let trimmed = line.trim();
6231 !(trimmed.is_empty() && !trimmed.starts_with('{'))
6232 })
6233 .collect::<Vec<_>>()
6234 .join("\n");
6235 if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
6236 if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
6237 if let Some(clean) = sanitize_explicit_output_target_path(path) {
6238 return Some(clean);
6239 }
6240 }
6241 }
6242 }
6243 }
6244 let auto_marker = "Create or update `";
6247 if let Some(idx) = text.find(auto_marker) {
6248 let after = &text[idx + auto_marker.len()..];
6249 if let Some(end) = after.find('`') {
6250 let path = after[..end].trim();
6251 if let Some(clean) = sanitize_explicit_output_target_path(path) {
6252 return Some(clean);
6253 }
6254 }
6255 }
6256 None
6257}
6258
6259fn infer_write_file_path_from_text(text: &str) -> Option<String> {
6260 let inferred = infer_file_path_from_text(text)?;
6261 let workspace_root = infer_workspace_root_from_text(text);
6262 if workspace_root
6263 .as_deref()
6264 .is_some_and(|root| root == inferred)
6265 {
6266 return None;
6267 }
6268 Some(inferred)
6269}
6270
6271fn infer_url_from_text(text: &str) -> Option<String> {
6272 let trimmed = text.trim();
6273 if trimmed.is_empty() {
6274 return None;
6275 }
6276
6277 let mut candidates: Vec<String> = Vec::new();
6278
6279 let mut in_tick = false;
6281 let mut tick_buf = String::new();
6282 for ch in trimmed.chars() {
6283 if ch == '`' {
6284 if in_tick {
6285 if let Some(url) = sanitize_url_candidate(&tick_buf) {
6286 candidates.push(url);
6287 }
6288 tick_buf.clear();
6289 }
6290 in_tick = !in_tick;
6291 continue;
6292 }
6293 if in_tick {
6294 tick_buf.push(ch);
6295 }
6296 }
6297
6298 for raw in trimmed.split_whitespace() {
6300 if let Some(url) = sanitize_url_candidate(raw) {
6301 candidates.push(url);
6302 }
6303 }
6304
6305 let mut seen = HashSet::new();
6306 candidates
6307 .into_iter()
6308 .find(|candidate| seen.insert(candidate.clone()))
6309}
6310
6311fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
6312 let trimmed = text.trim();
6313 if trimmed.is_empty() {
6314 None
6315 } else {
6316 Some(trimmed.to_string())
6317 }
6318}
6319
6320fn sanitize_url_candidate(raw: &str) -> Option<String> {
6321 let token = raw
6322 .trim()
6323 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6324 .trim_start_matches(['(', '[', '{', '<'])
6325 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6326 .trim_end_matches('.')
6327 .trim();
6328
6329 if token.is_empty() {
6330 return None;
6331 }
6332 let lower = token.to_ascii_lowercase();
6333 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
6334 return None;
6335 }
6336 Some(token.to_string())
6337}
6338
6339fn clean_path_candidate_token(raw: &str) -> Option<String> {
6340 let token = raw.trim();
6341 let token = token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'));
6342 let token = token.trim_start_matches(['(', '[', '{', '<']);
6343 let token = token.trim_end_matches([',', ';', ':', ')', ']', '}', '>']);
6344 let token = token.trim_end_matches('.').trim();
6345
6346 if token.is_empty() {
6347 return None;
6348 }
6349 Some(token.to_string())
6350}
6351
6352fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
6353 let token = clean_path_candidate_token(raw)?;
6354 let lower = token.to_ascii_lowercase();
6355 if lower.starts_with("http://") || lower.starts_with("https://") {
6356 return None;
6357 }
6358 if is_malformed_tool_path_token(&token) {
6359 return None;
6360 }
6361 if is_root_only_path_token(&token) {
6362 return None;
6363 }
6364 if is_placeholder_path_token(&token) {
6365 return None;
6366 }
6367 if token.ends_with('/') || token.ends_with('\\') {
6368 return None;
6369 }
6370 Some(token.to_string())
6371}
6372
6373fn sanitize_path_candidate(raw: &str) -> Option<String> {
6374 let token = clean_path_candidate_token(raw)?;
6375 let lower = token.to_ascii_lowercase();
6376 if lower.starts_with("http://") || lower.starts_with("https://") {
6377 return None;
6378 }
6379 if is_malformed_tool_path_token(token.as_str()) {
6380 return None;
6381 }
6382 if is_root_only_path_token(token.as_str()) {
6383 return None;
6384 }
6385 if is_placeholder_path_token(token.as_str()) {
6386 return None;
6387 }
6388 if token.ends_with('/') || token.ends_with('\\') {
6389 return None;
6390 }
6391
6392 let looks_like_path = token.contains('/') || token.contains('\\');
6393 let has_file_ext = [
6394 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
6395 ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
6396 ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
6397 ]
6398 .iter()
6399 .any(|ext| lower.ends_with(ext));
6400
6401 if !looks_like_path && !has_file_ext {
6402 return None;
6403 }
6404
6405 Some(token)
6406}
6407
6408fn is_placeholder_path_token(token: &str) -> bool {
6409 let lowered = token.trim().to_ascii_lowercase();
6410 if lowered.is_empty() {
6411 return true;
6412 }
6413 matches!(
6414 lowered.as_str(),
6415 "files/directories"
6416 | "file/directory"
6417 | "relative/or/absolute/path"
6418 | "path/to/file"
6419 | "path/to/your/file"
6420 | "tool/policy"
6421 | "tools/policy"
6422 | "the expected artifact file"
6423 | "workspace/file"
6424 )
6425}
6426
6427fn is_malformed_tool_path_token(token: &str) -> bool {
6428 let lower = token.to_ascii_lowercase();
6429 if lower.contains("<tool_call")
6431 || lower.contains("</tool_call")
6432 || lower.contains("<function=")
6433 || lower.contains("<parameter=")
6434 || lower.contains("</function>")
6435 || lower.contains("</parameter>")
6436 {
6437 return true;
6438 }
6439 if token.contains('\n') || token.contains('\r') {
6441 return true;
6442 }
6443 if token.contains('*') || token.contains('?') {
6445 return true;
6446 }
6447 if lower.starts_with("ctx:") {
6451 return true;
6452 }
6453 if token.matches(':').count() >= 2 {
6456 return true;
6457 }
6458 false
6459}
6460
6461fn is_root_only_path_token(token: &str) -> bool {
6462 let trimmed = token.trim();
6463 if trimmed.is_empty() {
6464 return true;
6465 }
6466 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
6467 return true;
6468 }
6469 let bytes = trimmed.as_bytes();
6471 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
6472 return true;
6473 }
6474 if bytes.len() == 3
6475 && bytes[1] == b':'
6476 && (bytes[0] as char).is_ascii_alphabetic()
6477 && (bytes[2] == b'\\' || bytes[2] == b'/')
6478 {
6479 return true;
6480 }
6481 false
6482}
6483
6484fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
6485 let token = raw
6486 .trim()
6487 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
6488 .trim();
6489 if token.is_empty() {
6490 return None;
6491 }
6492 Some(token.to_string())
6493}
6494
6495fn looks_like_shell_command(candidate: &str) -> bool {
6496 let lower = candidate.to_ascii_lowercase();
6497 if lower.is_empty() {
6498 return false;
6499 }
6500 let first = lower.split_whitespace().next().unwrap_or_default();
6501 let common = [
6502 "rg",
6503 "git",
6504 "cargo",
6505 "pnpm",
6506 "npm",
6507 "node",
6508 "python",
6509 "pytest",
6510 "pwsh",
6511 "powershell",
6512 "cmd",
6513 "dir",
6514 "ls",
6515 "cat",
6516 "type",
6517 "echo",
6518 "cd",
6519 "mkdir",
6520 "cp",
6521 "copy",
6522 "move",
6523 "del",
6524 "rm",
6525 ];
6526 common.contains(&first)
6527 || first.starts_with("get-")
6528 || first.starts_with("./")
6529 || first.starts_with(".\\")
6530 || lower.contains(" | ")
6531 || lower.contains(" && ")
6532 || lower.contains(" ; ")
6533}
6534
6535const FILE_PATH_KEYS: [&str; 10] = [
6536 "path",
6537 "file_path",
6538 "filePath",
6539 "filepath",
6540 "filename",
6541 "file",
6542 "target",
6543 "targetFile",
6544 "absolutePath",
6545 "uri",
6546];
6547
6548const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
6549
6550const WRITE_CONTENT_KEYS: [&str; 8] = [
6551 "content",
6552 "text",
6553 "body",
6554 "value",
6555 "markdown",
6556 "document",
6557 "output",
6558 "file_content",
6559];
6560
6561const NESTED_ARGS_KEYS: [&str; 10] = [
6562 "arguments",
6563 "args",
6564 "input",
6565 "params",
6566 "payload",
6567 "data",
6568 "tool_input",
6569 "toolInput",
6570 "tool_args",
6571 "toolArgs",
6572];
6573
6574fn tool_signature(tool_name: &str, args: &Value) -> String {
6575 let normalized = normalize_tool_name(tool_name);
6576 if normalized == "websearch" {
6577 let query = extract_websearch_query(args)
6578 .unwrap_or_default()
6579 .to_lowercase();
6580 let limit = args
6581 .get("limit")
6582 .or_else(|| args.get("numResults"))
6583 .or_else(|| args.get("num_results"))
6584 .and_then(|v| v.as_u64())
6585 .unwrap_or(8);
6586 let domains = args
6587 .get("domains")
6588 .or_else(|| args.get("domain"))
6589 .map(|v| v.to_string())
6590 .unwrap_or_default();
6591 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
6592 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
6593 }
6594 format!("{}:{}", normalized, args)
6595}
6596
6597fn stable_hash(input: &str) -> String {
6598 let mut hasher = DefaultHasher::new();
6599 input.hash(&mut hasher);
6600 format!("{:016x}", hasher.finish())
6601}
6602
6603fn summarize_tool_outputs(outputs: &[String]) -> String {
6604 outputs
6605 .iter()
6606 .take(6)
6607 .map(|output| truncate_text(output, 600))
6608 .collect::<Vec<_>>()
6609 .join("\n\n")
6610}
6611
6612fn is_os_mismatch_tool_output(output: &str) -> bool {
6613 let lower = output.to_ascii_lowercase();
6614 lower.contains("os error 3")
6615 || lower.contains("system cannot find the path specified")
6616 || lower.contains("command not found")
6617 || lower.contains("is not recognized as an internal or external command")
6618 || lower.contains("shell command blocked on windows")
6619}
6620
6621fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
6622 match requested {
6623 ContextMode::Full => "full",
6624 ContextMode::Compact => "compact",
6625 ContextMode::Auto => {
6626 if auto_compact {
6627 "auto_compact"
6628 } else {
6629 "auto_standard"
6630 }
6631 }
6632 }
6633}
6634
6635fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
6636 let mut sections = Vec::new();
6637 if os_aware_prompts_enabled() {
6638 sections.push(format!(
6639 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
6640 host_os_label(host.os),
6641 shell_family_label(host.shell_family),
6642 path_style_label(host.path_style),
6643 host.arch
6644 ));
6645 }
6646 sections.push(
6647 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
6648Use tool calls to inspect and modify the workspace when needed instead of asking the user
6649to manually run basic discovery steps. Permission prompts may occur for some tools; if
6650a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
6651 .to_string(),
6652 );
6653 sections.push(
6654 "For greetings or simple conversational messages (for example: hi, hello, thanks),
6655respond directly without calling tools."
6656 .to_string(),
6657 );
6658 if host.os == HostOs::Windows {
6659 sections.push(
6660 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
6661Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
6662If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
6663 .to_string(),
6664 );
6665 } else {
6666 sections.push(
6667 "POSIX guidance: standard shell commands are available.
6668Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
6669 .to_string(),
6670 );
6671 }
6672 if !mcp_server_names.is_empty() {
6673 let cap = mcp_catalog_max_servers();
6674 let mut listed = mcp_server_names
6675 .iter()
6676 .take(cap)
6677 .cloned()
6678 .collect::<Vec<_>>();
6679 listed.sort();
6680 let mut catalog = listed
6681 .iter()
6682 .map(|name| format!("- {name}"))
6683 .collect::<Vec<_>>();
6684 if mcp_server_names.len() > cap {
6685 catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
6686 }
6687 sections.push(format!(
6688 "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
6689 catalog.join("\n")
6690 ));
6691 }
6692 sections.join("\n\n")
6693}
6694
6695fn os_aware_prompts_enabled() -> bool {
6696 std::env::var("TANDEM_OS_AWARE_PROMPTS")
6697 .ok()
6698 .map(|v| {
6699 let normalized = v.trim().to_ascii_lowercase();
6700 !(normalized == "0" || normalized == "false" || normalized == "off")
6701 })
6702 .unwrap_or(true)
6703}
6704
6705fn semantic_tool_retrieval_enabled() -> bool {
6706 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
6707 .ok()
6708 .map(|raw| {
6709 !matches!(
6710 raw.trim().to_ascii_lowercase().as_str(),
6711 "0" | "false" | "off" | "no"
6712 )
6713 })
6714 .unwrap_or(true)
6715}
6716
6717fn semantic_tool_retrieval_k() -> usize {
6718 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
6719 .ok()
6720 .and_then(|raw| raw.trim().parse::<usize>().ok())
6721 .filter(|value| *value > 0)
6722 .unwrap_or_else(max_tools_per_call_expanded)
6723}
6724
6725fn mcp_catalog_in_system_prompt_enabled() -> bool {
6726 std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
6727 .ok()
6728 .map(|raw| {
6729 !matches!(
6730 raw.trim().to_ascii_lowercase().as_str(),
6731 "0" | "false" | "off" | "no"
6732 )
6733 })
6734 .unwrap_or(true)
6735}
6736
6737fn mcp_catalog_max_servers() -> usize {
6738 std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
6739 .ok()
6740 .and_then(|raw| raw.trim().parse::<usize>().ok())
6741 .filter(|value| *value > 0)
6742 .unwrap_or(20)
6743}
6744
6745fn host_os_label(os: HostOs) -> &'static str {
6746 match os {
6747 HostOs::Windows => "windows",
6748 HostOs::Linux => "linux",
6749 HostOs::Macos => "macos",
6750 }
6751}
6752
6753fn shell_family_label(shell: ShellFamily) -> &'static str {
6754 match shell {
6755 ShellFamily::Powershell => "powershell",
6756 ShellFamily::Posix => "posix",
6757 }
6758}
6759
6760fn path_style_label(path_style: PathStyle) -> &'static str {
6761 match path_style {
6762 PathStyle::Windows => "windows",
6763 PathStyle::Posix => "posix",
6764 }
6765}
6766
6767fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
6768 let user = user_text.to_lowercase();
6769 let reply = completion.to_lowercase();
6770
6771 let asked_for_project_context = [
6772 "what is this project",
6773 "what's this project",
6774 "what project is this",
6775 "explain this project",
6776 "analyze this project",
6777 "inspect this project",
6778 "look at the project",
6779 "summarize this project",
6780 "show me this project",
6781 "what files are in",
6782 "show files",
6783 "list files",
6784 "read files",
6785 "browse files",
6786 "use glob",
6787 "run glob",
6788 ]
6789 .iter()
6790 .any(|needle| user.contains(needle));
6791
6792 if !asked_for_project_context {
6793 return false;
6794 }
6795
6796 let assistant_claimed_no_access = [
6797 "can't inspect",
6798 "cannot inspect",
6799 "unable to inspect",
6800 "unable to directly inspect",
6801 "can't access",
6802 "cannot access",
6803 "unable to access",
6804 "can't read files",
6805 "cannot read files",
6806 "unable to read files",
6807 "tool restriction",
6808 "tool restrictions",
6809 "don't have visibility",
6810 "no visibility",
6811 "haven't been able to inspect",
6812 "i don't know what this project is",
6813 "need your help to",
6814 "sandbox",
6815 "restriction",
6816 "system restriction",
6817 "permissions restrictions",
6818 ]
6819 .iter()
6820 .any(|needle| reply.contains(needle));
6821
6822 asked_for_project_context && assistant_claimed_no_access
6825}
6826
6827fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
6828 let raw = input.trim();
6829 if !raw.starts_with("/tool ") {
6830 return None;
6831 }
6832 let rest = raw.trim_start_matches("/tool ").trim();
6833 let mut split = rest.splitn(2, ' ');
6834 let tool = normalize_tool_name(split.next()?.trim());
6835 let args = split
6836 .next()
6837 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
6838 .unwrap_or_else(|| json!({}));
6839 Some((tool, args))
6840}
6841
6842fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
6843 let trimmed = input.trim();
6844 if trimmed.is_empty() {
6845 return Vec::new();
6846 }
6847
6848 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
6849 if let Some(found) = extract_tool_call_from_value(&parsed) {
6850 return vec![found];
6851 }
6852 }
6853
6854 if let Some(block) = extract_first_json_object(trimmed) {
6855 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
6856 if let Some(found) = extract_tool_call_from_value(&parsed) {
6857 return vec![found];
6858 }
6859 }
6860 }
6861
6862 parse_function_style_tool_calls(trimmed)
6863}
6864
6865#[cfg(test)]
6866fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
6867 parse_tool_invocations_from_response(input)
6868 .into_iter()
6869 .next()
6870}
6871
6872fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
6873 let mut calls = Vec::new();
6874 let lower = input.to_lowercase();
6875 let names = [
6876 "todo_write",
6877 "todowrite",
6878 "update_todo_list",
6879 "update_todos",
6880 ];
6881 let mut cursor = 0usize;
6882
6883 while cursor < lower.len() {
6884 let mut best: Option<(usize, &str)> = None;
6885 for name in names {
6886 let needle = format!("{name}(");
6887 if let Some(rel_idx) = lower[cursor..].find(&needle) {
6888 let idx = cursor + rel_idx;
6889 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
6890 best = Some((idx, name));
6891 }
6892 }
6893 }
6894
6895 let Some((tool_start, tool_name)) = best else {
6896 break;
6897 };
6898
6899 let open_paren = tool_start + tool_name.len();
6900 if let Some(close_paren) = find_matching_paren(input, open_paren) {
6901 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
6902 let args = parse_function_style_args(args_text.trim());
6903 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
6904 }
6905 cursor = close_paren.saturating_add(1);
6906 } else {
6907 cursor = tool_start.saturating_add(tool_name.len());
6908 }
6909 }
6910
6911 calls
6912}
6913
6914fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
6915 if input.as_bytes().get(open_paren).copied()? != b'(' {
6916 return None;
6917 }
6918
6919 let mut depth = 0usize;
6920 let mut in_single = false;
6921 let mut in_double = false;
6922 let mut escaped = false;
6923
6924 for (offset, ch) in input.get(open_paren..)?.char_indices() {
6925 if escaped {
6926 escaped = false;
6927 continue;
6928 }
6929 if ch == '\\' && (in_single || in_double) {
6930 escaped = true;
6931 continue;
6932 }
6933 if ch == '\'' && !in_double {
6934 in_single = !in_single;
6935 continue;
6936 }
6937 if ch == '"' && !in_single {
6938 in_double = !in_double;
6939 continue;
6940 }
6941 if in_single || in_double {
6942 continue;
6943 }
6944
6945 match ch {
6946 '(' => depth += 1,
6947 ')' => {
6948 depth = depth.saturating_sub(1);
6949 if depth == 0 {
6950 return Some(open_paren + offset);
6951 }
6952 }
6953 _ => {}
6954 }
6955 }
6956
6957 None
6958}
6959
6960fn parse_function_style_args(input: &str) -> Map<String, Value> {
6961 let mut args = Map::new();
6962 if input.trim().is_empty() {
6963 return args;
6964 }
6965
6966 let mut parts = Vec::<String>::new();
6967 let mut current = String::new();
6968 let mut in_single = false;
6969 let mut in_double = false;
6970 let mut escaped = false;
6971 let mut depth_paren = 0usize;
6972 let mut depth_bracket = 0usize;
6973 let mut depth_brace = 0usize;
6974
6975 for ch in input.chars() {
6976 if escaped {
6977 current.push(ch);
6978 escaped = false;
6979 continue;
6980 }
6981 if ch == '\\' && (in_single || in_double) {
6982 current.push(ch);
6983 escaped = true;
6984 continue;
6985 }
6986 if ch == '\'' && !in_double {
6987 in_single = !in_single;
6988 current.push(ch);
6989 continue;
6990 }
6991 if ch == '"' && !in_single {
6992 in_double = !in_double;
6993 current.push(ch);
6994 continue;
6995 }
6996 if in_single || in_double {
6997 current.push(ch);
6998 continue;
6999 }
7000
7001 match ch {
7002 '(' => depth_paren += 1,
7003 ')' => depth_paren = depth_paren.saturating_sub(1),
7004 '[' => depth_bracket += 1,
7005 ']' => depth_bracket = depth_bracket.saturating_sub(1),
7006 '{' => depth_brace += 1,
7007 '}' => depth_brace = depth_brace.saturating_sub(1),
7008 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
7009 let part = current.trim();
7010 if !part.is_empty() {
7011 parts.push(part.to_string());
7012 }
7013 current.clear();
7014 continue;
7015 }
7016 _ => {}
7017 }
7018 current.push(ch);
7019 }
7020 let tail = current.trim();
7021 if !tail.is_empty() {
7022 parts.push(tail.to_string());
7023 }
7024
7025 for part in parts {
7026 let Some((raw_key, raw_value)) = part
7027 .split_once('=')
7028 .or_else(|| part.split_once(':'))
7029 .map(|(k, v)| (k.trim(), v.trim()))
7030 else {
7031 continue;
7032 };
7033 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
7034 if key.is_empty() {
7035 continue;
7036 }
7037 if !is_valid_function_style_key(key) {
7038 continue;
7039 }
7040 let value = parse_scalar_like_value(raw_value);
7041 args.insert(key.to_string(), value);
7042 }
7043
7044 args
7045}
7046
7047fn is_valid_function_style_key(key: &str) -> bool {
7048 let mut chars = key.chars();
7052 let Some(first) = chars.next() else {
7053 return false;
7054 };
7055 if !(first.is_ascii_alphanumeric() || first == '_') {
7056 return false;
7057 }
7058 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
7059}
7060
7061fn parse_scalar_like_value(raw: &str) -> Value {
7062 let trimmed = raw.trim();
7063 if trimmed.is_empty() {
7064 return Value::Null;
7065 }
7066
7067 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
7068 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
7069 {
7070 if trimmed.len() < 2 {
7071 return Value::String(trimmed.to_string());
7072 }
7073 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
7074 }
7075
7076 if trimmed.eq_ignore_ascii_case("true") {
7077 return Value::Bool(true);
7078 }
7079 if trimmed.eq_ignore_ascii_case("false") {
7080 return Value::Bool(false);
7081 }
7082 if trimmed.eq_ignore_ascii_case("null") {
7083 return Value::Null;
7084 }
7085
7086 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
7087 return v;
7088 }
7089 if let Ok(v) = trimmed.parse::<i64>() {
7090 return Value::Number(Number::from(v));
7091 }
7092 if let Ok(v) = trimmed.parse::<f64>() {
7093 if let Some(n) = Number::from_f64(v) {
7094 return Value::Number(n);
7095 }
7096 }
7097
7098 Value::String(trimmed.to_string())
7099}
7100
7101fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
7102 let content = extract_loose_json_string_field(raw, "content")?;
7103 let mut obj = Map::new();
7104 if let Some(path) = extract_loose_json_string_field(raw, "path") {
7105 obj.insert("path".to_string(), Value::String(path));
7106 }
7107 obj.insert("content".to_string(), Value::String(content));
7108 Some(Value::Object(obj))
7109}
7110
7111fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
7112 let pattern = format!("\"{key}\"");
7113 let start = input.find(&pattern)?;
7114 let remainder = input.get(start + pattern.len()..)?;
7115 let colon = remainder.find(':')?;
7116 let value = remainder.get(colon + 1..)?.trim_start();
7117 let value = value.strip_prefix('"')?;
7118 Some(parse_loose_json_string_value(value))
7119}
7120
7121fn parse_loose_json_string_value(input: &str) -> String {
7122 let mut out = String::new();
7123 let mut chars = input.chars().peekable();
7124 let mut closed = false;
7125
7126 while let Some(ch) = chars.next() {
7127 if ch == '"' {
7128 closed = true;
7129 break;
7130 }
7131 if ch != '\\' {
7132 out.push(ch);
7133 continue;
7134 }
7135
7136 let Some(escaped) = chars.next() else {
7137 out.push('\\');
7138 break;
7139 };
7140 match escaped {
7141 '"' => out.push('"'),
7142 '\\' => out.push('\\'),
7143 '/' => out.push('/'),
7144 'b' => out.push('\u{0008}'),
7145 'f' => out.push('\u{000C}'),
7146 'n' => out.push('\n'),
7147 'r' => out.push('\r'),
7148 't' => out.push('\t'),
7149 'u' => {
7150 let mut hex = String::new();
7151 for _ in 0..4 {
7152 let Some(next) = chars.next() else {
7153 break;
7154 };
7155 hex.push(next);
7156 }
7157 if hex.len() == 4 {
7158 if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
7159 if let Some(decoded) = char::from_u32(codepoint as u32) {
7160 out.push(decoded);
7161 continue;
7162 }
7163 }
7164 }
7165 out.push('\\');
7166 out.push('u');
7167 out.push_str(&hex);
7168 }
7169 other => {
7170 out.push('\\');
7171 out.push(other);
7172 }
7173 }
7174 }
7175
7176 if !closed {
7177 return out;
7178 }
7179 out
7180}
7181
7182fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
7183 if is_todo_status_update_args(&args) {
7184 return args;
7185 }
7186
7187 let mut obj = match args {
7188 Value::Object(map) => map,
7189 Value::Array(items) => {
7190 return json!({ "todos": normalize_todo_arg_items(items) });
7191 }
7192 Value::String(text) => {
7193 let derived = extract_todo_candidates_from_text(&text);
7194 if !derived.is_empty() {
7195 return json!({ "todos": derived });
7196 }
7197 return json!({});
7198 }
7199 _ => return json!({}),
7200 };
7201
7202 if obj
7203 .get("todos")
7204 .and_then(|v| v.as_array())
7205 .map(|arr| !arr.is_empty())
7206 .unwrap_or(false)
7207 {
7208 return Value::Object(obj);
7209 }
7210
7211 for alias in ["tasks", "items", "list", "checklist"] {
7212 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
7213 let normalized = normalize_todo_arg_items(items.clone());
7214 if !normalized.is_empty() {
7215 obj.insert("todos".to_string(), Value::Array(normalized));
7216 return Value::Object(obj);
7217 }
7218 }
7219 }
7220
7221 let derived = extract_todo_candidates_from_text(completion);
7222 if !derived.is_empty() {
7223 obj.insert("todos".to_string(), Value::Array(derived));
7224 }
7225 Value::Object(obj)
7226}
7227
7228fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
7229 items
7230 .into_iter()
7231 .filter_map(|item| match item {
7232 Value::String(text) => {
7233 let content = text.trim();
7234 if content.is_empty() {
7235 None
7236 } else {
7237 Some(json!({"content": content}))
7238 }
7239 }
7240 Value::Object(mut obj) => {
7241 if !obj.contains_key("content") {
7242 if let Some(text) = obj.get("text").cloned() {
7243 obj.insert("content".to_string(), text);
7244 } else if let Some(title) = obj.get("title").cloned() {
7245 obj.insert("content".to_string(), title);
7246 } else if let Some(name) = obj.get("name").cloned() {
7247 obj.insert("content".to_string(), name);
7248 }
7249 }
7250 let content = obj
7251 .get("content")
7252 .and_then(|v| v.as_str())
7253 .map(str::trim)
7254 .unwrap_or("");
7255 if content.is_empty() {
7256 None
7257 } else {
7258 Some(Value::Object(obj))
7259 }
7260 }
7261 _ => None,
7262 })
7263 .collect()
7264}
7265
7266fn is_todo_status_update_args(args: &Value) -> bool {
7267 let Some(obj) = args.as_object() else {
7268 return false;
7269 };
7270 let has_status = obj
7271 .get("status")
7272 .and_then(|v| v.as_str())
7273 .map(|s| !s.trim().is_empty())
7274 .unwrap_or(false);
7275 let has_target =
7276 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
7277 has_status && has_target
7278}
7279
7280fn is_empty_todo_write_args(args: &Value) -> bool {
7281 if is_todo_status_update_args(args) {
7282 return false;
7283 }
7284 let Some(obj) = args.as_object() else {
7285 return true;
7286 };
7287 !obj.get("todos")
7288 .and_then(|v| v.as_array())
7289 .map(|arr| !arr.is_empty())
7290 .unwrap_or(false)
7291}
7292
7293fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
7294 let trimmed = raw_args.trim();
7295 if trimmed.is_empty() {
7296 return json!({});
7297 }
7298
7299 let normalized_tool = normalize_tool_name(tool_name);
7300 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
7301 return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
7302 }
7303
7304 if normalized_tool == "write" {
7305 if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
7306 return recovered;
7307 }
7308 }
7309
7310 let kv_args = parse_function_style_args(trimmed);
7313 if !kv_args.is_empty() {
7314 return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
7315 }
7316
7317 if normalized_tool == "websearch" {
7318 if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
7319 return json!({ "query": query });
7320 }
7321 return json!({});
7322 }
7323
7324 Value::String(trimmed.to_string())
7325}
7326
7327fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
7328 let normalized_tool = normalize_tool_name(tool_name);
7329 if normalized_tool != "websearch" {
7330 return parsed;
7331 }
7332
7333 match parsed {
7334 Value::Object(mut obj) => {
7335 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
7336 if let Some(query) = sanitize_websearch_query_candidate(raw) {
7337 obj.insert("query".to_string(), Value::String(query));
7338 }
7339 }
7340 Value::Object(obj)
7341 }
7342 Value::String(s) => match sanitize_websearch_query_candidate(&s) {
7343 Some(query) => json!({ "query": query }),
7344 None => json!({}),
7345 },
7346 other => other,
7347 }
7348}
7349
7350fn has_websearch_query(obj: &Map<String, Value>) -> bool {
7351 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
7352 QUERY_KEYS.iter().any(|key| {
7353 obj.get(*key)
7354 .and_then(|v| v.as_str())
7355 .map(|s| !s.trim().is_empty())
7356 .unwrap_or(false)
7357 })
7358}
7359
7360fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
7361 if let Some(obj) = value.as_object() {
7362 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
7363 return Some((
7364 normalize_tool_name(tool),
7365 obj.get("args").cloned().unwrap_or_else(|| json!({})),
7366 ));
7367 }
7368
7369 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
7370 let args = obj
7371 .get("args")
7372 .cloned()
7373 .or_else(|| obj.get("arguments").cloned())
7374 .unwrap_or_else(|| json!({}));
7375 let normalized_tool = normalize_tool_name(tool);
7376 let args = if let Some(raw) = args.as_str() {
7377 parse_streamed_tool_args(&normalized_tool, raw)
7378 } else {
7379 args
7380 };
7381 return Some((normalized_tool, args));
7382 }
7383
7384 for key in [
7385 "tool_call",
7386 "toolCall",
7387 "call",
7388 "function_call",
7389 "functionCall",
7390 ] {
7391 if let Some(nested) = obj.get(key) {
7392 if let Some(found) = extract_tool_call_from_value(nested) {
7393 return Some(found);
7394 }
7395 }
7396 }
7397
7398 if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
7399 for call in calls {
7400 if let Some(found) = extract_tool_call_from_value(call) {
7401 return Some(found);
7402 }
7403 }
7404 }
7405 }
7406
7407 if let Some(items) = value.as_array() {
7408 for item in items {
7409 if let Some(found) = extract_tool_call_from_value(item) {
7410 return Some(found);
7411 }
7412 }
7413 }
7414
7415 None
7416}
7417
7418fn extract_first_json_object(input: &str) -> Option<String> {
7419 let mut start = None;
7420 let mut depth = 0usize;
7421 for (idx, ch) in input.char_indices() {
7422 if ch == '{' {
7423 if start.is_none() {
7424 start = Some(idx);
7425 }
7426 depth += 1;
7427 } else if ch == '}' {
7428 if depth == 0 {
7429 continue;
7430 }
7431 depth -= 1;
7432 if depth == 0 {
7433 let begin = start?;
7434 let block = input.get(begin..=idx)?;
7435 return Some(block.to_string());
7436 }
7437 }
7438 }
7439 None
7440}
7441
7442fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
7443 let mut seen = HashSet::<String>::new();
7444 let mut todos = Vec::new();
7445
7446 for raw_line in input.lines() {
7447 let mut line = raw_line.trim();
7448 let mut structured_line = false;
7449 if line.is_empty() {
7450 continue;
7451 }
7452 if line.starts_with("```") {
7453 continue;
7454 }
7455 if line.ends_with(':') {
7456 continue;
7457 }
7458 if let Some(rest) = line
7459 .strip_prefix("- [ ]")
7460 .or_else(|| line.strip_prefix("* [ ]"))
7461 .or_else(|| line.strip_prefix("- [x]"))
7462 .or_else(|| line.strip_prefix("* [x]"))
7463 {
7464 line = rest.trim();
7465 structured_line = true;
7466 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
7467 line = rest.trim();
7468 structured_line = true;
7469 } else {
7470 let bytes = line.as_bytes();
7471 let mut i = 0usize;
7472 while i < bytes.len() && bytes[i].is_ascii_digit() {
7473 i += 1;
7474 }
7475 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
7476 line = line[i + 1..].trim();
7477 structured_line = true;
7478 }
7479 }
7480 if !structured_line {
7481 continue;
7482 }
7483
7484 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
7485 if content.len() < 5 || content.len() > 180 {
7486 continue;
7487 }
7488 let key = content.to_lowercase();
7489 if seen.contains(&key) {
7490 continue;
7491 }
7492 seen.insert(key);
7493 todos.push(json!({ "content": content }));
7494 if todos.len() >= 25 {
7495 break;
7496 }
7497 }
7498
7499 todos
7500}
7501
7502async fn emit_plan_todo_fallback(
7503 storage: std::sync::Arc<Storage>,
7504 bus: &EventBus,
7505 session_id: &str,
7506 message_id: &str,
7507 completion: &str,
7508) {
7509 let todos = extract_todo_candidates_from_text(completion);
7510 if todos.is_empty() {
7511 return;
7512 }
7513
7514 let invoke_part = WireMessagePart::tool_invocation(
7515 session_id,
7516 message_id,
7517 "todo_write",
7518 json!({"todos": todos.clone()}),
7519 );
7520 let call_id = invoke_part.id.clone();
7521 bus.publish(EngineEvent::new(
7522 "message.part.updated",
7523 json!({"part": invoke_part}),
7524 ));
7525
7526 if storage.set_todos(session_id, todos.clone()).await.is_err() {
7527 let mut failed_part = WireMessagePart::tool_result(
7528 session_id,
7529 message_id,
7530 "todo_write",
7531 Some(json!({"todos": todos.clone()})),
7532 json!(null),
7533 );
7534 failed_part.id = call_id;
7535 failed_part.state = Some("failed".to_string());
7536 failed_part.error = Some("failed to persist plan todos".to_string());
7537 bus.publish(EngineEvent::new(
7538 "message.part.updated",
7539 json!({"part": failed_part}),
7540 ));
7541 return;
7542 }
7543
7544 let normalized = storage.get_todos(session_id).await;
7545 let mut result_part = WireMessagePart::tool_result(
7546 session_id,
7547 message_id,
7548 "todo_write",
7549 Some(json!({"todos": todos.clone()})),
7550 json!({ "todos": normalized }),
7551 );
7552 result_part.id = call_id;
7553 bus.publish(EngineEvent::new(
7554 "message.part.updated",
7555 json!({"part": result_part}),
7556 ));
7557 bus.publish(EngineEvent::new(
7558 "todo.updated",
7559 json!({
7560 "sessionID": session_id,
7561 "todos": normalized
7562 }),
7563 ));
7564}
7565
7566async fn emit_plan_question_fallback(
7567 storage: std::sync::Arc<Storage>,
7568 bus: &EventBus,
7569 session_id: &str,
7570 message_id: &str,
7571 completion: &str,
7572) {
7573 let trimmed = completion.trim();
7574 if trimmed.is_empty() {
7575 return;
7576 }
7577
7578 let hints = extract_todo_candidates_from_text(trimmed)
7579 .into_iter()
7580 .take(6)
7581 .filter_map(|v| {
7582 v.get("content")
7583 .and_then(|c| c.as_str())
7584 .map(ToString::to_string)
7585 })
7586 .collect::<Vec<_>>();
7587
7588 let mut options = hints
7589 .iter()
7590 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
7591 .collect::<Vec<_>>();
7592 if options.is_empty() {
7593 options = vec![
7594 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
7595 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
7596 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
7597 ];
7598 }
7599
7600 let question_payload = vec![json!({
7601 "header":"Planning Input",
7602 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
7603 "options": options,
7604 "multiple": true,
7605 "custom": true
7606 })];
7607
7608 let request = storage
7609 .add_question_request(session_id, message_id, question_payload.clone())
7610 .await
7611 .ok();
7612 bus.publish(EngineEvent::new(
7613 "question.asked",
7614 json!({
7615 "id": request
7616 .as_ref()
7617 .map(|req| req.id.clone())
7618 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7619 "sessionID": session_id,
7620 "messageID": message_id,
7621 "questions": question_payload,
7622 "tool": request.and_then(|req| {
7623 req.tool.map(|tool| {
7624 json!({
7625 "callID": tool.call_id,
7626 "messageID": tool.message_id
7627 })
7628 })
7629 })
7630 }),
7631 ));
7632}
7633
7634#[derive(Debug, Clone, Copy)]
7635enum ChatHistoryProfile {
7636 Full,
7637 Standard,
7638 Compact,
7639}
7640
7641async fn load_chat_history(
7642 storage: std::sync::Arc<Storage>,
7643 session_id: &str,
7644 profile: ChatHistoryProfile,
7645) -> Vec<ChatMessage> {
7646 let Some(session) = storage.get_session(session_id).await else {
7647 return Vec::new();
7648 };
7649 let messages = session
7650 .messages
7651 .into_iter()
7652 .map(|m| {
7653 let role = format!("{:?}", m.role).to_lowercase();
7654 let content = m
7655 .parts
7656 .into_iter()
7657 .map(|part| match part {
7658 MessagePart::Text { text } => text,
7659 MessagePart::Reasoning { text } => text,
7660 MessagePart::ToolInvocation {
7661 tool,
7662 args,
7663 result,
7664 error,
7665 } => summarize_tool_invocation_for_history(
7666 &tool,
7667 &args,
7668 result.as_ref(),
7669 error.as_deref(),
7670 ),
7671 })
7672 .collect::<Vec<_>>()
7673 .join("\n");
7674 ChatMessage {
7675 role,
7676 content,
7677 attachments: Vec::new(),
7678 }
7679 })
7680 .collect::<Vec<_>>();
7681 compact_chat_history(messages, profile)
7682}
7683
7684fn summarize_tool_invocation_for_history(
7685 tool: &str,
7686 args: &Value,
7687 result: Option<&Value>,
7688 error: Option<&str>,
7689) -> String {
7690 let mut segments = vec![format!("Tool {tool}")];
7691 if !args.is_null()
7692 && !args.as_object().is_some_and(|value| value.is_empty())
7693 && !args
7694 .as_str()
7695 .map(|value| value.trim().is_empty())
7696 .unwrap_or(false)
7697 {
7698 segments.push(format!("args={args}"));
7699 }
7700 if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
7701 segments.push(format!("error={error}"));
7702 }
7703 if let Some(result) = result.filter(|value| !value.is_null()) {
7704 segments.push(format!("result={result}"));
7705 }
7706 if segments.len() == 1 {
7707 segments.push("result={}".to_string());
7708 }
7709 segments.join(" ")
7710}
7711
7712fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
7713 if attachments.is_empty() {
7714 return;
7715 }
7716 if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
7717 message.attachments = attachments.to_vec();
7718 }
7719}
7720
7721async fn build_runtime_attachments(
7722 provider_id: &str,
7723 parts: &[MessagePartInput],
7724) -> Vec<ChatAttachment> {
7725 if !supports_image_attachments(provider_id) {
7726 return Vec::new();
7727 }
7728
7729 let mut attachments = Vec::new();
7730 for part in parts {
7731 let MessagePartInput::File { mime, url, .. } = part else {
7732 continue;
7733 };
7734 if !mime.to_ascii_lowercase().starts_with("image/") {
7735 continue;
7736 }
7737 if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
7738 attachments.push(ChatAttachment::ImageUrl { url: source_url });
7739 }
7740 }
7741
7742 attachments
7743}
7744
7745fn supports_image_attachments(provider_id: &str) -> bool {
7746 matches!(
7747 provider_id,
7748 "openai"
7749 | "openrouter"
7750 | "ollama"
7751 | "groq"
7752 | "mistral"
7753 | "together"
7754 | "azure"
7755 | "bedrock"
7756 | "vertex"
7757 | "copilot"
7758 )
7759}
7760
7761async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
7762 let trimmed = url.trim();
7763 if trimmed.is_empty() {
7764 return None;
7765 }
7766 if trimmed.starts_with("http://")
7767 || trimmed.starts_with("https://")
7768 || trimmed.starts_with("data:")
7769 {
7770 return Some(trimmed.to_string());
7771 }
7772
7773 let file_path = trimmed
7774 .strip_prefix("file://")
7775 .map(PathBuf::from)
7776 .unwrap_or_else(|| PathBuf::from(trimmed));
7777 if !file_path.exists() {
7778 return None;
7779 }
7780
7781 let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
7782 .ok()
7783 .and_then(|v| v.parse::<usize>().ok())
7784 .unwrap_or(20 * 1024 * 1024);
7785
7786 let bytes = match tokio::fs::read(&file_path).await {
7787 Ok(bytes) => bytes,
7788 Err(err) => {
7789 tracing::warn!(
7790 "failed reading local attachment '{}': {}",
7791 file_path.to_string_lossy(),
7792 err
7793 );
7794 return None;
7795 }
7796 };
7797 if bytes.len() > max_bytes {
7798 tracing::warn!(
7799 "local attachment '{}' exceeds max bytes ({} > {})",
7800 file_path.to_string_lossy(),
7801 bytes.len(),
7802 max_bytes
7803 );
7804 return None;
7805 }
7806
7807 use base64::Engine as _;
7808 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
7809 Some(format!("data:{mime};base64,{b64}"))
7810}
7811
7812struct ToolSideEventContext<'a> {
7813 session_id: &'a str,
7814 message_id: &'a str,
7815 tool: &'a str,
7816 args: &'a serde_json::Value,
7817 metadata: &'a serde_json::Value,
7818 workspace_root: Option<&'a str>,
7819 effective_cwd: Option<&'a str>,
7820}
7821
7822async fn emit_tool_side_events(
7823 storage: std::sync::Arc<Storage>,
7824 bus: &EventBus,
7825 ctx: ToolSideEventContext<'_>,
7826) {
7827 let ToolSideEventContext {
7828 session_id,
7829 message_id,
7830 tool,
7831 args,
7832 metadata,
7833 workspace_root,
7834 effective_cwd,
7835 } = ctx;
7836 if tool == "todo_write" {
7837 let todos_from_metadata = metadata
7838 .get("todos")
7839 .and_then(|v| v.as_array())
7840 .cloned()
7841 .unwrap_or_default();
7842
7843 if !todos_from_metadata.is_empty() {
7844 let _ = storage.set_todos(session_id, todos_from_metadata).await;
7845 } else {
7846 let current = storage.get_todos(session_id).await;
7847 if let Some(updated) = apply_todo_updates_from_args(current, args) {
7848 let _ = storage.set_todos(session_id, updated).await;
7849 }
7850 }
7851
7852 let normalized = storage.get_todos(session_id).await;
7853 bus.publish(EngineEvent::new(
7854 "todo.updated",
7855 json!({
7856 "sessionID": session_id,
7857 "todos": normalized,
7858 "workspaceRoot": workspace_root,
7859 "effectiveCwd": effective_cwd
7860 }),
7861 ));
7862 }
7863 if tool == "question" {
7864 let questions = metadata
7865 .get("questions")
7866 .and_then(|v| v.as_array())
7867 .cloned()
7868 .unwrap_or_default();
7869 if questions.is_empty() {
7870 tracing::warn!(
7871 "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
7872 session_id,
7873 message_id
7874 );
7875 } else {
7876 let request = storage
7877 .add_question_request(session_id, message_id, questions.clone())
7878 .await
7879 .ok();
7880 bus.publish(EngineEvent::new(
7881 "question.asked",
7882 json!({
7883 "id": request
7884 .as_ref()
7885 .map(|req| req.id.clone())
7886 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7887 "sessionID": session_id,
7888 "messageID": message_id,
7889 "questions": questions,
7890 "tool": request.and_then(|req| {
7891 req.tool.map(|tool| {
7892 json!({
7893 "callID": tool.call_id,
7894 "messageID": tool.message_id
7895 })
7896 })
7897 }),
7898 "workspaceRoot": workspace_root,
7899 "effectiveCwd": effective_cwd
7900 }),
7901 ));
7902 }
7903 }
7904 if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
7905 for event in events {
7906 let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
7907 continue;
7908 };
7909 if !event_type.starts_with("agent_team.") {
7910 continue;
7911 }
7912 let mut properties = event
7913 .get("properties")
7914 .and_then(|v| v.as_object())
7915 .cloned()
7916 .unwrap_or_default();
7917 properties
7918 .entry("sessionID".to_string())
7919 .or_insert(json!(session_id));
7920 properties
7921 .entry("messageID".to_string())
7922 .or_insert(json!(message_id));
7923 properties
7924 .entry("workspaceRoot".to_string())
7925 .or_insert(json!(workspace_root));
7926 properties
7927 .entry("effectiveCwd".to_string())
7928 .or_insert(json!(effective_cwd));
7929 bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
7930 }
7931 }
7932}
7933
7934fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
7935 let obj = args.as_object()?;
7936 let mut todos = current;
7937 let mut changed = false;
7938
7939 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
7940 for item in items {
7941 let Some(item_obj) = item.as_object() else {
7942 continue;
7943 };
7944 let status = item_obj
7945 .get("status")
7946 .and_then(|v| v.as_str())
7947 .map(normalize_todo_status);
7948 let target = item_obj
7949 .get("task_id")
7950 .or_else(|| item_obj.get("todo_id"))
7951 .or_else(|| item_obj.get("id"));
7952
7953 if let (Some(status), Some(target)) = (status, target) {
7954 changed |= apply_single_todo_status_update(&mut todos, target, &status);
7955 }
7956 }
7957 }
7958
7959 let status = obj
7960 .get("status")
7961 .and_then(|v| v.as_str())
7962 .map(normalize_todo_status);
7963 let target = obj
7964 .get("task_id")
7965 .or_else(|| obj.get("todo_id"))
7966 .or_else(|| obj.get("id"));
7967 if let (Some(status), Some(target)) = (status, target) {
7968 changed |= apply_single_todo_status_update(&mut todos, target, &status);
7969 }
7970
7971 if changed {
7972 Some(todos)
7973 } else {
7974 None
7975 }
7976}
7977
7978fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
7979 let idx_from_value = match target {
7980 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
7981 Value::String(s) => {
7982 let trimmed = s.trim();
7983 trimmed
7984 .parse::<usize>()
7985 .ok()
7986 .map(|v| v.saturating_sub(1))
7987 .or_else(|| {
7988 let digits = trimmed
7989 .chars()
7990 .rev()
7991 .take_while(|c| c.is_ascii_digit())
7992 .collect::<String>()
7993 .chars()
7994 .rev()
7995 .collect::<String>();
7996 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
7997 })
7998 }
7999 _ => None,
8000 };
8001
8002 if let Some(idx) = idx_from_value {
8003 if idx < todos.len() {
8004 if let Some(obj) = todos[idx].as_object_mut() {
8005 obj.insert("status".to_string(), Value::String(status.to_string()));
8006 return true;
8007 }
8008 }
8009 }
8010
8011 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
8012 if let Some(id_target) = id_target {
8013 for todo in todos.iter_mut() {
8014 if let Some(obj) = todo.as_object_mut() {
8015 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
8016 obj.insert("status".to_string(), Value::String(status.to_string()));
8017 return true;
8018 }
8019 }
8020 }
8021 }
8022
8023 false
8024}
8025
8026fn normalize_todo_status(raw: &str) -> String {
8027 match raw.trim().to_lowercase().as_str() {
8028 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
8029 "done" | "complete" | "completed" => "completed".to_string(),
8030 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
8031 "open" | "todo" | "pending" => "pending".to_string(),
8032 other => other.to_string(),
8033 }
8034}
8035
8036fn compact_chat_history(
8037 messages: Vec<ChatMessage>,
8038 profile: ChatHistoryProfile,
8039) -> Vec<ChatMessage> {
8040 let (max_context_chars, keep_recent_messages) = match profile {
8041 ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
8042 ChatHistoryProfile::Standard => (80_000usize, 40usize),
8043 ChatHistoryProfile::Compact => (12_000usize, 12usize),
8044 };
8045
8046 if messages.len() <= keep_recent_messages {
8047 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
8048 if total_chars <= max_context_chars {
8049 return messages;
8050 }
8051 }
8052
8053 let mut kept = messages;
8054 let mut dropped_count = 0usize;
8055 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
8056
8057 while kept.len() > keep_recent_messages || total_chars > max_context_chars {
8058 if kept.is_empty() {
8059 break;
8060 }
8061 let removed = kept.remove(0);
8062 total_chars = total_chars.saturating_sub(removed.content.len());
8063 dropped_count += 1;
8064 }
8065
8066 if dropped_count > 0 {
8067 kept.insert(
8068 0,
8069 ChatMessage {
8070 role: "system".to_string(),
8071 content: format!(
8072 "[history compacted: omitted {} older messages to fit context window]",
8073 dropped_count
8074 ),
8075 attachments: Vec::new(),
8076 },
8077 );
8078 }
8079 kept
8080}
8081
8082#[cfg(test)]
8083mod tests {
8084 use super::*;
8085 use crate::{EventBus, Storage};
8086 use std::sync::{Mutex, OnceLock};
8087 use tandem_types::Session;
8088 use uuid::Uuid;
8089
8090 fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
8091 static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
8092 ENV_TEST_LOCK
8093 .get_or_init(|| Mutex::new(()))
8094 .lock()
8095 .expect("env test lock")
8096 }
8097
8098 #[tokio::test]
8099 async fn todo_updated_event_is_normalized() {
8100 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8101 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8102 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8103 let session_id = session.id.clone();
8104 storage.save_session(session).await.expect("save session");
8105
8106 let bus = EventBus::new();
8107 let mut rx = bus.subscribe();
8108 emit_tool_side_events(
8109 storage.clone(),
8110 &bus,
8111 ToolSideEventContext {
8112 session_id: &session_id,
8113 message_id: "m1",
8114 tool: "todo_write",
8115 args: &json!({"todos":[{"content":"ship parity"}]}),
8116 metadata: &json!({"todos":[{"content":"ship parity"}]}),
8117 workspace_root: Some("."),
8118 effective_cwd: Some("."),
8119 },
8120 )
8121 .await;
8122
8123 let event = rx.recv().await.expect("event");
8124 assert_eq!(event.event_type, "todo.updated");
8125 let todos = event
8126 .properties
8127 .get("todos")
8128 .and_then(|v| v.as_array())
8129 .cloned()
8130 .unwrap_or_default();
8131 assert_eq!(todos.len(), 1);
8132 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
8133 assert_eq!(
8134 todos[0].get("content").and_then(|v| v.as_str()),
8135 Some("ship parity")
8136 );
8137 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
8138 }
8139
8140 #[tokio::test]
8141 async fn question_asked_event_contains_tool_reference() {
8142 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8143 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8144 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8145 let session_id = session.id.clone();
8146 storage.save_session(session).await.expect("save session");
8147
8148 let bus = EventBus::new();
8149 let mut rx = bus.subscribe();
8150 emit_tool_side_events(
8151 storage,
8152 &bus,
8153 ToolSideEventContext {
8154 session_id: &session_id,
8155 message_id: "msg-1",
8156 tool: "question",
8157 args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8158 metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8159 workspace_root: Some("."),
8160 effective_cwd: Some("."),
8161 },
8162 )
8163 .await;
8164
8165 let event = rx.recv().await.expect("event");
8166 assert_eq!(event.event_type, "question.asked");
8167 assert_eq!(
8168 event
8169 .properties
8170 .get("sessionID")
8171 .and_then(|v| v.as_str())
8172 .unwrap_or(""),
8173 session_id
8174 );
8175 let tool = event
8176 .properties
8177 .get("tool")
8178 .cloned()
8179 .unwrap_or_else(|| json!({}));
8180 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
8181 assert_eq!(
8182 tool.get("messageID").and_then(|v| v.as_str()),
8183 Some("msg-1")
8184 );
8185 }
8186
8187 #[test]
8188 fn compact_chat_history_keeps_recent_and_inserts_summary() {
8189 let mut messages = Vec::new();
8190 for i in 0..60 {
8191 messages.push(ChatMessage {
8192 role: "user".to_string(),
8193 content: format!("message-{i}"),
8194 attachments: Vec::new(),
8195 });
8196 }
8197 let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
8198 assert!(compacted.len() <= 41);
8199 assert_eq!(compacted[0].role, "system");
8200 assert!(compacted[0].content.contains("history compacted"));
8201 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
8202 }
8203
8204 #[tokio::test]
8205 async fn load_chat_history_preserves_tool_args_and_error_context() {
8206 let base = std::env::temp_dir().join(format!(
8207 "tandem-core-load-chat-history-error-{}",
8208 uuid::Uuid::new_v4()
8209 ));
8210 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8211 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8212 let session_id = session.id.clone();
8213 storage.save_session(session).await.expect("save session");
8214
8215 let message = Message::new(
8216 MessageRole::User,
8217 vec![
8218 MessagePart::Text {
8219 text: "build the page".to_string(),
8220 },
8221 MessagePart::ToolInvocation {
8222 tool: "write".to_string(),
8223 args: json!({"path":"game.html","content":"<html>draft</html>"}),
8224 result: None,
8225 error: Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
8226 },
8227 ],
8228 );
8229 storage
8230 .append_message(&session_id, message)
8231 .await
8232 .expect("append message");
8233
8234 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8235 let content = history
8236 .iter()
8237 .find(|message| message.role == "user")
8238 .map(|message| message.content.clone())
8239 .unwrap_or_default();
8240 assert!(content.contains("build the page"));
8241 assert!(content.contains("Tool write"));
8242 assert!(content.contains(r#"args={"content":"<html>draft</html>","path":"game.html"}"#));
8243 assert!(content.contains("error=WRITE_ARGS_EMPTY_FROM_PROVIDER"));
8244 }
8245
8246 #[tokio::test]
8247 async fn load_chat_history_preserves_tool_args_and_result_context() {
8248 let base = std::env::temp_dir().join(format!(
8249 "tandem-core-load-chat-history-result-{}",
8250 uuid::Uuid::new_v4()
8251 ));
8252 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8253 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8254 let session_id = session.id.clone();
8255 storage.save_session(session).await.expect("save session");
8256
8257 let message = Message::new(
8258 MessageRole::Assistant,
8259 vec![MessagePart::ToolInvocation {
8260 tool: "glob".to_string(),
8261 args: json!({"pattern":"src/**/*.rs"}),
8262 result: Some(json!({"output":"src/lib.rs\nsrc/main.rs"})),
8263 error: None,
8264 }],
8265 );
8266 storage
8267 .append_message(&session_id, message)
8268 .await
8269 .expect("append message");
8270
8271 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8272 let content = history
8273 .iter()
8274 .find(|message| message.role == "assistant")
8275 .map(|message| message.content.clone())
8276 .unwrap_or_default();
8277 assert!(content.contains("Tool glob"));
8278 assert!(content.contains(r#"args={"pattern":"src/**/*.rs"}"#));
8279 assert!(content.contains(r#"result={"output":"src/lib.rs\nsrc/main.rs"}"#));
8280 }
8281
8282 #[test]
8283 fn extracts_todos_from_checklist_and_numbered_lines() {
8284 let input = r#"
8285Plan:
8286- [ ] Audit current implementation
8287- [ ] Add planner fallback
82881. Add regression test coverage
8289"#;
8290 let todos = extract_todo_candidates_from_text(input);
8291 assert_eq!(todos.len(), 3);
8292 assert_eq!(
8293 todos[0].get("content").and_then(|v| v.as_str()),
8294 Some("Audit current implementation")
8295 );
8296 }
8297
8298 #[test]
8299 fn does_not_extract_todos_from_plain_prose_lines() {
8300 let input = r#"
8301I need more information to proceed.
8302Can you tell me the event size and budget?
8303Once I have that, I can provide a detailed plan.
8304"#;
8305 let todos = extract_todo_candidates_from_text(input);
8306 assert!(todos.is_empty());
8307 }
8308
8309 #[test]
8310 fn parses_wrapped_tool_call_from_markdown_response() {
8311 let input = r#"
8312Here is the tool call:
8313```json
8314{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
8315```
8316"#;
8317 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
8318 assert_eq!(parsed.0, "todo_write");
8319 assert!(parsed.1.get("todos").is_some());
8320 }
8321
8322 #[test]
8323 fn parses_top_level_name_args_tool_call() {
8324 let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
8325 let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
8326 assert_eq!(parsed.0, "bash");
8327 assert_eq!(
8328 parsed.1.get("command").and_then(|v| v.as_str()),
8329 Some("echo hi")
8330 );
8331 }
8332
8333 #[test]
8334 fn parses_function_style_todowrite_call() {
8335 let input = r#"Status: Completed
8336Call: todowrite(task_id=2, status="completed")"#;
8337 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
8338 assert_eq!(parsed.0, "todo_write");
8339 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8340 assert_eq!(
8341 parsed.1.get("status").and_then(|v| v.as_str()),
8342 Some("completed")
8343 );
8344 }
8345
8346 #[test]
8347 fn parses_multiple_function_style_todowrite_calls() {
8348 let input = r#"
8349Call: todowrite(task_id=2, status="completed")
8350Call: todowrite(task_id=3, status="in_progress")
8351"#;
8352 let parsed = parse_tool_invocations_from_response(input);
8353 assert_eq!(parsed.len(), 2);
8354 assert_eq!(parsed[0].0, "todo_write");
8355 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8356 assert_eq!(
8357 parsed[0].1.get("status").and_then(|v| v.as_str()),
8358 Some("completed")
8359 );
8360 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
8361 assert_eq!(
8362 parsed[1].1.get("status").and_then(|v| v.as_str()),
8363 Some("in_progress")
8364 );
8365 }
8366
8367 #[test]
8368 fn applies_todo_status_update_from_task_id_args() {
8369 let current = vec![
8370 json!({"id":"todo-1","content":"a","status":"pending"}),
8371 json!({"id":"todo-2","content":"b","status":"pending"}),
8372 json!({"id":"todo-3","content":"c","status":"pending"}),
8373 ];
8374 let updated =
8375 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
8376 .expect("status update");
8377 assert_eq!(
8378 updated[1].get("status").and_then(|v| v.as_str()),
8379 Some("completed")
8380 );
8381 }
8382
8383 #[test]
8384 fn normalizes_todo_write_tasks_alias() {
8385 let normalized = normalize_todo_write_args(
8386 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
8387 "",
8388 );
8389 let todos = normalized
8390 .get("todos")
8391 .and_then(|v| v.as_array())
8392 .cloned()
8393 .unwrap_or_default();
8394 assert_eq!(todos.len(), 2);
8395 assert_eq!(
8396 todos[0].get("content").and_then(|v| v.as_str()),
8397 Some("Book venue")
8398 );
8399 assert_eq!(
8400 todos[1].get("content").and_then(|v| v.as_str()),
8401 Some("Send invites")
8402 );
8403 }
8404
8405 #[test]
8406 fn normalizes_todo_write_from_completion_when_args_empty() {
8407 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
8408 let normalized = normalize_todo_write_args(json!({}), completion);
8409 let todos = normalized
8410 .get("todos")
8411 .and_then(|v| v.as_array())
8412 .cloned()
8413 .unwrap_or_default();
8414 assert_eq!(todos.len(), 3);
8415 assert!(!is_empty_todo_write_args(&normalized));
8416 }
8417
8418 #[test]
8419 fn empty_todo_write_args_allows_status_updates() {
8420 let args = json!({"task_id": 2, "status":"completed"});
8421 assert!(!is_empty_todo_write_args(&args));
8422 }
8423
8424 #[test]
8425 fn streamed_websearch_args_fallback_to_query_string() {
8426 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
8427 assert_eq!(
8428 parsed.get("query").and_then(|v| v.as_str()),
8429 Some("meaning of life")
8430 );
8431 }
8432
8433 #[test]
8434 fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
8435 assert_eq!(
8436 parse_scalar_like_value("\""),
8437 Value::String("\"".to_string())
8438 );
8439 assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
8440 }
8441
8442 #[test]
8443 fn streamed_websearch_stringified_json_args_are_unwrapped() {
8444 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
8445 assert_eq!(
8446 parsed.get("query").and_then(|v| v.as_str()),
8447 Some("donkey gestation period")
8448 );
8449 }
8450
8451 #[test]
8452 fn streamed_websearch_args_strip_arg_key_value_wrappers() {
8453 let parsed = parse_streamed_tool_args(
8454 "websearch",
8455 "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
8456 );
8457 assert_eq!(
8458 parsed.get("query").and_then(|v| v.as_str()),
8459 Some("taj card what is it benefits how to apply")
8460 );
8461 }
8462
8463 #[test]
8464 fn normalize_tool_args_websearch_infers_from_user_text() {
8465 let normalized =
8466 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
8467 assert_eq!(
8468 normalized.args.get("query").and_then(|v| v.as_str()),
8469 Some("meaning of life")
8470 );
8471 assert_eq!(normalized.args_source, "inferred_from_user");
8472 assert_eq!(normalized.args_integrity, "recovered");
8473 }
8474
8475 #[test]
8476 fn normalize_tool_args_websearch_keeps_existing_query() {
8477 let normalized = normalize_tool_args(
8478 "websearch",
8479 json!({"query":"already set"}),
8480 "web search should not override",
8481 "",
8482 );
8483 assert_eq!(
8484 normalized.args.get("query").and_then(|v| v.as_str()),
8485 Some("already set")
8486 );
8487 assert_eq!(normalized.args_source, "provider_json");
8488 assert_eq!(normalized.args_integrity, "ok");
8489 }
8490
8491 #[test]
8492 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
8493 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
8494 assert!(normalized.query.is_none());
8495 assert!(normalized.missing_terminal);
8496 assert_eq!(normalized.args_source, "missing");
8497 assert_eq!(normalized.args_integrity, "empty");
8498 }
8499
8500 #[test]
8501 fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
8502 let normalized = normalize_tool_args(
8503 "webfetch",
8504 json!({}),
8505 "Please fetch `https://docs.tandem.ac/` in markdown mode",
8506 "",
8507 );
8508 assert!(!normalized.missing_terminal);
8509 assert_eq!(
8510 normalized.args.get("url").and_then(|v| v.as_str()),
8511 Some("https://docs.tandem.ac/")
8512 );
8513 assert_eq!(normalized.args_source, "inferred_from_user");
8514 assert_eq!(normalized.args_integrity, "recovered");
8515 }
8516
8517 #[test]
8518 fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
8519 let normalized = normalize_tool_args(
8520 "webfetch",
8521 json!({"args":{"uri":"https://example.com/page"}}),
8522 "",
8523 "",
8524 );
8525 assert!(!normalized.missing_terminal);
8526 assert_eq!(
8527 normalized.args.get("url").and_then(|v| v.as_str()),
8528 Some("https://example.com/page")
8529 );
8530 assert_eq!(normalized.args_source, "provider_json");
8531 }
8532
8533 #[test]
8534 fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
8535 let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
8536 assert!(normalized.missing_terminal);
8537 assert_eq!(
8538 normalized.missing_terminal_reason.as_deref(),
8539 Some("WEBFETCH_URL_MISSING")
8540 );
8541 }
8542
8543 #[test]
8544 fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
8545 let user_text =
8546 "Create a pack that checks latest headline news every day at 8 AM and emails me.";
8547 let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
8548 assert!(!normalized.missing_terminal);
8549 assert_eq!(
8550 normalized.args.get("goal").and_then(|v| v.as_str()),
8551 Some(user_text)
8552 );
8553 assert_eq!(
8554 normalized.args.get("mode").and_then(|v| v.as_str()),
8555 Some("preview")
8556 );
8557 assert_eq!(normalized.args_source, "inferred_from_user");
8558 assert_eq!(normalized.args_integrity, "recovered");
8559 }
8560
8561 #[test]
8562 fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
8563 let normalized = normalize_tool_args(
8564 "pack_builder",
8565 json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
8566 "new goal should not override",
8567 "",
8568 );
8569 assert!(!normalized.missing_terminal);
8570 assert_eq!(
8571 normalized.args.get("goal").and_then(|v| v.as_str()),
8572 Some("existing goal")
8573 );
8574 assert_eq!(
8575 normalized.args.get("mode").and_then(|v| v.as_str()),
8576 Some("apply")
8577 );
8578 assert_eq!(normalized.args_source, "provider_json");
8579 assert_eq!(normalized.args_integrity, "ok");
8580 }
8581
8582 #[test]
8583 fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
8584 let assistant_context =
8585 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8586 let normalized =
8587 normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
8588 assert!(!normalized.missing_terminal);
8589 assert_eq!(
8590 normalized.args.get("mode").and_then(|v| v.as_str()),
8591 Some("apply")
8592 );
8593 assert_eq!(
8594 normalized.args.get("plan_id").and_then(|v| v.as_str()),
8595 Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
8596 );
8597 assert_eq!(
8598 normalized
8599 .args
8600 .get("approve_pack_install")
8601 .and_then(|v| v.as_bool()),
8602 Some(true)
8603 );
8604 assert_eq!(normalized.args_source, "recovered_from_context");
8605 }
8606
8607 #[test]
8608 fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
8609 let assistant_context =
8610 "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
8611 let normalized = normalize_tool_args(
8612 "pack_builder",
8613 json!({"mode":"apply"}),
8614 "yes",
8615 assistant_context,
8616 );
8617 assert!(!normalized.missing_terminal);
8618 assert_eq!(
8619 normalized.args.get("mode").and_then(|v| v.as_str()),
8620 Some("apply")
8621 );
8622 assert_eq!(
8623 normalized.args.get("plan_id").and_then(|v| v.as_str()),
8624 Some("plan-11111111-2222-3333-4444-555555555555")
8625 );
8626 }
8627
8628 #[test]
8629 fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
8630 let assistant_context =
8631 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8632 let normalized = normalize_tool_args(
8633 "pack_builder",
8634 json!({}),
8635 "create jira sync",
8636 assistant_context,
8637 );
8638 assert!(!normalized.missing_terminal);
8639 assert_eq!(
8640 normalized.args.get("mode").and_then(|v| v.as_str()),
8641 Some("preview")
8642 );
8643 assert_eq!(
8644 normalized.args.get("goal").and_then(|v| v.as_str()),
8645 Some("create jira sync")
8646 );
8647 }
8648
8649 #[test]
8650 fn normalize_tool_args_write_requires_path() {
8651 let normalized = normalize_tool_args("write", json!({}), "", "");
8652 assert!(normalized.missing_terminal);
8653 assert_eq!(
8654 normalized.missing_terminal_reason.as_deref(),
8655 Some("FILE_PATH_MISSING")
8656 );
8657 }
8658
8659 #[test]
8660 fn persisted_failed_tool_args_prefers_normalized_when_raw_is_empty() {
8661 let args = persisted_failed_tool_args(
8662 &json!({}),
8663 &json!({"path":"game.html","content":"<html></html>"}),
8664 );
8665 assert_eq!(args["path"], "game.html");
8666 assert_eq!(args["content"], "<html></html>");
8667 }
8668
8669 #[test]
8670 fn persisted_failed_tool_args_keeps_non_empty_raw_payload() {
8671 let args = persisted_failed_tool_args(
8672 &json!("path=game.html content"),
8673 &json!({"path":"game.html"}),
8674 );
8675 assert_eq!(args, json!("path=game.html content"));
8676 }
8677
8678 #[test]
8679 fn normalize_tool_args_write_recovers_alias_path_key() {
8680 let normalized = normalize_tool_args(
8681 "write",
8682 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
8683 "",
8684 "",
8685 );
8686 assert!(!normalized.missing_terminal);
8687 assert_eq!(
8688 normalized.args.get("path").and_then(|v| v.as_str()),
8689 Some("docs/CONCEPT.md")
8690 );
8691 assert_eq!(
8692 normalized.args.get("content").and_then(|v| v.as_str()),
8693 Some("hello")
8694 );
8695 }
8696
8697 #[test]
8698 fn normalize_tool_args_write_recovers_html_output_target_path() {
8699 let normalized = normalize_tool_args_with_mode(
8700 "write",
8701 json!({"content":"<html></html>"}),
8702 "Execute task.\n\nRequired output target:\n{\n \"path\": \"game.html\",\n \"kind\": \"source\",\n \"operation\": \"create_or_update\"\n}\n",
8703 "",
8704 WritePathRecoveryMode::OutputTargetOnly,
8705 );
8706 assert!(!normalized.missing_terminal);
8707 assert_eq!(
8708 normalized.args.get("path").and_then(|v| v.as_str()),
8709 Some("game.html")
8710 );
8711 }
8712
8713 #[test]
8714 fn normalize_tool_args_read_infers_path_from_user_prompt() {
8715 let normalized = normalize_tool_args(
8716 "read",
8717 json!({}),
8718 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
8719 "",
8720 );
8721 assert!(!normalized.missing_terminal);
8722 assert_eq!(
8723 normalized.args.get("path").and_then(|v| v.as_str()),
8724 Some("FEATURE_LIST.md")
8725 );
8726 assert_eq!(normalized.args_source, "inferred_from_user");
8727 assert_eq!(normalized.args_integrity, "recovered");
8728 }
8729
8730 #[test]
8731 fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
8732 let normalized = normalize_tool_args(
8733 "read",
8734 json!({}),
8735 "generic instruction",
8736 "I will read src-tauri/src/orchestrator/engine.rs first.",
8737 );
8738 assert!(normalized.missing_terminal);
8739 assert_eq!(
8740 normalized.missing_terminal_reason.as_deref(),
8741 Some("FILE_PATH_MISSING")
8742 );
8743 }
8744
8745 #[test]
8746 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
8747 let normalized = normalize_tool_args(
8748 "write",
8749 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
8750 "",
8751 "",
8752 );
8753 assert!(!normalized.missing_terminal);
8754 assert_eq!(
8755 normalized.args.get("path").and_then(|v| v.as_str()),
8756 Some("docs/CONCEPT.md")
8757 );
8758 }
8759
8760 #[test]
8761 fn normalize_tool_args_write_recovers_content_alias() {
8762 let normalized = normalize_tool_args(
8763 "write",
8764 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
8765 "",
8766 "",
8767 );
8768 assert!(!normalized.missing_terminal);
8769 assert_eq!(
8770 normalized.args.get("content").and_then(|v| v.as_str()),
8771 Some("feature notes")
8772 );
8773 }
8774
8775 #[test]
8776 fn normalize_tool_args_write_fails_when_content_missing() {
8777 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
8778 assert!(normalized.missing_terminal);
8779 assert_eq!(
8780 normalized.missing_terminal_reason.as_deref(),
8781 Some("WRITE_CONTENT_MISSING")
8782 );
8783 }
8784
8785 #[test]
8786 fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
8787 let normalized = normalize_tool_args_with_mode(
8788 "write",
8789 json!({}),
8790 "Please implement the screen/state structure in the workspace.",
8791 "",
8792 WritePathRecoveryMode::OutputTargetOnly,
8793 );
8794 assert!(normalized.missing_terminal);
8795 assert_eq!(
8796 normalized.missing_terminal_reason.as_deref(),
8797 Some("FILE_PATH_MISSING")
8798 );
8799 }
8800
8801 #[test]
8802 fn normalize_tool_args_write_output_target_only_recovers_from_dot_slash_path() {
8803 let normalized = normalize_tool_args_with_mode(
8804 "write",
8805 json!({"path":"./","content":"{}"}),
8806 "Required Workspace Output:\n- Create or update `.tandem/runs/automation-v2-run-123/artifacts/research-sources.json` relative to the workspace root.",
8807 "",
8808 WritePathRecoveryMode::OutputTargetOnly,
8809 );
8810 assert!(!normalized.missing_terminal);
8811 assert_eq!(
8812 normalized.args.get("path").and_then(|v| v.as_str()),
8813 Some(".tandem/runs/automation-v2-run-123/artifacts/research-sources.json")
8814 );
8815 }
8816
8817 #[test]
8818 fn normalize_tool_args_write_recovers_content_from_assistant_context() {
8819 let normalized = normalize_tool_args(
8820 "write",
8821 json!({"path":"docs/FEATURES.md"}),
8822 "",
8823 "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
8824 );
8825 assert!(!normalized.missing_terminal);
8826 assert_eq!(
8827 normalized.args.get("path").and_then(|v| v.as_str()),
8828 Some("docs/FEATURES.md")
8829 );
8830 assert_eq!(
8831 normalized.args.get("content").and_then(|v| v.as_str()),
8832 Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
8833 );
8834 assert_eq!(normalized.args_source, "recovered_from_context");
8835 assert_eq!(normalized.args_integrity, "recovered");
8836 }
8837
8838 #[test]
8839 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
8840 let normalized = normalize_tool_args(
8841 "write",
8842 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
8843 "",
8844 "",
8845 );
8846 assert!(!normalized.missing_terminal);
8847 assert_eq!(
8848 normalized.args.get("path").and_then(|v| v.as_str()),
8849 Some("docs/FEATURES.md")
8850 );
8851 assert_eq!(
8852 normalized.args.get("content").and_then(|v| v.as_str()),
8853 Some("Line 1\nLine 2")
8854 );
8855 }
8856
8857 #[test]
8858 fn normalize_tool_args_write_does_not_treat_path_as_content() {
8859 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
8860 assert!(normalized.missing_terminal);
8861 assert_eq!(
8862 normalized.missing_terminal_reason.as_deref(),
8863 Some("WRITE_CONTENT_MISSING")
8864 );
8865 }
8866
8867 #[test]
8868 fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
8869 let normalized = normalize_tool_args(
8870 "mcp.composio_1.gmail_send_email",
8871 json!({
8872 "to": "user123@example.com",
8873 "subject": "Test",
8874 "body": "Hello",
8875 "attachment": {
8876 "s3key": ""
8877 }
8878 }),
8879 "",
8880 "",
8881 );
8882 assert!(normalized.args.get("attachment").is_none());
8883 assert_eq!(normalized.args_source, "sanitized_attachment");
8884 }
8885
8886 #[test]
8887 fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
8888 let normalized = normalize_tool_args(
8889 "mcp.composio_1.gmail_send_email",
8890 json!({
8891 "to": "user123@example.com",
8892 "subject": "Test",
8893 "body": "Hello",
8894 "attachment": {
8895 "s3key": "file_123"
8896 }
8897 }),
8898 "",
8899 "",
8900 );
8901 assert_eq!(
8902 normalized
8903 .args
8904 .get("attachment")
8905 .and_then(|value| value.get("s3key"))
8906 .and_then(|value| value.as_str()),
8907 Some("file_123")
8908 );
8909 }
8910
8911 #[test]
8912 fn classify_required_tool_failure_detects_empty_provider_write_args() {
8913 let reason = classify_required_tool_failure(
8914 &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
8915 true,
8916 1,
8917 false,
8918 false,
8919 );
8920 assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
8921 }
8922
8923 #[test]
8924 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
8925 let normalized = normalize_tool_args(
8926 "read",
8927 json!({}),
8928 "Please read **FEATURE_LIST.md** and summarize.",
8929 "",
8930 );
8931 assert!(!normalized.missing_terminal);
8932 assert_eq!(
8933 normalized.args.get("path").and_then(|v| v.as_str()),
8934 Some("FEATURE_LIST.md")
8935 );
8936 }
8937
8938 #[test]
8939 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
8940 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
8941 assert!(!normalized.missing_terminal);
8942 assert_eq!(
8943 normalized.args.get("command").and_then(|v| v.as_str()),
8944 Some("rg -n \"TODO\" .")
8945 );
8946 assert_eq!(normalized.args_source, "inferred_from_user");
8947 assert_eq!(normalized.args_integrity, "recovered");
8948 }
8949
8950 #[test]
8951 fn normalize_tool_args_read_rejects_root_only_path() {
8952 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
8953 assert!(normalized.missing_terminal);
8954 assert_eq!(
8955 normalized.missing_terminal_reason.as_deref(),
8956 Some("FILE_PATH_MISSING")
8957 );
8958 }
8959
8960 #[test]
8961 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
8962 let normalized =
8963 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
8964 assert!(!normalized.missing_terminal);
8965 assert_eq!(
8966 normalized.args.get("path").and_then(|v| v.as_str()),
8967 Some("CONCEPT.md")
8968 );
8969 assert_eq!(normalized.args_source, "inferred_from_user");
8970 assert_eq!(normalized.args_integrity, "recovered");
8971 }
8972
8973 #[test]
8974 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
8975 let normalized = normalize_tool_args(
8976 "read",
8977 json!({
8978 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
8979 }),
8980 "",
8981 "",
8982 );
8983 assert!(normalized.missing_terminal);
8984 assert_eq!(
8985 normalized.missing_terminal_reason.as_deref(),
8986 Some("FILE_PATH_MISSING")
8987 );
8988 }
8989
8990 #[test]
8991 fn normalize_tool_args_read_rejects_glob_pattern_path() {
8992 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
8993 assert!(normalized.missing_terminal);
8994 assert_eq!(
8995 normalized.missing_terminal_reason.as_deref(),
8996 Some("FILE_PATH_MISSING")
8997 );
8998 }
8999
9000 #[test]
9001 fn normalize_tool_args_read_rejects_placeholder_path() {
9002 let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
9003 assert!(normalized.missing_terminal);
9004 assert_eq!(
9005 normalized.missing_terminal_reason.as_deref(),
9006 Some("FILE_PATH_MISSING")
9007 );
9008 }
9009
9010 #[test]
9011 fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
9012 let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
9013 assert!(normalized.missing_terminal);
9014 assert_eq!(
9015 normalized.missing_terminal_reason.as_deref(),
9016 Some("FILE_PATH_MISSING")
9017 );
9018 }
9019
9020 #[test]
9021 fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
9022 let normalized = normalize_tool_args(
9023 "read",
9024 json!({"path":"tool/policy"}),
9025 "Read `T1011U kitöltési útmutató.pdf` and summarize.",
9026 "",
9027 );
9028 assert!(!normalized.missing_terminal);
9029 assert_eq!(
9030 normalized.args.get("path").and_then(|v| v.as_str()),
9031 Some("T1011U kitöltési útmutató.pdf")
9032 );
9033 assert_eq!(normalized.args_source, "inferred_from_user");
9034 assert_eq!(normalized.args_integrity, "recovered");
9035 }
9036
9037 #[test]
9038 fn normalize_tool_name_strips_default_api_namespace() {
9039 assert_eq!(normalize_tool_name("default_api:read"), "read");
9040 assert_eq!(normalize_tool_name("functions.shell"), "bash");
9041 }
9042
9043 #[test]
9044 fn mcp_server_from_tool_name_parses_server_segment() {
9045 assert_eq!(
9046 mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
9047 Some("arcade")
9048 );
9049 assert_eq!(mcp_server_from_tool_name("read"), None);
9050 assert_eq!(mcp_server_from_tool_name("mcp"), None);
9051 }
9052
9053 #[test]
9054 fn mcp_tools_are_exempt_from_workspace_sandbox_path_checks() {
9055 assert!(is_mcp_tool_name("mcp_list"));
9056 assert!(is_mcp_tool_name("mcp.tandem_mcp.get_doc"));
9057 assert!(is_mcp_tool_name("MCP.TANDEM_MCP.GET_DOC"));
9058 assert!(!is_mcp_tool_name("read"));
9059 assert!(!is_mcp_tool_name("glob"));
9060 }
9061
9062 #[test]
9063 fn batch_helpers_use_name_when_tool_is_wrapper() {
9064 let args = json!({
9065 "tool_calls":[
9066 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
9067 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
9068 ]
9069 });
9070 let calls = extract_batch_calls(&args);
9071 assert_eq!(calls.len(), 2);
9072 assert_eq!(calls[0].0, "read");
9073 assert_eq!(calls[1].0, "glob");
9074 assert!(is_read_only_batch_call(&args));
9075 let sig = batch_tool_signature(&args).unwrap_or_default();
9076 assert!(sig.contains("read:"));
9077 assert!(sig.contains("glob:"));
9078 }
9079
9080 #[test]
9081 fn batch_helpers_resolve_nested_function_name() {
9082 let args = json!({
9083 "tool_calls":[
9084 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
9085 ]
9086 });
9087 let calls = extract_batch_calls(&args);
9088 assert_eq!(calls.len(), 1);
9089 assert_eq!(calls[0].0, "read");
9090 assert!(is_read_only_batch_call(&args));
9091 }
9092
9093 #[test]
9094 fn batch_output_classifier_detects_non_productive_unknown_results() {
9095 let output = r#"
9096[
9097 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
9098 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
9099]
9100"#;
9101 assert!(is_non_productive_batch_output(output));
9102 }
9103
9104 #[test]
9105 fn runtime_prompt_includes_execution_environment_block() {
9106 let prompt = tandem_runtime_system_prompt(
9107 &HostRuntimeContext {
9108 os: HostOs::Windows,
9109 arch: "x86_64".to_string(),
9110 shell_family: ShellFamily::Powershell,
9111 path_style: PathStyle::Windows,
9112 },
9113 &[],
9114 );
9115 assert!(prompt.contains("[Execution Environment]"));
9116 assert!(prompt.contains("Host OS: windows"));
9117 assert!(prompt.contains("Shell: powershell"));
9118 assert!(prompt.contains("Path style: windows"));
9119 }
9120
9121 #[test]
9122 fn runtime_prompt_includes_connected_integrations_block() {
9123 let prompt = tandem_runtime_system_prompt(
9124 &HostRuntimeContext {
9125 os: HostOs::Linux,
9126 arch: "x86_64".to_string(),
9127 shell_family: ShellFamily::Posix,
9128 path_style: PathStyle::Posix,
9129 },
9130 &["notion".to_string(), "github".to_string()],
9131 );
9132 assert!(prompt.contains("[Connected Integrations]"));
9133 assert!(prompt.contains("- notion"));
9134 assert!(prompt.contains("- github"));
9135 }
9136
9137 #[test]
9138 fn detects_web_research_prompt_keywords() {
9139 assert!(requires_web_research_prompt(
9140 "research todays top news stories and include links"
9141 ));
9142 assert!(!requires_web_research_prompt(
9143 "say hello and summarize this text"
9144 ));
9145 }
9146
9147 #[test]
9148 fn detects_email_delivery_prompt_keywords() {
9149 assert!(requires_email_delivery_prompt(
9150 "send a full report with links to user123@example.com"
9151 ));
9152 assert!(!requires_email_delivery_prompt("draft a summary for later"));
9153 }
9154
9155 #[test]
9156 fn completion_claim_detector_flags_sent_language() {
9157 assert!(completion_claims_email_sent(
9158 "Email Status: Sent to user123@example.com."
9159 ));
9160 assert!(!completion_claims_email_sent(
9161 "I could not send email in this run."
9162 ));
9163 }
9164
9165 #[test]
9166 fn email_tool_detector_finds_mcp_gmail_tools() {
9167 let schemas = vec![
9168 ToolSchema::new("read", "", json!({})),
9169 ToolSchema::new("mcp.composio.gmail_send_email", "", json!({})),
9170 ];
9171 assert!(has_email_action_tools(&schemas));
9172 }
9173
9174 #[test]
9175 fn extract_mcp_auth_required_metadata_parses_expected_shape() {
9176 let metadata = json!({
9177 "server": "arcade",
9178 "mcpAuth": {
9179 "required": true,
9180 "challengeId": "abc123",
9181 "authorizationUrl": "https://example.com/oauth",
9182 "message": "Authorize first",
9183 "pending": true,
9184 "blocked": true,
9185 "retryAfterMs": 8000
9186 }
9187 });
9188 let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
9189 assert_eq!(parsed.challenge_id, "abc123");
9190 assert_eq!(parsed.authorization_url, "https://example.com/oauth");
9191 assert_eq!(parsed.message, "Authorize first");
9192 assert_eq!(parsed.server.as_deref(), Some("arcade"));
9193 assert!(parsed.pending);
9194 assert!(parsed.blocked);
9195 assert_eq!(parsed.retry_after_ms, Some(8000));
9196 }
9197
9198 #[test]
9199 fn auth_required_output_detector_matches_auth_text() {
9200 assert!(is_auth_required_tool_output(
9201 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
9202 ));
9203 assert!(is_auth_required_tool_output(
9204 "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
9205 ));
9206 assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
9207 }
9208
9209 #[test]
9210 fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
9211 assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
9212 assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
9213 assert!(!is_productive_tool_output(
9214 "write",
9215 "Tool `write` result:\nWRITE_CONTENT_MISSING"
9216 ));
9217 assert!(!is_productive_tool_output(
9218 "edit",
9219 "Tool `edit` result:\nFILE_PATH_MISSING"
9220 ));
9221 assert!(!is_productive_tool_output(
9222 "write",
9223 "Tool `write` result:\ninvalid_function_parameters"
9224 ));
9225 }
9226
9227 #[test]
9228 fn productive_tool_output_detector_accepts_real_tool_results() {
9229 assert!(is_productive_tool_output(
9230 "write",
9231 "Tool `write` result:\nWrote /tmp/probe.html"
9232 ));
9233 assert!(!is_productive_tool_output(
9234 "write",
9235 "Authorization required for `write`.\nAuthorize here: https://example.com"
9236 ));
9237 }
9238
9239 #[test]
9240 fn glob_empty_result_is_productive() {
9241 assert!(is_productive_tool_output("glob", "Tool `glob` result:\n"));
9242 assert!(is_productive_tool_output("glob", ""));
9243 }
9244
9245 #[test]
9246 fn write_required_node_retries_after_empty_glob() {
9247 assert!(should_retry_nonproductive_required_tool_cycle(
9248 true, false, true, 0
9249 ));
9250 assert!(should_retry_nonproductive_required_tool_cycle(
9251 true, false, true, 1
9252 ));
9253 assert!(!should_retry_nonproductive_required_tool_cycle(
9254 true, false, true, 2
9255 ));
9256 }
9257
9258 #[test]
9259 fn write_required_node_does_not_take_preparatory_retry_after_write_attempt() {
9260 assert!(!should_retry_nonproductive_required_tool_cycle(
9261 true, true, true, 0
9262 ));
9263 assert!(should_retry_nonproductive_required_tool_cycle(
9264 false, true, false, 0
9265 ));
9266 }
9267
9268 #[test]
9269 fn guard_budget_output_detector_matches_expected_text() {
9270 assert!(is_guard_budget_tool_output(
9271 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9272 ));
9273 assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
9274 }
9275
9276 #[test]
9277 fn summarize_guard_budget_outputs_returns_run_scoped_message() {
9278 let outputs = vec![
9279 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9280 .to_string(),
9281 "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
9282 .to_string(),
9283 ];
9284 let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
9285 assert!(summary.contains("per-run tool guard budget"));
9286 assert!(summary.contains("fresh run"));
9287 }
9288
9289 #[test]
9290 fn duplicate_signature_output_detector_matches_expected_text() {
9291 assert!(is_duplicate_signature_limit_output(
9292 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9293 ));
9294 assert!(!is_duplicate_signature_limit_output(
9295 "Tool `read` result: ok"
9296 ));
9297 }
9298
9299 #[test]
9300 fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
9301 let outputs = vec![
9302 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9303 .to_string(),
9304 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9305 .to_string(),
9306 ];
9307 let summary =
9308 summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
9309 assert!(summary.contains("same tool call kept repeating"));
9310 assert!(summary.contains("clearer command target"));
9311 }
9312
9313 #[test]
9314 fn required_tool_mode_unsatisfied_completion_includes_marker() {
9315 let message =
9316 required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
9317 assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
9318 assert!(message.contains("NO_TOOL_CALL_EMITTED"));
9319 assert!(message.contains("tool_mode=required"));
9320 }
9321
9322 #[test]
9323 fn post_tool_final_narrative_generation_is_allowed_after_required_tools_succeed() {
9324 assert!(should_generate_post_tool_final_narrative(
9325 ToolMode::Required,
9326 1
9327 ));
9328 assert!(!should_generate_post_tool_final_narrative(
9329 ToolMode::Required,
9330 0
9331 ));
9332 assert!(should_generate_post_tool_final_narrative(ToolMode::Auto, 0));
9333 }
9334
9335 #[test]
9336 fn post_tool_final_narrative_prompt_preserves_structured_response_requirements() {
9337 let prompt = build_post_tool_final_narrative_prompt(&[String::from(
9338 "Tool `glob` result:\n/home/user123/marketing-tandem/tandem-reference/SOURCES.md",
9339 )]);
9340 assert!(prompt.contains("Preserve any requested output contract"));
9341 assert!(prompt.contains("required JSON structure"));
9342 assert!(prompt.contains("required handoff fields"));
9343 assert!(prompt.contains("required final status object"));
9344 assert!(prompt.contains("Do not stop at a tool summary"));
9345 }
9346
9347 #[test]
9348 fn required_tool_retry_context_mentions_offered_tools() {
9349 let prompt = build_required_tool_retry_context(
9350 "read, write, apply_patch",
9351 RequiredToolFailureKind::ToolCallInvalidArgs,
9352 );
9353 assert!(prompt.contains("Tool access is mandatory"));
9354 assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
9355 assert!(prompt.contains("full `content`"));
9356 assert!(prompt.contains("write, edit, or apply_patch"));
9357 }
9358
9359 #[test]
9360 fn required_tool_retry_context_requires_write_after_read_only_pass() {
9361 let prompt = build_required_tool_retry_context(
9362 "glob, read, write, edit, apply_patch",
9363 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9364 );
9365 assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
9366 assert!(prompt.contains("Inspection is complete"));
9367 assert!(prompt.contains("write, edit, or apply_patch"));
9368 }
9369
9370 #[test]
9371 fn classify_required_tool_failure_detects_invalid_args() {
9372 let reason = classify_required_tool_failure(
9373 &[String::from("WRITE_CONTENT_MISSING")],
9374 true,
9375 1,
9376 false,
9377 false,
9378 );
9379 assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
9380 }
9381
9382 #[test]
9383 fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
9384 assert!(looks_like_unparsed_tool_payload(
9385 r#"{"content":[{"type":"tool_call","name":"write"}]}"#
9386 ));
9387 assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
9388 }
9389
9390 #[test]
9391 fn workspace_write_tool_detection_is_limited_to_mutations() {
9392 assert!(is_workspace_write_tool("write"));
9393 assert!(is_workspace_write_tool("edit"));
9394 assert!(is_workspace_write_tool("apply_patch"));
9395 assert!(!is_workspace_write_tool("read"));
9396 assert!(!is_workspace_write_tool("glob"));
9397 }
9398
9399 #[test]
9400 fn proactive_write_gate_applies_only_before_prewrite_is_satisfied() {
9401 let decision = evaluate_prewrite_gate(
9402 true,
9403 &PrewriteRequirements {
9404 workspace_inspection_required: true,
9405 web_research_required: false,
9406 concrete_read_required: true,
9407 successful_web_research_required: false,
9408 repair_on_unmet_requirements: true,
9409 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9410 },
9411 PrewriteProgress {
9412 productive_write_tool_calls_total: 0,
9413 productive_workspace_inspection_total: 0,
9414 productive_concrete_read_total: 0,
9415 productive_web_research_total: 0,
9416 successful_web_research_total: 0,
9417 required_write_retry_count: 0,
9418 unmet_prewrite_repair_retry_count: 0,
9419 prewrite_gate_waived: false,
9420 },
9421 );
9422 assert!(decision.gate_write);
9423 }
9424
9425 #[test]
9426 fn prewrite_repair_can_start_before_any_write_attempt() {
9427 assert!(should_start_prewrite_repair_before_first_write(
9428 true, 0, false, false
9429 ));
9430 assert!(!should_start_prewrite_repair_before_first_write(
9431 true, 0, true, false
9432 ));
9433 assert!(!should_start_prewrite_repair_before_first_write(
9434 false, 0, false, false
9435 ));
9436 assert!(should_start_prewrite_repair_before_first_write(
9437 false, 0, false, true
9438 ));
9439 }
9440
9441 #[test]
9442 fn prewrite_repair_does_not_fire_after_first_write() {
9443 assert!(!should_start_prewrite_repair_before_first_write(
9444 true, 1, false, false
9445 ));
9446 assert!(!should_start_prewrite_repair_before_first_write(
9447 true, 2, false, true
9448 ));
9449 }
9450
9451 #[test]
9452 fn infer_code_workflow_from_text_detects_code_agent_contract() {
9453 let prompt = "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test";
9454 assert!(infer_code_workflow_from_text(prompt));
9455 }
9456
9457 #[test]
9458 fn infer_code_workflow_from_text_detects_source_target_path() {
9459 let prompt = "Required Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.";
9460 assert!(infer_code_workflow_from_text(prompt));
9461 }
9462
9463 #[test]
9464 fn required_tool_retry_context_for_task_adds_code_loop_guidance() {
9465 let prompt = build_required_tool_retry_context_for_task(
9466 "read, edit, apply_patch, bash",
9467 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9468 "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test\nRequired Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.",
9469 );
9470 assert!(prompt.contains("inspect -> patch -> apply -> test -> repair"));
9471 assert!(prompt.contains("apply_patch"));
9472 assert!(prompt.contains("cargo test"));
9473 assert!(prompt.contains("src/lib.rs"));
9474 }
9475
9476 #[test]
9477 fn write_tool_removed_after_first_productive_write() {
9478 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9479 let repair_on_unmet_requirements = true;
9480 let productive_write_tool_calls_total = 1usize;
9481 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9482 offered.retain(|tool| !is_workspace_write_tool(tool));
9483 }
9484 assert_eq!(offered, vec!["glob", "read", "websearch", "write", "edit"]);
9485 }
9486
9487 #[test]
9488 fn write_tool_removed_after_third_productive_write() {
9489 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9490 let repair_on_unmet_requirements = true;
9491 let productive_write_tool_calls_total = 3usize;
9492 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9493 offered.retain(|tool| !is_workspace_write_tool(tool));
9494 }
9495 assert_eq!(offered, vec!["glob", "read", "websearch"]);
9496 }
9497
9498 #[test]
9499 fn force_write_only_retry_disabled_for_prewrite_repair_nodes() {
9500 let requested_write_required = true;
9501 let required_write_retry_count = 1usize;
9502 let productive_write_tool_calls_total = 0usize;
9503 let prewrite_satisfied = true;
9504 let prewrite_gate_write = false;
9505 let repair_on_unmet_requirements = true;
9506
9507 let force_write_only_retry = requested_write_required
9508 && required_write_retry_count > 0
9509 && (productive_write_tool_calls_total == 0 || prewrite_satisfied)
9510 && !prewrite_gate_write
9511 && !repair_on_unmet_requirements;
9512
9513 assert!(!force_write_only_retry);
9514 }
9515
9516 #[test]
9517 fn infer_required_output_target_path_reads_prompt_json_block() {
9518 let prompt = r#"Execute task.
9519
9520Required output target:
9521{
9522 "path": "src/game.html",
9523 "kind": "source",
9524 "operation": "create"
9525}
9526"#;
9527 assert_eq!(
9528 infer_required_output_target_path_from_text(prompt).as_deref(),
9529 Some("src/game.html")
9530 );
9531 }
9532
9533 #[test]
9534 fn infer_required_output_target_path_accepts_extensionless_target() {
9535 let prompt = r#"Execute task.
9536
9537Required output target:
9538{
9539 "path": "Dockerfile",
9540 "kind": "source",
9541 "operation": "create"
9542}
9543"#;
9544 assert_eq!(
9545 infer_required_output_target_path_from_text(prompt).as_deref(),
9546 Some("Dockerfile")
9547 );
9548 }
9549
9550 #[test]
9551 fn infer_write_file_path_from_text_rejects_workspace_root() {
9552 let prompt = "Workspace: /home/user123/game\nCreate the scaffold in the workspace now.";
9553 assert_eq!(infer_write_file_path_from_text(prompt), None);
9554 }
9555
9556 #[test]
9557 fn duplicate_signature_limit_defaults_to_200_for_general_tools_and_1_for_email_delivery() {
9558 let _guard = env_test_lock();
9559 unsafe {
9560 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9561 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9562 }
9563 assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
9564 assert_eq!(duplicate_signature_limit_for("bash"), 200);
9565 assert_eq!(duplicate_signature_limit_for("write"), 200);
9566 assert_eq!(
9567 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9568 1
9569 );
9570 assert_eq!(
9571 duplicate_signature_limit_for("mcp.composio_1.gmail_create_email_draft"),
9572 1
9573 );
9574 }
9575
9576 #[test]
9577 fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
9578 let parsed = parse_streamed_tool_args("write", "path=game.html content");
9579 assert_ne!(parsed, json!({}));
9580 }
9581
9582 #[test]
9583 fn parse_streamed_tool_args_rejects_malformed_json_fragment_as_function_style() {
9584 let parsed = parse_streamed_tool_args("write", r#"{"allow_empty": null"#);
9585 assert_eq!(parsed, json!(r#"{"allow_empty": null"#));
9586 }
9587
9588 #[test]
9589 fn parse_streamed_tool_args_preserves_large_write_payload() {
9590 let content = "x".repeat(4096);
9591 let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
9592 let parsed = parse_streamed_tool_args("write", &raw_args);
9593 assert_eq!(
9594 parsed.get("path").and_then(|value| value.as_str()),
9595 Some("game.html")
9596 );
9597 assert_eq!(
9598 parsed.get("content").and_then(|value| value.as_str()),
9599 Some(content.as_str())
9600 );
9601 }
9602
9603 #[test]
9604 fn parse_streamed_tool_args_recovers_truncated_write_json() {
9605 let raw_args = concat!(
9606 r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9607 r#"<html lang=\"en\"><body>Neon Drift"#
9608 );
9609 let parsed = parse_streamed_tool_args("write", raw_args);
9610 assert_eq!(
9611 parsed,
9612 json!({
9613 "path": "game.html",
9614 "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
9615 })
9616 );
9617 }
9618
9619 #[test]
9620 fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
9621 let raw_args = concat!(
9622 r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9623 r#"<html lang=\"en\"><body>Neon Drift"#
9624 );
9625 let parsed = parse_streamed_tool_args("write", raw_args);
9626 assert_eq!(parsed.get("path"), None);
9627 assert_eq!(
9628 parsed.get("content").and_then(|value| value.as_str()),
9629 Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
9630 );
9631 }
9632
9633 #[test]
9634 fn duplicate_signature_limit_env_override_respects_minimum_floor() {
9635 let _guard = env_test_lock();
9636 unsafe {
9637 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
9638 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9639 }
9640 assert_eq!(duplicate_signature_limit_for("write"), 200);
9641 assert_eq!(duplicate_signature_limit_for("bash"), 200);
9642 unsafe {
9643 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
9644 }
9645 assert_eq!(duplicate_signature_limit_for("bash"), 250);
9646 unsafe {
9647 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9648 }
9649 }
9650
9651 #[test]
9652 fn email_delivery_duplicate_signature_limit_env_override_respects_floor_of_one() {
9653 let _guard = env_test_lock();
9654 unsafe {
9655 std::env::set_var(
9656 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9657 "1",
9658 );
9659 }
9660 assert_eq!(
9661 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9662 1
9663 );
9664 unsafe {
9665 std::env::set_var(
9666 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9667 "3",
9668 );
9669 }
9670 assert_eq!(
9671 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9672 3
9673 );
9674 unsafe {
9675 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9676 }
9677 }
9678
9679 #[test]
9680 fn email_delivery_detection_is_provider_agnostic() {
9681 assert!(is_email_delivery_tool_name(
9682 "mcp.composio_1.gmail_send_email"
9683 ));
9684 assert!(is_email_delivery_tool_name("mcp.sendgrid.send_email"));
9685 assert!(is_email_delivery_tool_name("mcp.resend.create_email_draft"));
9686 assert!(is_email_delivery_tool_name("mcp.outlook.reply_email"));
9687 assert!(!is_email_delivery_tool_name("mcp.reddit.send_message"));
9688 assert!(!is_email_delivery_tool_name("mcp.github.create_issue"));
9689 }
9690
9691 #[test]
9692 fn websearch_duplicate_signature_limit_is_unset_by_default() {
9693 let _guard = env_test_lock();
9694 unsafe {
9695 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9696 }
9697 assert_eq!(websearch_duplicate_signature_limit(), None);
9698 }
9699
9700 #[test]
9701 fn websearch_duplicate_signature_limit_reads_env() {
9702 let _guard = env_test_lock();
9703 unsafe {
9704 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
9705 }
9706 assert_eq!(websearch_duplicate_signature_limit(), Some(200));
9707 unsafe {
9708 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
9709 }
9710 assert_eq!(websearch_duplicate_signature_limit(), Some(300));
9711 unsafe {
9712 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9713 }
9714 }
9715
9716 #[test]
9717 fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
9718 let outputs = vec![
9719 "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
9720 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
9721 ];
9722 let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
9723 assert!(summary.contains("Authorization is required before I can continue"));
9724 assert!(summary.contains("gmail_sendemail"));
9725 assert!(summary.contains("gmail_whoami"));
9726 }
9727
9728 #[test]
9729 fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
9730 let outputs = vec![
9731 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
9732 "Tool `read` result:\nok".to_string(),
9733 ];
9734 assert!(summarize_auth_pending_outputs(&outputs).is_none());
9735 }
9736
9737 #[test]
9738 fn invalid_tool_args_retry_context_handles_missing_bash_command() {
9739 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9740 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
9741 .expect("retry expected");
9742 assert!(message.contains("required `command` field"));
9743 assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
9744 }
9745
9746 #[test]
9747 fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
9748 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9749 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
9750 .expect("retry expected");
9751 assert!(message.contains("Do not repeat an empty bash call"));
9752 }
9753
9754 #[test]
9755 fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
9756 let outputs = vec!["Tool `read` result:\nok".to_string()];
9757 assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
9758 }
9759
9760 #[test]
9761 fn prewrite_repair_retry_context_prioritizes_research_tools_before_write() {
9762 let requirements = PrewriteRequirements {
9763 workspace_inspection_required: true,
9764 web_research_required: true,
9765 concrete_read_required: true,
9766 successful_web_research_required: true,
9767 repair_on_unmet_requirements: true,
9768 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9769 };
9770 let prompt = build_prewrite_repair_retry_context(
9771 "glob, read, websearch, write",
9772 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9773 r#"Required output target:
9774{
9775 "path": "marketing-brief.md",
9776 "kind": "artifact"
9777}"#,
9778 &requirements,
9779 true,
9780 false,
9781 false,
9782 false,
9783 );
9784 assert!(prompt.contains("requires concrete `read` calls"));
9785 assert!(prompt.contains("call `websearch` with a concrete query now"));
9786 assert!(prompt.contains("Use `read` and `websearch` now to gather evidence"));
9787 assert!(prompt.contains("Do not declare the output blocked"));
9788 assert!(!prompt.contains("blocked-but-substantive artifact"));
9789 assert!(!prompt.contains("Your next response must be a `write` tool call"));
9790 assert!(!prompt.contains("Do not call `glob`, `read`, or `websearch` again"));
9791 }
9792
9793 #[test]
9794 fn empty_completion_retry_context_requires_write_when_prewrite_is_satisfied() {
9795 let requirements = PrewriteRequirements {
9796 workspace_inspection_required: true,
9797 web_research_required: false,
9798 concrete_read_required: true,
9799 successful_web_research_required: false,
9800 repair_on_unmet_requirements: true,
9801 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9802 };
9803 let prompt = build_empty_completion_retry_context(
9804 "glob, read, write",
9805 "Create or update `marketing-brief.md` relative to the workspace root.",
9806 &requirements,
9807 true,
9808 true,
9809 false,
9810 false,
9811 );
9812 assert!(prompt.contains("returned no final output"));
9813 assert!(prompt.contains("marketing-brief.md"));
9814 assert!(prompt.contains("must be a `write` tool call"));
9815 }
9816
9817 #[test]
9818 fn empty_completion_retry_context_mentions_missing_prewrite_work() {
9819 let requirements = PrewriteRequirements {
9820 workspace_inspection_required: true,
9821 web_research_required: true,
9822 concrete_read_required: true,
9823 successful_web_research_required: true,
9824 repair_on_unmet_requirements: true,
9825 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9826 };
9827 let prompt = build_empty_completion_retry_context(
9828 "glob, read, websearch, write",
9829 "Create or update `marketing-brief.md` relative to the workspace root.",
9830 &requirements,
9831 true,
9832 false,
9833 false,
9834 false,
9835 );
9836 assert!(prompt.contains("still need to use `read`"));
9837 assert!(prompt.contains("use `websearch`"));
9838 assert!(prompt.contains("After completing the missing requirement"));
9839 }
9840
9841 #[test]
9842 fn synthesize_artifact_write_completion_from_tool_state_marks_completed() {
9843 let completion = synthesize_artifact_write_completion_from_tool_state(
9844 "Create or update `marketing-brief.md` relative to the workspace root.",
9845 true,
9846 false,
9847 );
9848 assert!(completion.contains("wrote `marketing-brief.md`"));
9849 assert!(completion.contains("\"status\":\"completed\""));
9850 assert!(completion.contains("Runtime validation will verify"));
9851 }
9852
9853 #[test]
9854 fn synthesize_artifact_write_completion_from_tool_state_mentions_waived_evidence() {
9855 let completion = synthesize_artifact_write_completion_from_tool_state(
9856 "Create or update `marketing-brief.md` relative to the workspace root.",
9857 false,
9858 true,
9859 );
9860 assert!(completion.contains("waived in-run"));
9861 assert!(completion.contains("\"status\":\"completed\""));
9862 }
9863
9864 #[test]
9865 fn prewrite_repair_retry_budget_allows_five_repair_attempts() {
9866 assert_eq!(prewrite_repair_retry_max_attempts(), 5);
9867 }
9868
9869 #[test]
9870 fn prewrite_repair_tool_filter_removes_write_until_evidence_is_satisfied() {
9871 let offered = ["glob", "read", "websearch", "write", "edit"];
9872 let filtered = offered
9873 .iter()
9874 .copied()
9875 .filter(|tool| {
9876 tool_matches_unmet_prewrite_repair_requirement(
9877 tool,
9878 &[
9879 "workspace_inspection_required",
9880 "concrete_read_required",
9881 "successful_web_research_required",
9882 ],
9883 )
9884 })
9885 .collect::<Vec<_>>();
9886 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9887 }
9888
9889 #[test]
9890 fn prewrite_repair_tool_filter_restricts_to_glob_and_read_for_concrete_reads() {
9891 let offered = ["glob", "read", "search", "write"];
9892 let filtered = offered
9893 .iter()
9894 .copied()
9895 .filter(|tool| {
9896 tool_matches_unmet_prewrite_repair_requirement(tool, &["concrete_read_required"])
9897 })
9898 .collect::<Vec<_>>();
9899 assert_eq!(filtered, vec!["glob", "read"]);
9900 }
9901
9902 #[test]
9903 fn prewrite_repair_tool_filter_allows_glob_only_for_workspace_inspection() {
9904 let offered = ["glob", "read", "websearch", "write"];
9905 let with_inspection_unmet = offered
9906 .iter()
9907 .copied()
9908 .filter(|tool| {
9909 tool_matches_unmet_prewrite_repair_requirement(
9910 tool,
9911 &["workspace_inspection_required", "concrete_read_required"],
9912 )
9913 })
9914 .collect::<Vec<_>>();
9915 assert_eq!(with_inspection_unmet, vec!["glob", "read"]);
9916
9917 let without_inspection_unmet = offered
9918 .iter()
9919 .copied()
9920 .filter(|tool| {
9921 tool_matches_unmet_prewrite_repair_requirement(
9922 tool,
9923 &["concrete_read_required", "web_research_required"],
9924 )
9925 })
9926 .collect::<Vec<_>>();
9927 assert_eq!(without_inspection_unmet, vec!["glob", "read", "websearch"]);
9928 }
9929
9930 #[test]
9931 fn prewrite_repair_after_glob_restricts_to_glob_read_and_websearch() {
9932 let offered = ["glob", "read", "websearch", "write", "edit"];
9933 let filtered = offered
9934 .iter()
9935 .copied()
9936 .filter(|tool| {
9937 tool_matches_unmet_prewrite_repair_requirement(
9938 tool,
9939 &[
9940 "concrete_read_required",
9941 "successful_web_research_required",
9942 "coverage_mode",
9943 ],
9944 )
9945 })
9946 .collect::<Vec<_>>();
9947 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9948 }
9949
9950 #[test]
9951 fn prewrite_requirements_exhausted_completion_reports_structured_repair_state() {
9952 let message = prewrite_requirements_exhausted_completion(
9953 &["concrete_read_required", "successful_web_research_required"],
9954 2,
9955 0,
9956 );
9957 assert!(message.contains("PREWRITE_REQUIREMENTS_EXHAUSTED"));
9958 assert!(message.contains("\"status\":\"blocked\""));
9959 assert!(message.contains("\"repairAttempt\":2"));
9960 assert!(message.contains("\"repairAttemptsRemaining\":0"));
9961 assert!(message.contains("\"repairExhausted\":true"));
9962 assert!(message.contains("\"unmetRequirements\":[\"concrete_read_required\", \"successful_web_research_required\"]"));
9963 }
9964
9965 #[test]
9966 fn prewrite_waived_write_context_includes_unmet_codes() {
9967 let user_text = "Some task text without output target marker.";
9968 let unmet = vec!["concrete_read_required", "coverage_mode"];
9969 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9970 assert!(ctx.contains("could not be fully satisfied"));
9971 assert!(ctx.contains("concrete_read_required"));
9972 assert!(ctx.contains("coverage_mode"));
9973 assert!(ctx.contains("write"));
9974 assert!(ctx.contains("Do not write a blocked or placeholder file"));
9975 }
9976
9977 #[test]
9978 fn prewrite_waived_write_context_includes_output_path_when_present() {
9979 let user_text = "Required output target: {\"path\": \"marketing-brief.md\"}";
9980 let unmet = vec!["concrete_read_required"];
9981 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9982 assert!(ctx.contains("marketing-brief.md"));
9983 assert!(ctx.contains("`write`"));
9984 }
9985
9986 #[test]
9987 fn prewrite_gate_waived_disables_prewrite_gate_write() {
9988 let requirements = PrewriteRequirements {
9989 workspace_inspection_required: true,
9990 web_research_required: false,
9991 concrete_read_required: true,
9992 successful_web_research_required: false,
9993 repair_on_unmet_requirements: true,
9994 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9995 };
9996 let before = evaluate_prewrite_gate(
9997 true,
9998 &requirements,
9999 PrewriteProgress {
10000 productive_write_tool_calls_total: 0,
10001 productive_workspace_inspection_total: 0,
10002 productive_concrete_read_total: 0,
10003 productive_web_research_total: 0,
10004 successful_web_research_total: 0,
10005 required_write_retry_count: 0,
10006 unmet_prewrite_repair_retry_count: 0,
10007 prewrite_gate_waived: false,
10008 },
10009 );
10010 assert!(before.gate_write, "gate should be active before waiver");
10011 let after = evaluate_prewrite_gate(
10012 true,
10013 &requirements,
10014 PrewriteProgress {
10015 productive_write_tool_calls_total: 0,
10016 productive_workspace_inspection_total: 0,
10017 productive_concrete_read_total: 0,
10018 productive_web_research_total: 0,
10019 successful_web_research_total: 0,
10020 required_write_retry_count: 0,
10021 unmet_prewrite_repair_retry_count: 0,
10022 prewrite_gate_waived: true,
10023 },
10024 );
10025 assert!(!after.gate_write, "gate should be off after waiver");
10026 }
10027
10028 #[test]
10029 fn prewrite_gate_waived_disables_allow_repair_tools() {
10030 let requirements = PrewriteRequirements {
10031 workspace_inspection_required: true,
10032 web_research_required: true,
10033 concrete_read_required: true,
10034 successful_web_research_required: true,
10035 repair_on_unmet_requirements: true,
10036 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10037 };
10038 let before = evaluate_prewrite_gate(
10039 true,
10040 &requirements,
10041 PrewriteProgress {
10042 productive_write_tool_calls_total: 0,
10043 productive_workspace_inspection_total: 0,
10044 productive_concrete_read_total: 0,
10045 productive_web_research_total: 0,
10046 successful_web_research_total: 0,
10047 required_write_retry_count: 0,
10048 unmet_prewrite_repair_retry_count: 1,
10049 prewrite_gate_waived: false,
10050 },
10051 );
10052 assert!(
10053 before.allow_repair_tools,
10054 "repair tools should be active before waiver"
10055 );
10056 let after = evaluate_prewrite_gate(
10057 true,
10058 &requirements,
10059 PrewriteProgress {
10060 productive_write_tool_calls_total: 0,
10061 productive_workspace_inspection_total: 0,
10062 productive_concrete_read_total: 0,
10063 productive_web_research_total: 0,
10064 successful_web_research_total: 0,
10065 required_write_retry_count: 0,
10066 unmet_prewrite_repair_retry_count: 1,
10067 prewrite_gate_waived: true,
10068 },
10069 );
10070 assert!(
10071 !after.allow_repair_tools,
10072 "repair tools should be disabled after waiver"
10073 );
10074 }
10075
10076 #[test]
10077 fn force_write_only_enabled_after_prewrite_waiver() {
10078 let requirements = PrewriteRequirements {
10079 workspace_inspection_required: true,
10080 web_research_required: true,
10081 concrete_read_required: true,
10082 successful_web_research_required: true,
10083 repair_on_unmet_requirements: true,
10084 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10085 };
10086 let decision = evaluate_prewrite_gate(
10087 true,
10088 &requirements,
10089 PrewriteProgress {
10090 productive_write_tool_calls_total: 0,
10091 productive_workspace_inspection_total: 0,
10092 productive_concrete_read_total: 0,
10093 productive_web_research_total: 0,
10094 successful_web_research_total: 0,
10095 required_write_retry_count: 1,
10096 unmet_prewrite_repair_retry_count: 1,
10097 prewrite_gate_waived: true,
10098 },
10099 );
10100 assert!(
10101 decision.force_write_only_retry,
10102 "force_write_only should be active after prewrite waiver + write retry"
10103 );
10104 }
10105
10106 #[test]
10107 fn force_write_only_disabled_before_prewrite_waiver() {
10108 let requirements = PrewriteRequirements {
10109 workspace_inspection_required: true,
10110 web_research_required: true,
10111 concrete_read_required: true,
10112 successful_web_research_required: true,
10113 repair_on_unmet_requirements: true,
10114 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10115 };
10116 let decision = evaluate_prewrite_gate(
10117 true,
10118 &requirements,
10119 PrewriteProgress {
10120 productive_write_tool_calls_total: 0,
10121 productive_workspace_inspection_total: 0,
10122 productive_concrete_read_total: 0,
10123 productive_web_research_total: 0,
10124 successful_web_research_total: 0,
10125 required_write_retry_count: 1,
10126 unmet_prewrite_repair_retry_count: 1,
10127 prewrite_gate_waived: false,
10128 },
10129 );
10130 assert!(
10131 !decision.force_write_only_retry,
10132 "force_write_only should be disabled before waiver for prewrite nodes"
10133 );
10134 }
10135
10136 #[test]
10137 fn parse_budget_override_zero_disables_budget() {
10138 unsafe {
10139 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
10140 }
10141 assert_eq!(
10142 parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
10143 Some(usize::MAX)
10144 );
10145 unsafe {
10146 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10147 }
10148 }
10149
10150 #[test]
10151 fn disable_tool_guard_budgets_env_overrides_all_budgets() {
10152 unsafe {
10153 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10154 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10155 }
10156 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), 1);
10157 assert_eq!(tool_budget_for("websearch"), HARD_TOOL_CALL_CEILING);
10160 unsafe {
10161 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10162 }
10163 }
10164
10165 #[test]
10166 fn email_delivery_budget_can_still_be_explicitly_overridden_when_global_budgets_are_disabled() {
10167 let _guard = env_test_lock();
10168 unsafe {
10169 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10170 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "0");
10171 }
10172 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
10173 unsafe {
10174 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10175 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10176 }
10177 }
10178
10179 #[test]
10180 fn tool_budget_defaults_to_200_calls_and_1_for_email_delivery() {
10181 let _guard = env_test_lock();
10182 unsafe {
10183 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10184 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10185 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10186 std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
10187 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10188 }
10189 assert_eq!(tool_budget_for("bash"), 200);
10190 assert_eq!(tool_budget_for("websearch"), 200);
10191 assert_eq!(tool_budget_for("read"), 200);
10192 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10193 assert_eq!(
10194 tool_budget_for("mcp.composio_1.gmail_create_email_draft"),
10195 1
10196 );
10197 }
10198
10199 #[test]
10200 fn tool_budget_env_override_respects_minimum_floor() {
10201 let _guard = env_test_lock();
10202 unsafe {
10203 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10204 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
10205 std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
10206 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10207 }
10208 assert_eq!(tool_budget_for("bash"), 200);
10209 assert_eq!(tool_budget_for("websearch"), 250);
10210 unsafe {
10211 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10212 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10213 }
10214 }
10215
10216 #[test]
10217 fn email_delivery_tool_budget_env_override_respects_floor_of_one() {
10218 let _guard = env_test_lock();
10219 unsafe {
10220 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10221 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "1");
10222 }
10223 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10224 unsafe {
10225 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "5");
10226 }
10227 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 5);
10228 unsafe {
10229 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10230 }
10231 }
10232
10233 #[test]
10234 fn provider_agnostic_email_tools_share_single_send_budget() {
10235 let _guard = env_test_lock();
10236 unsafe {
10237 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10238 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10239 }
10240 assert_eq!(tool_budget_for("mcp.sendgrid.send_email"), 1);
10241 assert_eq!(tool_budget_for("mcp.resend.create_email_draft"), 1);
10242 assert_eq!(duplicate_signature_limit_for("mcp.outlook.reply_email"), 1);
10243 }
10244}