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