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 tandem_observability::{emit_event, ObservabilityEvent, ProcessKind};
9use tandem_providers::{ChatMessage, ProviderRegistry, StreamChunk, TokenUsage};
10use tandem_tools::{validate_tool_schemas, ToolRegistry};
11use tandem_types::{
12 EngineEvent, HostOs, HostRuntimeContext, Message, MessagePart, MessagePartInput, MessageRole,
13 ModelSpec, PathStyle, SendMessageRequest, ShellFamily,
14};
15use tandem_wire::WireMessagePart;
16use tokio_util::sync::CancellationToken;
17use tracing::Level;
18
19use crate::{
20 derive_session_title_from_prompt, title_needs_repair, AgentDefinition, AgentRegistry,
21 CancellationRegistry, EventBus, PermissionAction, PermissionManager, PluginRegistry, Storage,
22};
23use tokio::sync::RwLock;
24
25#[derive(Default)]
26struct StreamedToolCall {
27 name: String,
28 args: String,
29}
30
31#[derive(Debug, Clone)]
32pub struct SpawnAgentToolContext {
33 pub session_id: String,
34 pub message_id: String,
35 pub tool_call_id: Option<String>,
36 pub args: Value,
37}
38
39#[derive(Debug, Clone)]
40pub struct SpawnAgentToolResult {
41 pub output: String,
42 pub metadata: Value,
43}
44
45#[derive(Debug, Clone)]
46pub struct ToolPolicyContext {
47 pub session_id: String,
48 pub message_id: String,
49 pub tool: String,
50 pub args: Value,
51}
52
53#[derive(Debug, Clone)]
54pub struct ToolPolicyDecision {
55 pub allowed: bool,
56 pub reason: Option<String>,
57}
58
59pub trait SpawnAgentHook: Send + Sync {
60 fn spawn_agent(
61 &self,
62 ctx: SpawnAgentToolContext,
63 ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
64}
65
66pub trait ToolPolicyHook: Send + Sync {
67 fn evaluate_tool(
68 &self,
69 ctx: ToolPolicyContext,
70 ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
71}
72
73#[derive(Debug, Clone)]
74pub struct PromptContextHookContext {
75 pub session_id: String,
76 pub message_id: String,
77 pub provider_id: String,
78 pub model_id: String,
79 pub iteration: usize,
80}
81
82pub trait PromptContextHook: Send + Sync {
83 fn augment_provider_messages(
84 &self,
85 ctx: PromptContextHookContext,
86 messages: Vec<ChatMessage>,
87 ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
88}
89
90#[derive(Clone)]
91pub struct EngineLoop {
92 storage: std::sync::Arc<Storage>,
93 event_bus: EventBus,
94 providers: ProviderRegistry,
95 plugins: PluginRegistry,
96 agents: AgentRegistry,
97 permissions: PermissionManager,
98 tools: ToolRegistry,
99 cancellations: CancellationRegistry,
100 host_runtime_context: HostRuntimeContext,
101 workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
102 session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
103 spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
104 tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
105 prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
106}
107
108impl EngineLoop {
109 #[allow(clippy::too_many_arguments)]
110 pub fn new(
111 storage: std::sync::Arc<Storage>,
112 event_bus: EventBus,
113 providers: ProviderRegistry,
114 plugins: PluginRegistry,
115 agents: AgentRegistry,
116 permissions: PermissionManager,
117 tools: ToolRegistry,
118 cancellations: CancellationRegistry,
119 host_runtime_context: HostRuntimeContext,
120 ) -> Self {
121 Self {
122 storage,
123 event_bus,
124 providers,
125 plugins,
126 agents,
127 permissions,
128 tools,
129 cancellations,
130 host_runtime_context,
131 workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
132 session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
133 spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
134 tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
135 prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
136 }
137 }
138
139 pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
140 *self.spawn_agent_hook.write().await = Some(hook);
141 }
142
143 pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
144 *self.tool_policy_hook.write().await = Some(hook);
145 }
146
147 pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
148 *self.prompt_context_hook.write().await = Some(hook);
149 }
150
151 pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
152 let normalized = allowed_tools
153 .into_iter()
154 .map(|tool| normalize_tool_name(&tool))
155 .filter(|tool| !tool.trim().is_empty())
156 .collect::<Vec<_>>();
157 self.session_allowed_tools
158 .write()
159 .await
160 .insert(session_id.to_string(), normalized);
161 }
162
163 pub async fn clear_session_allowed_tools(&self, session_id: &str) {
164 self.session_allowed_tools.write().await.remove(session_id);
165 }
166
167 pub async fn grant_workspace_override_for_session(
168 &self,
169 session_id: &str,
170 ttl_seconds: u64,
171 ) -> u64 {
172 let expires_at = chrono::Utc::now()
173 .timestamp_millis()
174 .max(0)
175 .saturating_add((ttl_seconds as i64).saturating_mul(1000))
176 as u64;
177 self.workspace_overrides
178 .write()
179 .await
180 .insert(session_id.to_string(), expires_at);
181 expires_at
182 }
183
184 pub async fn run_prompt_async(
185 &self,
186 session_id: String,
187 req: SendMessageRequest,
188 ) -> anyhow::Result<()> {
189 self.run_prompt_async_with_context(session_id, req, None)
190 .await
191 }
192
193 pub async fn run_prompt_async_with_context(
194 &self,
195 session_id: String,
196 req: SendMessageRequest,
197 correlation_id: Option<String>,
198 ) -> anyhow::Result<()> {
199 let session_model = self
200 .storage
201 .get_session(&session_id)
202 .await
203 .and_then(|s| s.model);
204 let (provider_id, model_id_value) =
205 resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
206 anyhow::anyhow!(
207 "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
208 )
209 })?;
210 let correlation_ref = correlation_id.as_deref();
211 let model_id = Some(model_id_value.as_str());
212 let cancel = self.cancellations.create(&session_id).await;
213 emit_event(
214 Level::INFO,
215 ProcessKind::Engine,
216 ObservabilityEvent {
217 event: "provider.call.start",
218 component: "engine.loop",
219 correlation_id: correlation_ref,
220 session_id: Some(&session_id),
221 run_id: None,
222 message_id: None,
223 provider_id: Some(provider_id.as_str()),
224 model_id,
225 status: Some("start"),
226 error_code: None,
227 detail: Some("run_prompt_async dispatch"),
228 },
229 );
230 self.event_bus.publish(EngineEvent::new(
231 "session.status",
232 json!({"sessionID": session_id, "status":"running"}),
233 ));
234 let text = req
235 .parts
236 .iter()
237 .map(|p| match p {
238 MessagePartInput::Text { text } => text.clone(),
239 MessagePartInput::File {
240 mime,
241 filename,
242 url,
243 } => format!(
244 "[file mime={} name={} url={}]",
245 mime,
246 filename.clone().unwrap_or_else(|| "unknown".to_string()),
247 url
248 ),
249 })
250 .collect::<Vec<_>>()
251 .join("\n");
252 self.auto_rename_session_from_user_text(&session_id, &text)
253 .await;
254 let active_agent = self.agents.get(req.agent.as_deref()).await;
255 let mut user_message_id = self
256 .find_recent_matching_user_message_id(&session_id, &text)
257 .await;
258 if user_message_id.is_none() {
259 let user_message = Message::new(
260 MessageRole::User,
261 vec![MessagePart::Text { text: text.clone() }],
262 );
263 let created_message_id = user_message.id.clone();
264 self.storage
265 .append_message(&session_id, user_message)
266 .await?;
267
268 let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
269 self.event_bus.publish(EngineEvent::new(
270 "message.part.updated",
271 json!({
272 "part": user_part,
273 "delta": text,
274 "agent": active_agent.name
275 }),
276 ));
277 user_message_id = Some(created_message_id);
278 }
279 let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
280
281 if cancel.is_cancelled() {
282 self.event_bus.publish(EngineEvent::new(
283 "session.status",
284 json!({"sessionID": session_id, "status":"cancelled"}),
285 ));
286 self.cancellations.remove(&session_id).await;
287 return Ok(());
288 }
289
290 let mut question_tool_used = false;
291 let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
292 if normalize_tool_name(&tool) == "question" {
293 question_tool_used = true;
294 }
295 if !agent_can_use_tool(&active_agent, &tool) {
296 format!(
297 "Tool `{tool}` is not enabled for agent `{}`.",
298 active_agent.name
299 )
300 } else {
301 self.execute_tool_with_permission(
302 &session_id,
303 &user_message_id,
304 tool.clone(),
305 args,
306 active_agent.skills.as_deref(),
307 &text,
308 None,
309 cancel.clone(),
310 )
311 .await?
312 .unwrap_or_default()
313 }
314 } else {
315 let mut completion = String::new();
316 let mut max_iterations = 25usize;
317 let mut followup_context: Option<String> = None;
318 let mut last_tool_outputs: Vec<String> = Vec::new();
319 let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
320 let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
321 let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
322 let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
323 let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
324 let mut websearch_query_blocked = false;
325 let mut auto_workspace_probe_attempted = false;
326
327 while max_iterations > 0 && !cancel.is_cancelled() {
328 let iteration = 26usize.saturating_sub(max_iterations);
329 max_iterations -= 1;
330 let mut messages = load_chat_history(self.storage.clone(), &session_id).await;
331 let mut system_parts =
332 vec![tandem_runtime_system_prompt(&self.host_runtime_context)];
333 if let Some(system) = active_agent.system_prompt.as_ref() {
334 system_parts.push(system.clone());
335 }
336 messages.insert(
337 0,
338 ChatMessage {
339 role: "system".to_string(),
340 content: system_parts.join("\n\n"),
341 },
342 );
343 if let Some(extra) = followup_context.take() {
344 messages.push(ChatMessage {
345 role: "user".to_string(),
346 content: extra,
347 });
348 }
349 if let Some(hook) = self.prompt_context_hook.read().await.clone() {
350 let ctx = PromptContextHookContext {
351 session_id: session_id.clone(),
352 message_id: user_message_id.clone(),
353 provider_id: provider_id.clone(),
354 model_id: model_id_value.clone(),
355 iteration,
356 };
357 if let Ok(augmented) =
358 hook.augment_provider_messages(ctx, messages.clone()).await
359 {
360 messages = augmented;
361 }
362 }
363 let mut tool_schemas = self.tools.list().await;
364 if active_agent.tools.is_some() {
365 tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
366 }
367 tool_schemas.retain(|schema| {
368 let normalized = normalize_tool_name(&schema.name);
369 if let Some(server) = mcp_server_from_tool_name(&normalized) {
370 !blocked_mcp_servers.contains(server)
371 } else {
372 true
373 }
374 });
375 if let Some(allowed_tools) = self
376 .session_allowed_tools
377 .read()
378 .await
379 .get(&session_id)
380 .cloned()
381 {
382 if !allowed_tools.is_empty() {
383 tool_schemas.retain(|schema| {
384 let normalized = normalize_tool_name(&schema.name);
385 allowed_tools.iter().any(|tool| tool == &normalized)
386 });
387 }
388 }
389 if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
390 let detail = validation_err.to_string();
391 emit_event(
392 Level::ERROR,
393 ProcessKind::Engine,
394 ObservabilityEvent {
395 event: "provider.call.error",
396 component: "engine.loop",
397 correlation_id: correlation_ref,
398 session_id: Some(&session_id),
399 run_id: None,
400 message_id: Some(&user_message_id),
401 provider_id: Some(provider_id.as_str()),
402 model_id,
403 status: Some("failed"),
404 error_code: Some("TOOL_SCHEMA_INVALID"),
405 detail: Some(&detail),
406 },
407 );
408 anyhow::bail!("{detail}");
409 }
410 let stream = self
411 .providers
412 .stream_for_provider(
413 Some(provider_id.as_str()),
414 Some(model_id_value.as_str()),
415 messages,
416 Some(tool_schemas),
417 cancel.clone(),
418 )
419 .await
420 .inspect_err(|err| {
421 let error_text = err.to_string();
422 let error_code = provider_error_code(&error_text);
423 let detail = truncate_text(&error_text, 500);
424 emit_event(
425 Level::ERROR,
426 ProcessKind::Engine,
427 ObservabilityEvent {
428 event: "provider.call.error",
429 component: "engine.loop",
430 correlation_id: correlation_ref,
431 session_id: Some(&session_id),
432 run_id: None,
433 message_id: Some(&user_message_id),
434 provider_id: Some(provider_id.as_str()),
435 model_id,
436 status: Some("failed"),
437 error_code: Some(error_code),
438 detail: Some(&detail),
439 },
440 );
441 })?;
442 tokio::pin!(stream);
443 completion.clear();
444 let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
445 let mut provider_usage: Option<TokenUsage> = None;
446 while let Some(chunk) = stream.next().await {
447 let chunk = match chunk {
448 Ok(chunk) => chunk,
449 Err(err) => {
450 let error_text = err.to_string();
451 let error_code = provider_error_code(&error_text);
452 let detail = truncate_text(&error_text, 500);
453 emit_event(
454 Level::ERROR,
455 ProcessKind::Engine,
456 ObservabilityEvent {
457 event: "provider.call.error",
458 component: "engine.loop",
459 correlation_id: correlation_ref,
460 session_id: Some(&session_id),
461 run_id: None,
462 message_id: Some(&user_message_id),
463 provider_id: Some(provider_id.as_str()),
464 model_id,
465 status: Some("failed"),
466 error_code: Some(error_code),
467 detail: Some(&detail),
468 },
469 );
470 return Err(anyhow::anyhow!(
471 "provider stream chunk error: {error_text}"
472 ));
473 }
474 };
475 match chunk {
476 StreamChunk::TextDelta(delta) => {
477 let delta = strip_model_control_markers(&delta);
478 if delta.trim().is_empty() {
479 continue;
480 }
481 if completion.is_empty() {
482 emit_event(
483 Level::INFO,
484 ProcessKind::Engine,
485 ObservabilityEvent {
486 event: "provider.call.first_byte",
487 component: "engine.loop",
488 correlation_id: correlation_ref,
489 session_id: Some(&session_id),
490 run_id: None,
491 message_id: Some(&user_message_id),
492 provider_id: Some(provider_id.as_str()),
493 model_id,
494 status: Some("streaming"),
495 error_code: None,
496 detail: Some("first text delta"),
497 },
498 );
499 }
500 completion.push_str(&delta);
501 let delta = truncate_text(&delta, 4_000);
502 let delta_part =
503 WireMessagePart::text(&session_id, &user_message_id, delta.clone());
504 self.event_bus.publish(EngineEvent::new(
505 "message.part.updated",
506 json!({"part": delta_part, "delta": delta}),
507 ));
508 }
509 StreamChunk::ReasoningDelta(_reasoning) => {}
510 StreamChunk::Done {
511 finish_reason: _,
512 usage,
513 } => {
514 if usage.is_some() {
515 provider_usage = usage;
516 }
517 break;
518 }
519 StreamChunk::ToolCallStart { id, name } => {
520 let entry = streamed_tool_calls.entry(id).or_default();
521 if entry.name.is_empty() {
522 entry.name = name;
523 }
524 }
525 StreamChunk::ToolCallDelta { id, args_delta } => {
526 let entry = streamed_tool_calls.entry(id.clone()).or_default();
527 entry.args.push_str(&args_delta);
528 let tool_name = if entry.name.trim().is_empty() {
529 "tool".to_string()
530 } else {
531 normalize_tool_name(&entry.name)
532 };
533 let parsed_preview = if entry.name.trim().is_empty() {
534 Value::String(truncate_text(&entry.args, 1_000))
535 } else {
536 parse_streamed_tool_args(&tool_name, &entry.args)
537 };
538 let mut tool_part = WireMessagePart::tool_invocation(
539 &session_id,
540 &user_message_id,
541 tool_name.clone(),
542 json!({}),
543 );
544 tool_part.id = Some(id.clone());
545 self.event_bus.publish(EngineEvent::new(
546 "message.part.updated",
547 json!({
548 "part": tool_part,
549 "toolCallDelta": {
550 "id": id,
551 "tool": tool_name,
552 "argsDelta": truncate_text(&args_delta, 1_000),
553 "parsedArgsPreview": parsed_preview
554 }
555 }),
556 ));
557 }
558 StreamChunk::ToolCallEnd { id: _ } => {}
559 }
560 if cancel.is_cancelled() {
561 break;
562 }
563 }
564
565 let mut tool_calls = streamed_tool_calls
566 .into_values()
567 .filter_map(|call| {
568 if call.name.trim().is_empty() {
569 return None;
570 }
571 let tool_name = normalize_tool_name(&call.name);
572 let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
573 Some((tool_name, parsed_args))
574 })
575 .collect::<Vec<_>>();
576 if tool_calls.is_empty() {
577 tool_calls = parse_tool_invocations_from_response(&completion);
578 }
579 if tool_calls.is_empty()
580 && !auto_workspace_probe_attempted
581 && should_force_workspace_probe(&text, &completion)
582 {
583 auto_workspace_probe_attempted = true;
584 tool_calls = vec![("glob".to_string(), json!({ "pattern": "*" }))];
585 }
586 if !tool_calls.is_empty() {
587 let mut outputs = Vec::new();
588 let mut executed_productive_tool = false;
589 let mut auth_required_hit_in_cycle = false;
590 let mut guard_budget_hit_in_cycle = false;
591 for (tool, args) in tool_calls {
592 if !agent_can_use_tool(&active_agent, &tool) {
593 continue;
594 }
595 let tool_key = normalize_tool_name(&tool);
596 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
597 if blocked_mcp_servers.contains(server) {
598 outputs.push(format!(
599 "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
600 tool_key, server
601 ));
602 continue;
603 }
604 }
605 if tool_key == "question" {
606 question_tool_used = true;
607 }
608 if websearch_query_blocked && tool_key == "websearch" {
609 outputs.push(
610 "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
611 .to_string(),
612 );
613 continue;
614 }
615 let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
616 *entry += 1;
617 let budget = tool_budget_for(&tool_key);
618 if *entry > budget {
619 outputs.push(format!(
620 "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
621 tool_key, budget
622 ));
623 guard_budget_hit_in_cycle = true;
624 continue;
625 }
626 let mut effective_args = args.clone();
627 if tool_key == "todo_write" {
628 effective_args = normalize_todo_write_args(effective_args, &completion);
629 if is_empty_todo_write_args(&effective_args) {
630 outputs.push(
631 "Tool `todo_write` call skipped: empty todo payload."
632 .to_string(),
633 );
634 continue;
635 }
636 }
637 let signature = if tool_key == "batch" {
638 batch_tool_signature(&args)
639 .unwrap_or_else(|| tool_signature(&tool_key, &args))
640 } else {
641 tool_signature(&tool_key, &args)
642 };
643 if is_shell_tool_name(&tool_key)
644 && shell_mismatch_signatures.contains(&signature)
645 {
646 outputs.push(
647 "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
648 .to_string(),
649 );
650 continue;
651 }
652 let mut signature_count = 1usize;
653 if is_read_only_tool(&tool_key)
654 || (tool_key == "batch" && is_read_only_batch_call(&args))
655 {
656 let count = readonly_signature_counts
657 .entry(signature.clone())
658 .and_modify(|v| *v = v.saturating_add(1))
659 .or_insert(1);
660 signature_count = *count;
661 if tool_key == "websearch" && *count > 2 {
662 self.event_bus.publish(EngineEvent::new(
663 "tool.loop_guard.triggered",
664 json!({
665 "sessionID": session_id,
666 "messageID": user_message_id,
667 "tool": tool_key,
668 "reason": "duplicate_signature_retry_exhausted",
669 "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
670 "loop_guard_triggered": true
671 }),
672 ));
673 outputs.push(
674 "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
675 .to_string(),
676 );
677 continue;
678 }
679 if tool_key != "websearch" && *count > 1 {
680 if let Some(cached) = readonly_tool_cache.get(&signature) {
681 outputs.push(cached.clone());
682 } else {
683 outputs.push(format!(
684 "Tool `{}` call skipped: duplicate call signature detected.",
685 tool_key
686 ));
687 }
688 continue;
689 }
690 }
691 if let Some(output) = self
692 .execute_tool_with_permission(
693 &session_id,
694 &user_message_id,
695 tool,
696 effective_args,
697 active_agent.skills.as_deref(),
698 &text,
699 Some(&completion),
700 cancel.clone(),
701 )
702 .await?
703 {
704 let productive = !(tool_key == "batch"
705 && is_non_productive_batch_output(&output))
706 && !is_auth_required_tool_output(&output);
707 if output.contains("WEBSEARCH_QUERY_MISSING") {
708 websearch_query_blocked = true;
709 }
710 if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
711 {
712 shell_mismatch_signatures.insert(signature.clone());
713 }
714 if is_read_only_tool(&tool_key)
715 && tool_key != "websearch"
716 && signature_count == 1
717 {
718 readonly_tool_cache.insert(signature, output.clone());
719 }
720 if productive {
721 executed_productive_tool = true;
722 }
723 if is_auth_required_tool_output(&output) {
724 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
725 blocked_mcp_servers.insert(server.to_string());
726 }
727 auth_required_hit_in_cycle = true;
728 }
729 outputs.push(output);
730 if auth_required_hit_in_cycle {
731 break;
732 }
733 if guard_budget_hit_in_cycle {
734 break;
735 }
736 }
737 }
738 if !outputs.is_empty() {
739 last_tool_outputs = outputs.clone();
740 let guard_budget_hit =
741 outputs.iter().any(|o| is_guard_budget_tool_output(o));
742 if executed_productive_tool {
743 followup_context = Some(format!(
744 "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
745 summarize_tool_outputs(&outputs)
746 ));
747 continue;
748 }
749 if guard_budget_hit {
750 completion = summarize_guard_budget_outputs(&outputs)
751 .unwrap_or_else(|| {
752 "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()
753 });
754 } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
755 completion = summary;
756 } else {
757 completion.clear();
758 }
759 break;
760 }
761 }
762
763 if let Some(usage) = provider_usage {
764 self.event_bus.publish(EngineEvent::new(
765 "provider.usage",
766 json!({
767 "sessionID": session_id,
768 "messageID": user_message_id,
769 "promptTokens": usage.prompt_tokens,
770 "completionTokens": usage.completion_tokens,
771 "totalTokens": usage.total_tokens,
772 }),
773 ));
774 }
775
776 break;
777 }
778 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
779 if let Some(narrative) = self
780 .generate_final_narrative_without_tools(
781 &session_id,
782 &active_agent,
783 Some(provider_id.as_str()),
784 Some(model_id_value.as_str()),
785 cancel.clone(),
786 &last_tool_outputs,
787 )
788 .await
789 {
790 completion = narrative;
791 }
792 }
793 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
794 let preview = last_tool_outputs
795 .iter()
796 .take(3)
797 .map(|o| truncate_text(o, 240))
798 .collect::<Vec<_>>()
799 .join("\n");
800 completion = format!(
801 "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
802 preview
803 );
804 }
805 completion = strip_model_control_markers(&completion);
806 truncate_text(&completion, 16_000)
807 };
808 emit_event(
809 Level::INFO,
810 ProcessKind::Engine,
811 ObservabilityEvent {
812 event: "provider.call.finish",
813 component: "engine.loop",
814 correlation_id: correlation_ref,
815 session_id: Some(&session_id),
816 run_id: None,
817 message_id: Some(&user_message_id),
818 provider_id: Some(provider_id.as_str()),
819 model_id,
820 status: Some("ok"),
821 error_code: None,
822 detail: Some("provider stream complete"),
823 },
824 );
825 if active_agent.name.eq_ignore_ascii_case("plan") {
826 emit_plan_todo_fallback(
827 self.storage.clone(),
828 &self.event_bus,
829 &session_id,
830 &user_message_id,
831 &completion,
832 )
833 .await;
834 let todos_after_fallback = self.storage.get_todos(&session_id).await;
835 if todos_after_fallback.is_empty() && !question_tool_used {
836 emit_plan_question_fallback(
837 self.storage.clone(),
838 &self.event_bus,
839 &session_id,
840 &user_message_id,
841 &completion,
842 )
843 .await;
844 }
845 }
846 if cancel.is_cancelled() {
847 self.event_bus.publish(EngineEvent::new(
848 "session.status",
849 json!({"sessionID": session_id, "status":"cancelled"}),
850 ));
851 self.cancellations.remove(&session_id).await;
852 return Ok(());
853 }
854 let assistant = Message::new(
855 MessageRole::Assistant,
856 vec![MessagePart::Text {
857 text: completion.clone(),
858 }],
859 );
860 let assistant_message_id = assistant.id.clone();
861 self.storage.append_message(&session_id, assistant).await?;
862 let final_part = WireMessagePart::text(
863 &session_id,
864 &assistant_message_id,
865 truncate_text(&completion, 16_000),
866 );
867 self.event_bus.publish(EngineEvent::new(
868 "message.part.updated",
869 json!({"part": final_part}),
870 ));
871 self.event_bus.publish(EngineEvent::new(
872 "session.updated",
873 json!({"sessionID": session_id, "status":"idle"}),
874 ));
875 self.event_bus.publish(EngineEvent::new(
876 "session.status",
877 json!({"sessionID": session_id, "status":"idle"}),
878 ));
879 self.cancellations.remove(&session_id).await;
880 Ok(())
881 }
882
883 pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
884 self.providers.default_complete(&prompt).await
885 }
886
887 pub async fn run_oneshot_for_provider(
888 &self,
889 prompt: String,
890 provider_id: Option<&str>,
891 ) -> anyhow::Result<String> {
892 self.providers
893 .complete_for_provider(provider_id, &prompt, None)
894 .await
895 }
896
897 #[allow(clippy::too_many_arguments)]
898 async fn execute_tool_with_permission(
899 &self,
900 session_id: &str,
901 message_id: &str,
902 tool: String,
903 args: Value,
904 equipped_skills: Option<&[String]>,
905 latest_user_text: &str,
906 latest_assistant_context: Option<&str>,
907 cancel: CancellationToken,
908 ) -> anyhow::Result<Option<String>> {
909 let tool = normalize_tool_name(&tool);
910 let normalized = normalize_tool_args(
911 &tool,
912 args,
913 latest_user_text,
914 latest_assistant_context.unwrap_or_default(),
915 );
916 self.event_bus.publish(EngineEvent::new(
917 "tool.args.normalized",
918 json!({
919 "sessionID": session_id,
920 "messageID": message_id,
921 "tool": tool,
922 "argsSource": normalized.args_source,
923 "argsIntegrity": normalized.args_integrity,
924 "query": normalized.query,
925 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
926 "requestID": Value::Null
927 }),
928 ));
929 if normalized.args_integrity == "recovered" {
930 self.event_bus.publish(EngineEvent::new(
931 "tool.args.recovered",
932 json!({
933 "sessionID": session_id,
934 "messageID": message_id,
935 "tool": tool,
936 "argsSource": normalized.args_source,
937 "query": normalized.query,
938 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
939 "requestID": Value::Null
940 }),
941 ));
942 }
943 if normalized.missing_terminal {
944 let missing_reason = normalized
945 .missing_terminal_reason
946 .clone()
947 .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
948 self.event_bus.publish(EngineEvent::new(
949 "tool.args.missing_terminal",
950 json!({
951 "sessionID": session_id,
952 "messageID": message_id,
953 "tool": tool,
954 "argsSource": normalized.args_source,
955 "argsIntegrity": normalized.args_integrity,
956 "requestID": Value::Null,
957 "error": missing_reason
958 }),
959 ));
960 let mut failed_part =
961 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
962 failed_part.state = Some("failed".to_string());
963 failed_part.error = Some(missing_reason.clone());
964 self.event_bus.publish(EngineEvent::new(
965 "message.part.updated",
966 json!({"part": failed_part}),
967 ));
968 return Ok(Some(missing_reason));
969 }
970
971 let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
972 Ok(args) => args,
973 Err(message) => return Ok(Some(message)),
974 };
975 if let Some(allowed_tools) = self
976 .session_allowed_tools
977 .read()
978 .await
979 .get(session_id)
980 .cloned()
981 {
982 if !allowed_tools.is_empty() && !allowed_tools.iter().any(|name| name == &tool) {
983 return Ok(Some(format!("Tool `{tool}` is not allowed for this run.")));
984 }
985 }
986 if let Some(hook) = self.tool_policy_hook.read().await.clone() {
987 let decision = hook
988 .evaluate_tool(ToolPolicyContext {
989 session_id: session_id.to_string(),
990 message_id: message_id.to_string(),
991 tool: tool.clone(),
992 args: args.clone(),
993 })
994 .await?;
995 if !decision.allowed {
996 let reason = decision
997 .reason
998 .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
999 let mut blocked_part =
1000 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1001 blocked_part.state = Some("failed".to_string());
1002 blocked_part.error = Some(reason.clone());
1003 self.event_bus.publish(EngineEvent::new(
1004 "message.part.updated",
1005 json!({"part": blocked_part}),
1006 ));
1007 return Ok(Some(reason));
1008 }
1009 }
1010 let mut tool_call_id: Option<String> = None;
1011 if let Some(violation) = self
1012 .workspace_sandbox_violation(session_id, &tool, &args)
1013 .await
1014 {
1015 let mut blocked_part =
1016 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1017 blocked_part.state = Some("failed".to_string());
1018 blocked_part.error = Some(violation.clone());
1019 self.event_bus.publish(EngineEvent::new(
1020 "message.part.updated",
1021 json!({"part": blocked_part}),
1022 ));
1023 return Ok(Some(violation));
1024 }
1025 let rule = self
1026 .plugins
1027 .permission_override(&tool)
1028 .await
1029 .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
1030 if matches!(rule, PermissionAction::Deny) {
1031 return Ok(Some(format!(
1032 "Permission denied for tool `{tool}` by policy."
1033 )));
1034 }
1035
1036 let mut effective_args = args.clone();
1037 if matches!(rule, PermissionAction::Ask) {
1038 let pending = self
1039 .permissions
1040 .ask_for_session_with_context(
1041 Some(session_id),
1042 &tool,
1043 args.clone(),
1044 Some(crate::PermissionArgsContext {
1045 args_source: normalized.args_source.clone(),
1046 args_integrity: normalized.args_integrity.clone(),
1047 query: normalized.query.clone(),
1048 }),
1049 )
1050 .await;
1051 let mut pending_part = WireMessagePart::tool_invocation(
1052 session_id,
1053 message_id,
1054 tool.clone(),
1055 args.clone(),
1056 );
1057 pending_part.id = Some(pending.id.clone());
1058 tool_call_id = Some(pending.id.clone());
1059 pending_part.state = Some("pending".to_string());
1060 self.event_bus.publish(EngineEvent::new(
1061 "message.part.updated",
1062 json!({"part": pending_part}),
1063 ));
1064 let reply = self
1065 .permissions
1066 .wait_for_reply(&pending.id, cancel.clone())
1067 .await;
1068 if cancel.is_cancelled() {
1069 return Ok(None);
1070 }
1071 let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
1072 if !approved {
1073 let mut denied_part =
1074 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1075 denied_part.id = Some(pending.id);
1076 denied_part.state = Some("denied".to_string());
1077 denied_part.error = Some("Permission denied by user".to_string());
1078 self.event_bus.publish(EngineEvent::new(
1079 "message.part.updated",
1080 json!({"part": denied_part}),
1081 ));
1082 return Ok(Some(format!(
1083 "Permission denied for tool `{tool}` by user."
1084 )));
1085 }
1086 effective_args = args;
1087 }
1088
1089 let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
1090 let tool_context = self.resolve_tool_execution_context(session_id).await;
1091 if let Some((workspace_root, effective_cwd)) = tool_context.as_ref() {
1092 if let Some(obj) = args.as_object_mut() {
1093 obj.insert(
1094 "__workspace_root".to_string(),
1095 Value::String(workspace_root.clone()),
1096 );
1097 obj.insert(
1098 "__effective_cwd".to_string(),
1099 Value::String(effective_cwd.clone()),
1100 );
1101 obj.insert(
1102 "__session_id".to_string(),
1103 Value::String(session_id.to_string()),
1104 );
1105 }
1106 tracing::info!(
1107 "tool execution context session_id={} tool={} workspace_root={} effective_cwd={}",
1108 session_id,
1109 tool,
1110 workspace_root,
1111 effective_cwd
1112 );
1113 }
1114 let mut invoke_part =
1115 WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
1116 if let Some(call_id) = tool_call_id.clone() {
1117 invoke_part.id = Some(call_id);
1118 }
1119 let invoke_part_id = invoke_part.id.clone();
1120 self.event_bus.publish(EngineEvent::new(
1121 "message.part.updated",
1122 json!({"part": invoke_part}),
1123 ));
1124 let args_for_side_events = args.clone();
1125 if tool == "spawn_agent" {
1126 let hook = self.spawn_agent_hook.read().await.clone();
1127 if let Some(hook) = hook {
1128 let spawned = hook
1129 .spawn_agent(SpawnAgentToolContext {
1130 session_id: session_id.to_string(),
1131 message_id: message_id.to_string(),
1132 tool_call_id: invoke_part_id.clone(),
1133 args: args_for_side_events.clone(),
1134 })
1135 .await?;
1136 let output = self.plugins.transform_tool_output(spawned.output).await;
1137 let output = truncate_text(&output, 16_000);
1138 emit_tool_side_events(
1139 self.storage.clone(),
1140 &self.event_bus,
1141 ToolSideEventContext {
1142 session_id,
1143 message_id,
1144 tool: &tool,
1145 args: &args_for_side_events,
1146 metadata: &spawned.metadata,
1147 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1148 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1149 },
1150 )
1151 .await;
1152 let mut result_part = WireMessagePart::tool_result(
1153 session_id,
1154 message_id,
1155 tool.clone(),
1156 json!(output.clone()),
1157 );
1158 result_part.id = invoke_part_id;
1159 self.event_bus.publish(EngineEvent::new(
1160 "message.part.updated",
1161 json!({"part": result_part}),
1162 ));
1163 return Ok(Some(truncate_text(
1164 &format!("Tool `{tool}` result:\n{output}"),
1165 16_000,
1166 )));
1167 }
1168 let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
1169 let mut failed_part =
1170 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1171 failed_part.id = invoke_part_id.clone();
1172 failed_part.state = Some("failed".to_string());
1173 failed_part.error = Some(output.to_string());
1174 self.event_bus.publish(EngineEvent::new(
1175 "message.part.updated",
1176 json!({"part": failed_part}),
1177 ));
1178 return Ok(Some(output.to_string()));
1179 }
1180 let result = match self
1181 .tools
1182 .execute_with_cancel(&tool, args, cancel.clone())
1183 .await
1184 {
1185 Ok(result) => result,
1186 Err(err) => {
1187 let err_text = err.to_string();
1188 if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
1189 self.event_bus.publish(EngineEvent::new(
1190 "mcp.auth.required",
1191 json!({
1192 "sessionID": session_id,
1193 "messageID": message_id,
1194 "tool": tool.clone(),
1195 "server": auth.server,
1196 "authorizationUrl": auth.authorization_url,
1197 "message": auth.message,
1198 "challengeId": auth.challenge_id
1199 }),
1200 ));
1201 let auth_output = format!(
1202 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
1203 tool, auth.message, auth.authorization_url
1204 );
1205 let mut result_part = WireMessagePart::tool_result(
1206 session_id,
1207 message_id,
1208 tool.clone(),
1209 json!(auth_output.clone()),
1210 );
1211 result_part.id = invoke_part_id.clone();
1212 self.event_bus.publish(EngineEvent::new(
1213 "message.part.updated",
1214 json!({"part": result_part}),
1215 ));
1216 return Ok(Some(truncate_text(
1217 &format!("Tool `{tool}` result:\n{auth_output}"),
1218 16_000,
1219 )));
1220 }
1221 let mut failed_part =
1222 WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1223 failed_part.id = invoke_part_id.clone();
1224 failed_part.state = Some("failed".to_string());
1225 failed_part.error = Some(err_text.clone());
1226 self.event_bus.publish(EngineEvent::new(
1227 "message.part.updated",
1228 json!({"part": failed_part}),
1229 ));
1230 return Err(err);
1231 }
1232 };
1233 if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
1234 let event_name = if auth.pending && auth.blocked {
1235 "mcp.auth.pending"
1236 } else {
1237 "mcp.auth.required"
1238 };
1239 self.event_bus.publish(EngineEvent::new(
1240 event_name,
1241 json!({
1242 "sessionID": session_id,
1243 "messageID": message_id,
1244 "tool": tool.clone(),
1245 "server": auth.server,
1246 "authorizationUrl": auth.authorization_url,
1247 "message": auth.message,
1248 "challengeId": auth.challenge_id,
1249 "pending": auth.pending,
1250 "blocked": auth.blocked,
1251 "retryAfterMs": auth.retry_after_ms
1252 }),
1253 ));
1254 }
1255 emit_tool_side_events(
1256 self.storage.clone(),
1257 &self.event_bus,
1258 ToolSideEventContext {
1259 session_id,
1260 message_id,
1261 tool: &tool,
1262 args: &args_for_side_events,
1263 metadata: &result.metadata,
1264 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1265 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1266 },
1267 )
1268 .await;
1269 let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
1270 if auth.pending && auth.blocked {
1271 let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
1272 format!(
1273 "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
1274 tool, auth.message, auth.authorization_url, retry_after_secs
1275 )
1276 } else {
1277 format!(
1278 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
1279 tool, auth.message, auth.authorization_url
1280 )
1281 }
1282 } else {
1283 self.plugins.transform_tool_output(result.output).await
1284 };
1285 let output = truncate_text(&output, 16_000);
1286 let mut result_part = WireMessagePart::tool_result(
1287 session_id,
1288 message_id,
1289 tool.clone(),
1290 json!(output.clone()),
1291 );
1292 result_part.id = invoke_part_id;
1293 self.event_bus.publish(EngineEvent::new(
1294 "message.part.updated",
1295 json!({"part": result_part}),
1296 ));
1297 Ok(Some(truncate_text(
1298 &format!("Tool `{tool}` result:\n{output}"),
1299 16_000,
1300 )))
1301 }
1302
1303 async fn find_recent_matching_user_message_id(
1304 &self,
1305 session_id: &str,
1306 text: &str,
1307 ) -> Option<String> {
1308 let session = self.storage.get_session(session_id).await?;
1309 let last = session.messages.last()?;
1310 if !matches!(last.role, MessageRole::User) {
1311 return None;
1312 }
1313 let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
1314 if age_ms > 10_000 {
1315 return None;
1316 }
1317 let last_text = last
1318 .parts
1319 .iter()
1320 .filter_map(|part| match part {
1321 MessagePart::Text { text } => Some(text.clone()),
1322 _ => None,
1323 })
1324 .collect::<Vec<_>>()
1325 .join("\n");
1326 if last_text == text {
1327 return Some(last.id.clone());
1328 }
1329 None
1330 }
1331
1332 async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
1333 let Some(mut session) = self.storage.get_session(session_id).await else {
1334 return;
1335 };
1336 if !title_needs_repair(&session.title) {
1337 return;
1338 }
1339
1340 let first_user_text = session.messages.iter().find_map(|message| {
1341 if !matches!(message.role, MessageRole::User) {
1342 return None;
1343 }
1344 message.parts.iter().find_map(|part| match part {
1345 MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
1346 _ => None,
1347 })
1348 });
1349
1350 let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
1351 let Some(title) = derive_session_title_from_prompt(&source, 60) else {
1352 return;
1353 };
1354
1355 session.title = title;
1356 session.time.updated = Utc::now();
1357 let _ = self.storage.save_session(session).await;
1358 }
1359
1360 async fn workspace_sandbox_violation(
1361 &self,
1362 session_id: &str,
1363 tool: &str,
1364 args: &Value,
1365 ) -> Option<String> {
1366 if self.workspace_override_active(session_id).await {
1367 return None;
1368 }
1369 let session = self.storage.get_session(session_id).await?;
1370 let workspace = session
1371 .workspace_root
1372 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1373 let workspace_path = PathBuf::from(&workspace);
1374 let candidate_paths = extract_tool_candidate_paths(tool, args);
1375 if candidate_paths.is_empty() {
1376 if is_shell_tool_name(tool) {
1377 if let Some(command) = extract_shell_command(args) {
1378 if shell_command_targets_sensitive_path(&command) {
1379 return Some(format!(
1380 "Sandbox blocked `{tool}` command targeting sensitive paths."
1381 ));
1382 }
1383 }
1384 }
1385 return None;
1386 }
1387 if let Some(sensitive) = candidate_paths.iter().find(|path| {
1388 let raw = Path::new(path);
1389 let resolved = if raw.is_absolute() {
1390 raw.to_path_buf()
1391 } else {
1392 workspace_path.join(raw)
1393 };
1394 is_sensitive_path_candidate(&resolved)
1395 }) {
1396 return Some(format!(
1397 "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
1398 ));
1399 }
1400
1401 let outside = candidate_paths.iter().find(|path| {
1402 let raw = Path::new(path);
1403 let resolved = if raw.is_absolute() {
1404 raw.to_path_buf()
1405 } else {
1406 workspace_path.join(raw)
1407 };
1408 !crate::is_within_workspace_root(&resolved, &workspace_path)
1409 })?;
1410 Some(format!(
1411 "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
1412 ))
1413 }
1414
1415 async fn resolve_tool_execution_context(&self, session_id: &str) -> Option<(String, String)> {
1416 let session = self.storage.get_session(session_id).await?;
1417 let workspace_root = session
1418 .workspace_root
1419 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1420 let effective_cwd = if session.directory.trim().is_empty()
1421 || session.directory.trim() == "."
1422 {
1423 workspace_root.clone()
1424 } else {
1425 crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
1426 };
1427 Some((workspace_root, effective_cwd))
1428 }
1429
1430 async fn workspace_override_active(&self, session_id: &str) -> bool {
1431 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
1432 let mut overrides = self.workspace_overrides.write().await;
1433 overrides.retain(|_, expires_at| *expires_at > now);
1434 overrides
1435 .get(session_id)
1436 .map(|expires_at| *expires_at > now)
1437 .unwrap_or(false)
1438 }
1439
1440 async fn generate_final_narrative_without_tools(
1441 &self,
1442 session_id: &str,
1443 active_agent: &AgentDefinition,
1444 provider_hint: Option<&str>,
1445 model_id: Option<&str>,
1446 cancel: CancellationToken,
1447 tool_outputs: &[String],
1448 ) -> Option<String> {
1449 if cancel.is_cancelled() {
1450 return None;
1451 }
1452 let mut messages = load_chat_history(self.storage.clone(), session_id).await;
1453 let mut system_parts = vec![tandem_runtime_system_prompt(&self.host_runtime_context)];
1454 if let Some(system) = active_agent.system_prompt.as_ref() {
1455 system_parts.push(system.clone());
1456 }
1457 messages.insert(
1458 0,
1459 ChatMessage {
1460 role: "system".to_string(),
1461 content: system_parts.join("\n\n"),
1462 },
1463 );
1464 messages.push(ChatMessage {
1465 role: "user".to_string(),
1466 content: format!(
1467 "Tool observations:\n{}\n\nProvide a direct final answer now. Do not call tools.",
1468 summarize_tool_outputs(tool_outputs)
1469 ),
1470 });
1471 let stream = self
1472 .providers
1473 .stream_for_provider(provider_hint, model_id, messages, None, cancel.clone())
1474 .await
1475 .ok()?;
1476 tokio::pin!(stream);
1477 let mut completion = String::new();
1478 while let Some(chunk) = stream.next().await {
1479 if cancel.is_cancelled() {
1480 return None;
1481 }
1482 match chunk {
1483 Ok(StreamChunk::TextDelta(delta)) => {
1484 let delta = strip_model_control_markers(&delta);
1485 if !delta.trim().is_empty() {
1486 completion.push_str(&delta);
1487 }
1488 }
1489 Ok(StreamChunk::Done { .. }) => break,
1490 Ok(_) => {}
1491 Err(_) => return None,
1492 }
1493 }
1494 let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
1495 if completion.trim().is_empty() {
1496 None
1497 } else {
1498 Some(completion)
1499 }
1500 }
1501}
1502
1503fn resolve_model_route(
1504 request_model: Option<&ModelSpec>,
1505 session_model: Option<&ModelSpec>,
1506) -> Option<(String, String)> {
1507 fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
1508 let provider_id = spec.provider_id.trim();
1509 let model_id = spec.model_id.trim();
1510 if provider_id.is_empty() || model_id.is_empty() {
1511 return None;
1512 }
1513 Some((provider_id.to_string(), model_id.to_string()))
1514 }
1515
1516 request_model
1517 .and_then(normalize)
1518 .or_else(|| session_model.and_then(normalize))
1519}
1520
1521fn strip_model_control_markers(input: &str) -> String {
1522 let mut cleaned = input.to_string();
1523 for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
1524 if cleaned.contains(marker) {
1525 cleaned = cleaned.replace(marker, "");
1526 }
1527 }
1528 cleaned
1529}
1530
1531fn truncate_text(input: &str, max_len: usize) -> String {
1532 if input.len() <= max_len {
1533 return input.to_string();
1534 }
1535 let mut out = input[..max_len].to_string();
1536 out.push_str("...<truncated>");
1537 out
1538}
1539
1540fn provider_error_code(error_text: &str) -> &'static str {
1541 let lower = error_text.to_lowercase();
1542 if lower.contains("invalid_function_parameters")
1543 || lower.contains("array schema missing items")
1544 || lower.contains("tool schema")
1545 {
1546 return "TOOL_SCHEMA_INVALID";
1547 }
1548 if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
1549 {
1550 return "RATE_LIMIT_EXCEEDED";
1551 }
1552 if lower.contains("context length")
1553 || lower.contains("max tokens")
1554 || lower.contains("token limit")
1555 {
1556 return "CONTEXT_LENGTH_EXCEEDED";
1557 }
1558 if lower.contains("unauthorized")
1559 || lower.contains("authentication")
1560 || lower.contains("401")
1561 || lower.contains("403")
1562 {
1563 return "AUTHENTICATION_ERROR";
1564 }
1565 if lower.contains("timeout") || lower.contains("timed out") {
1566 return "TIMEOUT";
1567 }
1568 if lower.contains("server error")
1569 || lower.contains("500")
1570 || lower.contains("502")
1571 || lower.contains("503")
1572 || lower.contains("504")
1573 {
1574 return "PROVIDER_SERVER_ERROR";
1575 }
1576 "PROVIDER_REQUEST_FAILED"
1577}
1578
1579fn normalize_tool_name(name: &str) -> String {
1580 let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
1581 for prefix in [
1582 "default_api:",
1583 "default_api.",
1584 "functions.",
1585 "function.",
1586 "tools.",
1587 "tool.",
1588 "builtin:",
1589 "builtin.",
1590 ] {
1591 if let Some(rest) = normalized.strip_prefix(prefix) {
1592 let trimmed = rest.trim();
1593 if !trimmed.is_empty() {
1594 normalized = trimmed.to_string();
1595 break;
1596 }
1597 }
1598 }
1599 match normalized.as_str() {
1600 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
1601 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
1602 other => other.to_string(),
1603 }
1604}
1605
1606fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
1607 let mut parts = tool_name.split('.');
1608 let prefix = parts.next()?;
1609 if prefix != "mcp" {
1610 return None;
1611 }
1612 parts.next().filter(|server| !server.is_empty())
1613}
1614
1615fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
1616 let Some(obj) = args.as_object() else {
1617 return Vec::new();
1618 };
1619 let keys: &[&str] = match tool {
1620 "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
1621 "glob" => &["pattern"],
1622 "lsp" => &["filePath", "path"],
1623 "bash" => &["cwd"],
1624 "apply_patch" => &[],
1625 _ => &["path", "cwd"],
1626 };
1627 keys.iter()
1628 .filter_map(|key| obj.get(*key))
1629 .filter_map(|value| value.as_str())
1630 .filter(|s| !s.trim().is_empty())
1631 .map(ToString::to_string)
1632 .collect()
1633}
1634
1635fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
1636 let target = normalize_tool_name(tool_name);
1637 match agent.tools.as_ref() {
1638 None => true,
1639 Some(list) => list.iter().any(|t| normalize_tool_name(t) == target),
1640 }
1641}
1642
1643fn enforce_skill_scope(
1644 tool_name: &str,
1645 args: Value,
1646 equipped_skills: Option<&[String]>,
1647) -> Result<Value, String> {
1648 if normalize_tool_name(tool_name) != "skill" {
1649 return Ok(args);
1650 }
1651 let Some(configured) = equipped_skills else {
1652 return Ok(args);
1653 };
1654
1655 let mut allowed = configured
1656 .iter()
1657 .map(|s| s.trim().to_string())
1658 .filter(|s| !s.is_empty())
1659 .collect::<Vec<_>>();
1660 if allowed
1661 .iter()
1662 .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
1663 {
1664 return Ok(args);
1665 }
1666 allowed.sort();
1667 allowed.dedup();
1668 if allowed.is_empty() {
1669 return Err("No skills are equipped for this agent.".to_string());
1670 }
1671
1672 let requested = args
1673 .get("name")
1674 .and_then(|v| v.as_str())
1675 .map(|v| v.trim().to_string())
1676 .unwrap_or_default();
1677 if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
1678 return Err(format!(
1679 "Skill '{}' is not equipped for this agent. Equipped skills: {}",
1680 requested,
1681 allowed.join(", ")
1682 ));
1683 }
1684
1685 let mut out = if let Some(obj) = args.as_object() {
1686 Value::Object(obj.clone())
1687 } else {
1688 json!({})
1689 };
1690 if let Some(obj) = out.as_object_mut() {
1691 obj.insert("allowed_skills".to_string(), json!(allowed));
1692 }
1693 Ok(out)
1694}
1695
1696fn is_read_only_tool(tool_name: &str) -> bool {
1697 matches!(
1698 normalize_tool_name(tool_name).as_str(),
1699 "glob"
1700 | "read"
1701 | "grep"
1702 | "search"
1703 | "codesearch"
1704 | "list"
1705 | "ls"
1706 | "lsp"
1707 | "websearch"
1708 | "webfetch"
1709 | "webfetch_html"
1710 )
1711}
1712
1713fn is_batch_wrapper_tool_name(name: &str) -> bool {
1714 matches!(
1715 normalize_tool_name(name).as_str(),
1716 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1717 )
1718}
1719
1720fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1721 obj.get(key)
1722 .and_then(|v| v.as_str())
1723 .map(str::trim)
1724 .filter(|s| !s.is_empty())
1725}
1726
1727fn nested_non_empty_string_at<'a>(
1728 obj: &'a Map<String, Value>,
1729 parent: &str,
1730 key: &str,
1731) -> Option<&'a str> {
1732 obj.get(parent)
1733 .and_then(|v| v.as_object())
1734 .and_then(|nested| nested.get(key))
1735 .and_then(|v| v.as_str())
1736 .map(str::trim)
1737 .filter(|s| !s.is_empty())
1738}
1739
1740fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1741 let calls = args
1742 .get("tool_calls")
1743 .and_then(|v| v.as_array())
1744 .cloned()
1745 .unwrap_or_default();
1746 calls
1747 .into_iter()
1748 .filter_map(|call| {
1749 let obj = call.as_object()?;
1750 let tool_raw = non_empty_string_at(obj, "tool")
1751 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
1752 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
1753 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
1754 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
1755 let name_raw = non_empty_string_at(obj, "name")
1756 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
1757 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
1758 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
1759 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
1760 let effective = match (tool_raw, name_raw) {
1761 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1762 (Some(t), _) => t,
1763 (None, Some(n)) => n,
1764 (None, None) => return None,
1765 };
1766 let normalized = normalize_tool_name(effective);
1767 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1768 Some((normalized, call_args))
1769 })
1770 .collect()
1771}
1772
1773fn is_read_only_batch_call(args: &Value) -> bool {
1774 let calls = extract_batch_calls(args);
1775 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1776}
1777
1778fn batch_tool_signature(args: &Value) -> Option<String> {
1779 let calls = extract_batch_calls(args);
1780 if calls.is_empty() {
1781 return None;
1782 }
1783 let parts = calls
1784 .into_iter()
1785 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
1786 .collect::<Vec<_>>();
1787 Some(format!("batch:{}", parts.join("|")))
1788}
1789
1790fn is_non_productive_batch_output(output: &str) -> bool {
1791 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
1792 return false;
1793 };
1794 let Some(items) = value.as_array() else {
1795 return false;
1796 };
1797 if items.is_empty() {
1798 return true;
1799 }
1800 items.iter().all(|item| {
1801 let text = item
1802 .get("output")
1803 .and_then(|v| v.as_str())
1804 .map(str::trim)
1805 .unwrap_or_default()
1806 .to_ascii_lowercase();
1807 text.is_empty()
1808 || text.starts_with("unknown tool:")
1809 || text.contains("call skipped")
1810 || text.contains("guard budget exceeded")
1811 })
1812}
1813
1814fn is_auth_required_tool_output(output: &str) -> bool {
1815 let lower = output.to_ascii_lowercase();
1816 (lower.contains("authorization required")
1817 || lower.contains("requires authorization")
1818 || lower.contains("authorization pending"))
1819 && (lower.contains("authorize here") || lower.contains("http"))
1820}
1821
1822#[derive(Debug, Clone)]
1823struct McpAuthRequiredMetadata {
1824 challenge_id: String,
1825 authorization_url: String,
1826 message: String,
1827 server: Option<String>,
1828 pending: bool,
1829 blocked: bool,
1830 retry_after_ms: Option<u64>,
1831}
1832
1833fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
1834 let auth = metadata.get("mcpAuth")?;
1835 if !auth
1836 .get("required")
1837 .and_then(|v| v.as_bool())
1838 .unwrap_or(false)
1839 {
1840 return None;
1841 }
1842 let authorization_url = auth
1843 .get("authorizationUrl")
1844 .and_then(|v| v.as_str())
1845 .map(str::trim)
1846 .filter(|v| !v.is_empty())?
1847 .to_string();
1848 let message = auth
1849 .get("message")
1850 .and_then(|v| v.as_str())
1851 .map(str::trim)
1852 .filter(|v| !v.is_empty())
1853 .unwrap_or("This tool requires authorization before it can run.")
1854 .to_string();
1855 let challenge_id = auth
1856 .get("challengeId")
1857 .and_then(|v| v.as_str())
1858 .map(str::trim)
1859 .filter(|v| !v.is_empty())
1860 .unwrap_or("unknown")
1861 .to_string();
1862 let server = metadata
1863 .get("server")
1864 .and_then(|v| v.as_str())
1865 .map(str::trim)
1866 .filter(|v| !v.is_empty())
1867 .map(ToString::to_string);
1868 let pending = auth
1869 .get("pending")
1870 .and_then(|v| v.as_bool())
1871 .unwrap_or(false);
1872 let blocked = auth
1873 .get("blocked")
1874 .and_then(|v| v.as_bool())
1875 .unwrap_or(false);
1876 let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
1877 Some(McpAuthRequiredMetadata {
1878 challenge_id,
1879 authorization_url,
1880 message,
1881 server,
1882 pending,
1883 blocked,
1884 retry_after_ms,
1885 })
1886}
1887
1888fn extract_mcp_auth_required_from_error_text(
1889 tool_name: &str,
1890 error_text: &str,
1891) -> Option<McpAuthRequiredMetadata> {
1892 let lower = error_text.to_ascii_lowercase();
1893 let auth_hint = lower.contains("authorization")
1894 || lower.contains("oauth")
1895 || lower.contains("invalid oauth token")
1896 || lower.contains("requires authorization");
1897 if !auth_hint {
1898 return None;
1899 }
1900 let authorization_url = find_first_url(error_text)?;
1901 let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
1902 let server = tool_name
1903 .strip_prefix("mcp.")
1904 .and_then(|rest| rest.split('.').next())
1905 .filter(|s| !s.is_empty())
1906 .map(ToString::to_string);
1907 Some(McpAuthRequiredMetadata {
1908 challenge_id,
1909 authorization_url,
1910 message: "This integration requires authorization before this action can run.".to_string(),
1911 server,
1912 pending: false,
1913 blocked: false,
1914 retry_after_ms: None,
1915 })
1916}
1917
1918fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
1919 if outputs.is_empty()
1920 || !outputs
1921 .iter()
1922 .all(|output| is_auth_required_tool_output(output))
1923 {
1924 return None;
1925 }
1926 let mut auth_lines = outputs
1927 .iter()
1928 .filter_map(|output| {
1929 let trimmed = output.trim();
1930 if trimmed.is_empty() {
1931 None
1932 } else {
1933 Some(trimmed.to_string())
1934 }
1935 })
1936 .collect::<Vec<_>>();
1937 auth_lines.sort();
1938 auth_lines.dedup();
1939 if auth_lines.is_empty() {
1940 return None;
1941 }
1942 Some(format!(
1943 "Authorization is required before I can continue with this action.\n\n{}",
1944 auth_lines.join("\n\n")
1945 ))
1946}
1947
1948fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
1949 if outputs.is_empty()
1950 || !outputs
1951 .iter()
1952 .all(|output| is_guard_budget_tool_output(output))
1953 {
1954 return None;
1955 }
1956 let mut lines = outputs
1957 .iter()
1958 .filter_map(|output| {
1959 let trimmed = output.trim();
1960 if trimmed.is_empty() {
1961 None
1962 } else {
1963 Some(trimmed.to_string())
1964 }
1965 })
1966 .collect::<Vec<_>>();
1967 lines.sort();
1968 lines.dedup();
1969 if lines.is_empty() {
1970 return None;
1971 }
1972 Some(format!(
1973 "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.",
1974 lines.join("\n")
1975 ))
1976}
1977
1978fn find_first_url(text: &str) -> Option<String> {
1979 text.split_whitespace().find_map(|token| {
1980 if token.starts_with("https://") || token.starts_with("http://") {
1981 let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
1982 if cleaned.len() > "https://".len() {
1983 return Some(cleaned.to_string());
1984 }
1985 }
1986 None
1987 })
1988}
1989
1990fn tool_budget_for(tool_name: &str) -> usize {
1991 match normalize_tool_name(tool_name).as_str() {
1992 "glob" => 4,
1993 "read" => 8,
1994 "websearch" => 3,
1995 "batch" => 4,
1996 "grep" | "search" | "codesearch" => 6,
1997 _ => 10,
1998 }
1999}
2000
2001fn is_guard_budget_tool_output(output: &str) -> bool {
2002 output
2003 .to_ascii_lowercase()
2004 .contains("per-run guard budget exceeded")
2005}
2006
2007fn is_sensitive_path_candidate(path: &Path) -> bool {
2008 let lowered = path.to_string_lossy().to_ascii_lowercase();
2009 if lowered.contains("/.ssh/")
2010 || lowered.ends_with("/.ssh")
2011 || lowered.contains("/.gnupg/")
2012 || lowered.ends_with("/.gnupg")
2013 {
2014 return true;
2015 }
2016 if lowered.contains("/.aws/credentials")
2017 || lowered.ends_with("/.npmrc")
2018 || lowered.ends_with("/.netrc")
2019 || lowered.ends_with("/.pypirc")
2020 {
2021 return true;
2022 }
2023 if lowered.contains("id_rsa")
2024 || lowered.contains("id_ed25519")
2025 || lowered.contains("id_ecdsa")
2026 || lowered.contains(".pem")
2027 || lowered.contains(".p12")
2028 || lowered.contains(".pfx")
2029 || lowered.contains(".key")
2030 {
2031 return true;
2032 }
2033 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
2034 let n = name.to_ascii_lowercase();
2035 if n == ".env" || n.starts_with(".env.") {
2036 return true;
2037 }
2038 }
2039 false
2040}
2041
2042fn shell_command_targets_sensitive_path(command: &str) -> bool {
2043 let lower = command.to_ascii_lowercase();
2044 let patterns = [
2045 ".env",
2046 ".ssh",
2047 ".gnupg",
2048 ".aws/credentials",
2049 "id_rsa",
2050 "id_ed25519",
2051 ".pem",
2052 ".p12",
2053 ".pfx",
2054 ".key",
2055 ];
2056 patterns.iter().any(|p| lower.contains(p))
2057}
2058
2059#[derive(Debug, Clone)]
2060struct NormalizedToolArgs {
2061 args: Value,
2062 args_source: String,
2063 args_integrity: String,
2064 query: Option<String>,
2065 missing_terminal: bool,
2066 missing_terminal_reason: Option<String>,
2067}
2068
2069fn normalize_tool_args(
2070 tool_name: &str,
2071 raw_args: Value,
2072 latest_user_text: &str,
2073 latest_assistant_context: &str,
2074) -> NormalizedToolArgs {
2075 let normalized_tool = normalize_tool_name(tool_name);
2076 let mut args = raw_args;
2077 let mut args_source = if args.is_string() {
2078 "provider_string".to_string()
2079 } else {
2080 "provider_json".to_string()
2081 };
2082 let mut args_integrity = "ok".to_string();
2083 let mut query = None;
2084 let mut missing_terminal = false;
2085 let mut missing_terminal_reason = None;
2086
2087 if normalized_tool == "websearch" {
2088 if let Some(found) = extract_websearch_query(&args) {
2089 query = Some(found);
2090 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
2091 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
2092 args_source = "inferred_from_user".to_string();
2093 args_integrity = "recovered".to_string();
2094 query = Some(inferred);
2095 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
2096 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
2097 args_source = "recovered_from_context".to_string();
2098 args_integrity = "recovered".to_string();
2099 query = Some(recovered);
2100 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
2101 } else {
2102 args_source = "missing".to_string();
2103 args_integrity = "empty".to_string();
2104 missing_terminal = true;
2105 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
2106 }
2107 } else if is_shell_tool_name(&normalized_tool) {
2108 if let Some(command) = extract_shell_command(&args) {
2109 args = set_shell_command(args, command);
2110 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
2111 args_source = "inferred_from_context".to_string();
2112 args_integrity = "recovered".to_string();
2113 args = set_shell_command(args, inferred);
2114 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
2115 args_source = "inferred_from_user".to_string();
2116 args_integrity = "recovered".to_string();
2117 args = set_shell_command(args, inferred);
2118 } else {
2119 args_source = "missing".to_string();
2120 args_integrity = "empty".to_string();
2121 missing_terminal = true;
2122 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
2123 }
2124 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
2125 if let Some(path) = extract_file_path_arg(&args) {
2126 args = set_file_path_arg(args, path);
2127 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
2128 args_source = "inferred_from_user".to_string();
2129 args_integrity = "recovered".to_string();
2130 args = set_file_path_arg(args, inferred);
2131 } else {
2132 args_source = "missing".to_string();
2133 args_integrity = "empty".to_string();
2134 missing_terminal = true;
2135 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
2136 }
2137
2138 if !missing_terminal && normalized_tool == "write" {
2139 if let Some(content) = extract_write_content_arg(&args) {
2140 args = set_write_content_arg(args, content);
2141 } else {
2142 args_source = "missing".to_string();
2143 args_integrity = "empty".to_string();
2144 missing_terminal = true;
2145 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
2146 }
2147 }
2148 } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
2149 if let Some(url) = extract_webfetch_url_arg(&args) {
2150 args = set_webfetch_url_arg(args, url);
2151 } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
2152 args_source = "inferred_from_context".to_string();
2153 args_integrity = "recovered".to_string();
2154 args = set_webfetch_url_arg(args, inferred);
2155 } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
2156 args_source = "inferred_from_user".to_string();
2157 args_integrity = "recovered".to_string();
2158 args = set_webfetch_url_arg(args, inferred);
2159 } else {
2160 args_source = "missing".to_string();
2161 args_integrity = "empty".to_string();
2162 missing_terminal = true;
2163 missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
2164 }
2165 }
2166
2167 NormalizedToolArgs {
2168 args,
2169 args_source,
2170 args_integrity,
2171 query,
2172 missing_terminal,
2173 missing_terminal_reason,
2174 }
2175}
2176
2177fn is_shell_tool_name(tool_name: &str) -> bool {
2178 matches!(
2179 tool_name.trim().to_ascii_lowercase().as_str(),
2180 "bash" | "shell" | "powershell" | "cmd"
2181 )
2182}
2183
2184fn set_file_path_arg(args: Value, path: String) -> Value {
2185 let mut obj = args.as_object().cloned().unwrap_or_default();
2186 obj.insert("path".to_string(), Value::String(path));
2187 Value::Object(obj)
2188}
2189
2190fn set_write_content_arg(args: Value, content: String) -> Value {
2191 let mut obj = args.as_object().cloned().unwrap_or_default();
2192 obj.insert("content".to_string(), Value::String(content));
2193 Value::Object(obj)
2194}
2195
2196fn extract_file_path_arg(args: &Value) -> Option<String> {
2197 extract_file_path_arg_internal(args, 0)
2198}
2199
2200fn extract_write_content_arg(args: &Value) -> Option<String> {
2201 extract_write_content_arg_internal(args, 0)
2202}
2203
2204fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
2205 if depth > 5 {
2206 return None;
2207 }
2208
2209 match args {
2210 Value::String(raw) => {
2211 let trimmed = raw.trim();
2212 if trimmed.is_empty() {
2213 return None;
2214 }
2215 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
2217 return sanitize_path_candidate(trimmed);
2218 }
2219 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2220 return extract_file_path_arg_internal(&parsed, depth + 1);
2221 }
2222 sanitize_path_candidate(trimmed)
2223 }
2224 Value::Array(items) => items
2225 .iter()
2226 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
2227 Value::Object(obj) => {
2228 for key in FILE_PATH_KEYS {
2229 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
2230 if let Some(path) = sanitize_path_candidate(raw) {
2231 return Some(path);
2232 }
2233 }
2234 }
2235 for container in NESTED_ARGS_KEYS {
2236 if let Some(nested) = obj.get(container) {
2237 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
2238 return Some(path);
2239 }
2240 }
2241 }
2242 None
2243 }
2244 _ => None,
2245 }
2246}
2247
2248fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
2249 if depth > 5 {
2250 return None;
2251 }
2252
2253 match args {
2254 Value::String(raw) => {
2255 let trimmed = raw.trim();
2256 if trimmed.is_empty() {
2257 return None;
2258 }
2259 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2260 return extract_write_content_arg_internal(&parsed, depth + 1);
2261 }
2262 if sanitize_path_candidate(trimmed).is_some()
2265 && !trimmed.contains('\n')
2266 && trimmed.split_whitespace().count() <= 3
2267 {
2268 return None;
2269 }
2270 Some(trimmed.to_string())
2271 }
2272 Value::Array(items) => items
2273 .iter()
2274 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
2275 Value::Object(obj) => {
2276 for key in WRITE_CONTENT_KEYS {
2277 if let Some(value) = obj.get(key) {
2278 if let Some(raw) = value.as_str() {
2279 if !raw.is_empty() {
2280 return Some(raw.to_string());
2281 }
2282 } else if let Some(recovered) =
2283 extract_write_content_arg_internal(value, depth + 1)
2284 {
2285 return Some(recovered);
2286 }
2287 }
2288 }
2289 for container in NESTED_ARGS_KEYS {
2290 if let Some(nested) = obj.get(container) {
2291 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
2292 return Some(content);
2293 }
2294 }
2295 }
2296 None
2297 }
2298 _ => None,
2299 }
2300}
2301
2302fn set_shell_command(args: Value, command: String) -> Value {
2303 let mut obj = args.as_object().cloned().unwrap_or_default();
2304 obj.insert("command".to_string(), Value::String(command));
2305 Value::Object(obj)
2306}
2307
2308fn extract_shell_command(args: &Value) -> Option<String> {
2309 extract_shell_command_internal(args, 0)
2310}
2311
2312fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
2313 if depth > 5 {
2314 return None;
2315 }
2316
2317 match args {
2318 Value::String(raw) => {
2319 let trimmed = raw.trim();
2320 if trimmed.is_empty() {
2321 return None;
2322 }
2323 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
2324 return sanitize_shell_command_candidate(trimmed);
2325 }
2326 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2327 return extract_shell_command_internal(&parsed, depth + 1);
2328 }
2329 sanitize_shell_command_candidate(trimmed)
2330 }
2331 Value::Array(items) => items
2332 .iter()
2333 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
2334 Value::Object(obj) => {
2335 for key in SHELL_COMMAND_KEYS {
2336 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
2337 if let Some(command) = sanitize_shell_command_candidate(raw) {
2338 return Some(command);
2339 }
2340 }
2341 }
2342 for container in NESTED_ARGS_KEYS {
2343 if let Some(nested) = obj.get(container) {
2344 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
2345 return Some(command);
2346 }
2347 }
2348 }
2349 None
2350 }
2351 _ => None,
2352 }
2353}
2354
2355fn infer_shell_command_from_text(text: &str) -> Option<String> {
2356 let trimmed = text.trim();
2357 if trimmed.is_empty() {
2358 return None;
2359 }
2360
2361 let mut in_tick = false;
2363 let mut tick_buf = String::new();
2364 for ch in trimmed.chars() {
2365 if ch == '`' {
2366 if in_tick {
2367 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
2368 if looks_like_shell_command(&candidate) {
2369 return Some(candidate);
2370 }
2371 }
2372 tick_buf.clear();
2373 }
2374 in_tick = !in_tick;
2375 continue;
2376 }
2377 if in_tick {
2378 tick_buf.push(ch);
2379 }
2380 }
2381
2382 for line in trimmed.lines() {
2383 let line = line.trim();
2384 if line.is_empty() {
2385 continue;
2386 }
2387 let lower = line.to_ascii_lowercase();
2388 for prefix in [
2389 "run ",
2390 "execute ",
2391 "call ",
2392 "use bash ",
2393 "use shell ",
2394 "bash ",
2395 "shell ",
2396 "powershell ",
2397 "pwsh ",
2398 ] {
2399 if lower.starts_with(prefix) {
2400 let candidate = line[prefix.len()..].trim();
2401 if let Some(command) = sanitize_shell_command_candidate(candidate) {
2402 if looks_like_shell_command(&command) {
2403 return Some(command);
2404 }
2405 }
2406 }
2407 }
2408 }
2409
2410 None
2411}
2412
2413fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
2414 let mut obj = args.as_object().cloned().unwrap_or_default();
2415 if let Some(q) = query {
2416 obj.insert("query".to_string(), Value::String(q));
2417 }
2418 obj.insert(
2419 "__query_source".to_string(),
2420 Value::String(query_source.to_string()),
2421 );
2422 Value::Object(obj)
2423}
2424
2425fn set_webfetch_url_arg(args: Value, url: String) -> Value {
2426 let mut obj = args.as_object().cloned().unwrap_or_default();
2427 obj.insert("url".to_string(), Value::String(url));
2428 Value::Object(obj)
2429}
2430
2431fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
2432 const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
2433 for key in URL_KEYS {
2434 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2435 if let Some(url) = sanitize_url_candidate(value) {
2436 return Some(url);
2437 }
2438 }
2439 }
2440 for container in ["arguments", "args", "input", "params"] {
2441 if let Some(obj) = args.get(container) {
2442 for key in URL_KEYS {
2443 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2444 if let Some(url) = sanitize_url_candidate(value) {
2445 return Some(url);
2446 }
2447 }
2448 }
2449 }
2450 }
2451 args.as_str().and_then(sanitize_url_candidate)
2452}
2453
2454fn extract_websearch_query(args: &Value) -> Option<String> {
2455 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2456 for key in QUERY_KEYS {
2457 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2458 if let Some(query) = sanitize_websearch_query_candidate(value) {
2459 return Some(query);
2460 }
2461 }
2462 }
2463 for container in ["arguments", "args", "input", "params"] {
2464 if let Some(obj) = args.get(container) {
2465 for key in QUERY_KEYS {
2466 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2467 if let Some(query) = sanitize_websearch_query_candidate(value) {
2468 return Some(query);
2469 }
2470 }
2471 }
2472 }
2473 }
2474 args.as_str().and_then(sanitize_websearch_query_candidate)
2475}
2476
2477fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
2478 let trimmed = raw.trim();
2479 if trimmed.is_empty() {
2480 return None;
2481 }
2482
2483 let lower = trimmed.to_ascii_lowercase();
2484 if let Some(start) = lower.find("<arg_value>") {
2485 let value_start = start + "<arg_value>".len();
2486 let tail = &trimmed[value_start..];
2487 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
2488 &tail[..end]
2489 } else {
2490 tail
2491 };
2492 let cleaned = value.trim();
2493 if !cleaned.is_empty() {
2494 return Some(cleaned.to_string());
2495 }
2496 }
2497
2498 let without_wrappers = trimmed
2499 .replace("<arg_key>", " ")
2500 .replace("</arg_key>", " ")
2501 .replace("<arg_value>", " ")
2502 .replace("</arg_value>", " ");
2503 let collapsed = without_wrappers
2504 .split_whitespace()
2505 .collect::<Vec<_>>()
2506 .join(" ");
2507 if collapsed.is_empty() {
2508 return None;
2509 }
2510
2511 let collapsed_lower = collapsed.to_ascii_lowercase();
2512 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
2513 let offset = collapsed.len() - rest.len();
2514 let q = collapsed[offset..].trim();
2515 if !q.is_empty() {
2516 return Some(q.to_string());
2517 }
2518 }
2519 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
2520 let offset = collapsed.len() - rest.len();
2521 let q = collapsed[offset..].trim();
2522 if !q.is_empty() {
2523 return Some(q.to_string());
2524 }
2525 }
2526
2527 Some(collapsed)
2528}
2529
2530fn infer_websearch_query_from_text(text: &str) -> Option<String> {
2531 let trimmed = text.trim();
2532 if trimmed.is_empty() {
2533 return None;
2534 }
2535
2536 let lower = trimmed.to_lowercase();
2537 const PREFIXES: [&str; 11] = [
2538 "web search",
2539 "websearch",
2540 "search web for",
2541 "search web",
2542 "search for",
2543 "search",
2544 "look up",
2545 "lookup",
2546 "find",
2547 "web lookup",
2548 "query",
2549 ];
2550
2551 let mut candidate = trimmed;
2552 for prefix in PREFIXES {
2553 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
2554 let remainder = trimmed[prefix.len()..]
2555 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
2556 candidate = remainder;
2557 break;
2558 }
2559 }
2560
2561 let normalized = candidate
2562 .trim()
2563 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
2564 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
2565 .trim()
2566 .to_string();
2567
2568 if normalized.split_whitespace().count() < 2 {
2569 return None;
2570 }
2571 Some(normalized)
2572}
2573
2574fn infer_file_path_from_text(text: &str) -> Option<String> {
2575 let trimmed = text.trim();
2576 if trimmed.is_empty() {
2577 return None;
2578 }
2579
2580 let mut candidates: Vec<String> = Vec::new();
2581
2582 let mut in_tick = false;
2584 let mut tick_buf = String::new();
2585 for ch in trimmed.chars() {
2586 if ch == '`' {
2587 if in_tick {
2588 let cand = sanitize_path_candidate(&tick_buf);
2589 if let Some(path) = cand {
2590 candidates.push(path);
2591 }
2592 tick_buf.clear();
2593 }
2594 in_tick = !in_tick;
2595 continue;
2596 }
2597 if in_tick {
2598 tick_buf.push(ch);
2599 }
2600 }
2601
2602 for raw in trimmed.split_whitespace() {
2604 if let Some(path) = sanitize_path_candidate(raw) {
2605 candidates.push(path);
2606 }
2607 }
2608
2609 let mut deduped = Vec::new();
2610 let mut seen = HashSet::new();
2611 for candidate in candidates {
2612 if seen.insert(candidate.clone()) {
2613 deduped.push(candidate);
2614 }
2615 }
2616
2617 deduped.into_iter().next()
2618}
2619
2620fn infer_url_from_text(text: &str) -> Option<String> {
2621 let trimmed = text.trim();
2622 if trimmed.is_empty() {
2623 return None;
2624 }
2625
2626 let mut candidates: Vec<String> = Vec::new();
2627
2628 let mut in_tick = false;
2630 let mut tick_buf = String::new();
2631 for ch in trimmed.chars() {
2632 if ch == '`' {
2633 if in_tick {
2634 if let Some(url) = sanitize_url_candidate(&tick_buf) {
2635 candidates.push(url);
2636 }
2637 tick_buf.clear();
2638 }
2639 in_tick = !in_tick;
2640 continue;
2641 }
2642 if in_tick {
2643 tick_buf.push(ch);
2644 }
2645 }
2646
2647 for raw in trimmed.split_whitespace() {
2649 if let Some(url) = sanitize_url_candidate(raw) {
2650 candidates.push(url);
2651 }
2652 }
2653
2654 let mut seen = HashSet::new();
2655 candidates
2656 .into_iter()
2657 .find(|candidate| seen.insert(candidate.clone()))
2658}
2659
2660fn sanitize_url_candidate(raw: &str) -> Option<String> {
2661 let token = raw
2662 .trim()
2663 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2664 .trim_start_matches(['(', '[', '{', '<'])
2665 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2666 .trim_end_matches('.')
2667 .trim();
2668
2669 if token.is_empty() {
2670 return None;
2671 }
2672 let lower = token.to_ascii_lowercase();
2673 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
2674 return None;
2675 }
2676 Some(token.to_string())
2677}
2678
2679fn sanitize_path_candidate(raw: &str) -> Option<String> {
2680 let token = raw
2681 .trim()
2682 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2683 .trim_start_matches(['(', '[', '{', '<'])
2684 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2685 .trim_end_matches('.')
2686 .trim();
2687
2688 if token.is_empty() {
2689 return None;
2690 }
2691 let lower = token.to_ascii_lowercase();
2692 if lower.starts_with("http://") || lower.starts_with("https://") {
2693 return None;
2694 }
2695 if is_malformed_tool_path_token(token) {
2696 return None;
2697 }
2698 if is_root_only_path_token(token) {
2699 return None;
2700 }
2701 if is_placeholder_path_token(token) {
2702 return None;
2703 }
2704
2705 let looks_like_path = token.contains('/') || token.contains('\\');
2706 let has_file_ext = [
2707 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
2708 ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
2709 ]
2710 .iter()
2711 .any(|ext| lower.ends_with(ext));
2712
2713 if !looks_like_path && !has_file_ext {
2714 return None;
2715 }
2716
2717 Some(token.to_string())
2718}
2719
2720fn is_placeholder_path_token(token: &str) -> bool {
2721 let lowered = token.trim().to_ascii_lowercase();
2722 if lowered.is_empty() {
2723 return true;
2724 }
2725 matches!(
2726 lowered.as_str(),
2727 "files/directories"
2728 | "file/directory"
2729 | "relative/or/absolute/path"
2730 | "path/to/file"
2731 | "path/to/your/file"
2732 | "tool/policy"
2733 | "tools/policy"
2734 | "the expected artifact file"
2735 | "workspace/file"
2736 )
2737}
2738
2739fn is_malformed_tool_path_token(token: &str) -> bool {
2740 let lower = token.to_ascii_lowercase();
2741 if lower.contains("<tool_call")
2743 || lower.contains("</tool_call")
2744 || lower.contains("<function=")
2745 || lower.contains("<parameter=")
2746 || lower.contains("</function>")
2747 || lower.contains("</parameter>")
2748 {
2749 return true;
2750 }
2751 if token.contains('\n') || token.contains('\r') {
2753 return true;
2754 }
2755 if token.contains('*') || token.contains('?') {
2757 return true;
2758 }
2759 false
2760}
2761
2762fn is_root_only_path_token(token: &str) -> bool {
2763 let trimmed = token.trim();
2764 if trimmed.is_empty() {
2765 return true;
2766 }
2767 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
2768 return true;
2769 }
2770 let bytes = trimmed.as_bytes();
2772 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
2773 return true;
2774 }
2775 if bytes.len() == 3
2776 && bytes[1] == b':'
2777 && (bytes[0] as char).is_ascii_alphabetic()
2778 && (bytes[2] == b'\\' || bytes[2] == b'/')
2779 {
2780 return true;
2781 }
2782 false
2783}
2784
2785fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
2786 let token = raw
2787 .trim()
2788 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
2789 .trim();
2790 if token.is_empty() {
2791 return None;
2792 }
2793 Some(token.to_string())
2794}
2795
2796fn looks_like_shell_command(candidate: &str) -> bool {
2797 let lower = candidate.to_ascii_lowercase();
2798 if lower.is_empty() {
2799 return false;
2800 }
2801 let first = lower.split_whitespace().next().unwrap_or_default();
2802 let common = [
2803 "rg",
2804 "git",
2805 "cargo",
2806 "pnpm",
2807 "npm",
2808 "node",
2809 "python",
2810 "pytest",
2811 "pwsh",
2812 "powershell",
2813 "cmd",
2814 "dir",
2815 "ls",
2816 "cat",
2817 "type",
2818 "echo",
2819 "cd",
2820 "mkdir",
2821 "cp",
2822 "copy",
2823 "move",
2824 "del",
2825 "rm",
2826 ];
2827 common.contains(&first)
2828 || first.starts_with("get-")
2829 || first.starts_with("./")
2830 || first.starts_with(".\\")
2831 || lower.contains(" | ")
2832 || lower.contains(" && ")
2833 || lower.contains(" ; ")
2834}
2835
2836const FILE_PATH_KEYS: [&str; 10] = [
2837 "path",
2838 "file_path",
2839 "filePath",
2840 "filepath",
2841 "filename",
2842 "file",
2843 "target",
2844 "targetFile",
2845 "absolutePath",
2846 "uri",
2847];
2848
2849const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
2850
2851const WRITE_CONTENT_KEYS: [&str; 8] = [
2852 "content",
2853 "text",
2854 "body",
2855 "value",
2856 "markdown",
2857 "document",
2858 "output",
2859 "file_content",
2860];
2861
2862const NESTED_ARGS_KEYS: [&str; 10] = [
2863 "arguments",
2864 "args",
2865 "input",
2866 "params",
2867 "payload",
2868 "data",
2869 "tool_input",
2870 "toolInput",
2871 "tool_args",
2872 "toolArgs",
2873];
2874
2875fn tool_signature(tool_name: &str, args: &Value) -> String {
2876 let normalized = normalize_tool_name(tool_name);
2877 if normalized == "websearch" {
2878 let query = extract_websearch_query(args)
2879 .unwrap_or_default()
2880 .to_lowercase();
2881 let limit = args
2882 .get("limit")
2883 .or_else(|| args.get("numResults"))
2884 .or_else(|| args.get("num_results"))
2885 .and_then(|v| v.as_u64())
2886 .unwrap_or(8);
2887 let domains = args
2888 .get("domains")
2889 .or_else(|| args.get("domain"))
2890 .map(|v| v.to_string())
2891 .unwrap_or_default();
2892 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
2893 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
2894 }
2895 format!("{}:{}", normalized, args)
2896}
2897
2898fn stable_hash(input: &str) -> String {
2899 let mut hasher = DefaultHasher::new();
2900 input.hash(&mut hasher);
2901 format!("{:016x}", hasher.finish())
2902}
2903
2904fn summarize_tool_outputs(outputs: &[String]) -> String {
2905 outputs
2906 .iter()
2907 .take(6)
2908 .map(|output| truncate_text(output, 600))
2909 .collect::<Vec<_>>()
2910 .join("\n\n")
2911}
2912
2913fn is_os_mismatch_tool_output(output: &str) -> bool {
2914 let lower = output.to_ascii_lowercase();
2915 lower.contains("os error 3")
2916 || lower.contains("system cannot find the path specified")
2917 || lower.contains("command not found")
2918 || lower.contains("is not recognized as an internal or external command")
2919 || lower.contains("shell command blocked on windows")
2920}
2921
2922fn tandem_runtime_system_prompt(host: &HostRuntimeContext) -> String {
2923 let mut sections = Vec::new();
2924 if os_aware_prompts_enabled() {
2925 sections.push(format!(
2926 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
2927 host_os_label(host.os),
2928 shell_family_label(host.shell_family),
2929 path_style_label(host.path_style),
2930 host.arch
2931 ));
2932 }
2933 sections.push(
2934 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
2935Use tool calls to inspect and modify the workspace when needed instead of asking the user
2936to manually run basic discovery steps. Permission prompts may occur for some tools; if
2937a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
2938 .to_string(),
2939 );
2940 if host.os == HostOs::Windows {
2941 sections.push(
2942 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
2943Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
2944If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
2945 .to_string(),
2946 );
2947 } else {
2948 sections.push(
2949 "POSIX guidance: standard shell commands are available.
2950Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
2951 .to_string(),
2952 );
2953 }
2954 sections.join("\n\n")
2955}
2956
2957fn os_aware_prompts_enabled() -> bool {
2958 std::env::var("TANDEM_OS_AWARE_PROMPTS")
2959 .ok()
2960 .map(|v| {
2961 let normalized = v.trim().to_ascii_lowercase();
2962 !(normalized == "0" || normalized == "false" || normalized == "off")
2963 })
2964 .unwrap_or(true)
2965}
2966
2967fn host_os_label(os: HostOs) -> &'static str {
2968 match os {
2969 HostOs::Windows => "windows",
2970 HostOs::Linux => "linux",
2971 HostOs::Macos => "macos",
2972 }
2973}
2974
2975fn shell_family_label(shell: ShellFamily) -> &'static str {
2976 match shell {
2977 ShellFamily::Powershell => "powershell",
2978 ShellFamily::Posix => "posix",
2979 }
2980}
2981
2982fn path_style_label(path_style: PathStyle) -> &'static str {
2983 match path_style {
2984 PathStyle::Windows => "windows",
2985 PathStyle::Posix => "posix",
2986 }
2987}
2988
2989fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
2990 let user = user_text.to_lowercase();
2991 let reply = completion.to_lowercase();
2992
2993 let asked_for_project_context = [
2994 "what is this project",
2995 "what's this project",
2996 "what project is this",
2997 "explain this project",
2998 "analyze this project",
2999 "inspect this project",
3000 "look at the project",
3001 "summarize this project",
3002 "show me this project",
3003 "what files are in",
3004 "show files",
3005 "list files",
3006 "read files",
3007 "browse files",
3008 "use glob",
3009 "run glob",
3010 ]
3011 .iter()
3012 .any(|needle| user.contains(needle));
3013
3014 if !asked_for_project_context {
3015 return false;
3016 }
3017
3018 let assistant_claimed_no_access = [
3019 "can't inspect",
3020 "cannot inspect",
3021 "unable to inspect",
3022 "unable to directly inspect",
3023 "can't access",
3024 "cannot access",
3025 "unable to access",
3026 "can't read files",
3027 "cannot read files",
3028 "unable to read files",
3029 "tool restriction",
3030 "tool restrictions",
3031 "don't have visibility",
3032 "no visibility",
3033 "haven't been able to inspect",
3034 "i don't know what this project is",
3035 "need your help to",
3036 "sandbox",
3037 "restriction",
3038 "system restriction",
3039 "permissions restrictions",
3040 ]
3041 .iter()
3042 .any(|needle| reply.contains(needle));
3043
3044 asked_for_project_context && assistant_claimed_no_access
3047}
3048
3049fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
3050 let raw = input.trim();
3051 if !raw.starts_with("/tool ") {
3052 return None;
3053 }
3054 let rest = raw.trim_start_matches("/tool ").trim();
3055 let mut split = rest.splitn(2, ' ');
3056 let tool = normalize_tool_name(split.next()?.trim());
3057 let args = split
3058 .next()
3059 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
3060 .unwrap_or_else(|| json!({}));
3061 Some((tool, args))
3062}
3063
3064fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
3065 let trimmed = input.trim();
3066 if trimmed.is_empty() {
3067 return Vec::new();
3068 }
3069
3070 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
3071 if let Some(found) = extract_tool_call_from_value(&parsed) {
3072 return vec![found];
3073 }
3074 }
3075
3076 if let Some(block) = extract_first_json_object(trimmed) {
3077 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
3078 if let Some(found) = extract_tool_call_from_value(&parsed) {
3079 return vec![found];
3080 }
3081 }
3082 }
3083
3084 parse_function_style_tool_calls(trimmed)
3085}
3086
3087#[cfg(test)]
3088fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
3089 parse_tool_invocations_from_response(input)
3090 .into_iter()
3091 .next()
3092}
3093
3094fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
3095 let mut calls = Vec::new();
3096 let lower = input.to_lowercase();
3097 let names = [
3098 "todo_write",
3099 "todowrite",
3100 "update_todo_list",
3101 "update_todos",
3102 ];
3103 let mut cursor = 0usize;
3104
3105 while cursor < lower.len() {
3106 let mut best: Option<(usize, &str)> = None;
3107 for name in names {
3108 let needle = format!("{name}(");
3109 if let Some(rel_idx) = lower[cursor..].find(&needle) {
3110 let idx = cursor + rel_idx;
3111 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
3112 best = Some((idx, name));
3113 }
3114 }
3115 }
3116
3117 let Some((tool_start, tool_name)) = best else {
3118 break;
3119 };
3120
3121 let open_paren = tool_start + tool_name.len();
3122 if let Some(close_paren) = find_matching_paren(input, open_paren) {
3123 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
3124 let args = parse_function_style_args(args_text.trim());
3125 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
3126 }
3127 cursor = close_paren.saturating_add(1);
3128 } else {
3129 cursor = tool_start.saturating_add(tool_name.len());
3130 }
3131 }
3132
3133 calls
3134}
3135
3136fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
3137 if input.as_bytes().get(open_paren).copied()? != b'(' {
3138 return None;
3139 }
3140
3141 let mut depth = 0usize;
3142 let mut in_single = false;
3143 let mut in_double = false;
3144 let mut escaped = false;
3145
3146 for (offset, ch) in input.get(open_paren..)?.char_indices() {
3147 if escaped {
3148 escaped = false;
3149 continue;
3150 }
3151 if ch == '\\' && (in_single || in_double) {
3152 escaped = true;
3153 continue;
3154 }
3155 if ch == '\'' && !in_double {
3156 in_single = !in_single;
3157 continue;
3158 }
3159 if ch == '"' && !in_single {
3160 in_double = !in_double;
3161 continue;
3162 }
3163 if in_single || in_double {
3164 continue;
3165 }
3166
3167 match ch {
3168 '(' => depth += 1,
3169 ')' => {
3170 depth = depth.saturating_sub(1);
3171 if depth == 0 {
3172 return Some(open_paren + offset);
3173 }
3174 }
3175 _ => {}
3176 }
3177 }
3178
3179 None
3180}
3181
3182fn parse_function_style_args(input: &str) -> Map<String, Value> {
3183 let mut args = Map::new();
3184 if input.trim().is_empty() {
3185 return args;
3186 }
3187
3188 let mut parts = Vec::<String>::new();
3189 let mut current = String::new();
3190 let mut in_single = false;
3191 let mut in_double = false;
3192 let mut escaped = false;
3193 let mut depth_paren = 0usize;
3194 let mut depth_bracket = 0usize;
3195 let mut depth_brace = 0usize;
3196
3197 for ch in input.chars() {
3198 if escaped {
3199 current.push(ch);
3200 escaped = false;
3201 continue;
3202 }
3203 if ch == '\\' && (in_single || in_double) {
3204 current.push(ch);
3205 escaped = true;
3206 continue;
3207 }
3208 if ch == '\'' && !in_double {
3209 in_single = !in_single;
3210 current.push(ch);
3211 continue;
3212 }
3213 if ch == '"' && !in_single {
3214 in_double = !in_double;
3215 current.push(ch);
3216 continue;
3217 }
3218 if in_single || in_double {
3219 current.push(ch);
3220 continue;
3221 }
3222
3223 match ch {
3224 '(' => depth_paren += 1,
3225 ')' => depth_paren = depth_paren.saturating_sub(1),
3226 '[' => depth_bracket += 1,
3227 ']' => depth_bracket = depth_bracket.saturating_sub(1),
3228 '{' => depth_brace += 1,
3229 '}' => depth_brace = depth_brace.saturating_sub(1),
3230 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
3231 let part = current.trim();
3232 if !part.is_empty() {
3233 parts.push(part.to_string());
3234 }
3235 current.clear();
3236 continue;
3237 }
3238 _ => {}
3239 }
3240 current.push(ch);
3241 }
3242 let tail = current.trim();
3243 if !tail.is_empty() {
3244 parts.push(tail.to_string());
3245 }
3246
3247 for part in parts {
3248 let Some((raw_key, raw_value)) = part
3249 .split_once('=')
3250 .or_else(|| part.split_once(':'))
3251 .map(|(k, v)| (k.trim(), v.trim()))
3252 else {
3253 continue;
3254 };
3255 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
3256 if key.is_empty() {
3257 continue;
3258 }
3259 let value = parse_scalar_like_value(raw_value);
3260 args.insert(key.to_string(), value);
3261 }
3262
3263 args
3264}
3265
3266fn parse_scalar_like_value(raw: &str) -> Value {
3267 let trimmed = raw.trim();
3268 if trimmed.is_empty() {
3269 return Value::Null;
3270 }
3271
3272 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
3273 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
3274 {
3275 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
3276 }
3277
3278 if trimmed.eq_ignore_ascii_case("true") {
3279 return Value::Bool(true);
3280 }
3281 if trimmed.eq_ignore_ascii_case("false") {
3282 return Value::Bool(false);
3283 }
3284 if trimmed.eq_ignore_ascii_case("null") {
3285 return Value::Null;
3286 }
3287
3288 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
3289 return v;
3290 }
3291 if let Ok(v) = trimmed.parse::<i64>() {
3292 return Value::Number(Number::from(v));
3293 }
3294 if let Ok(v) = trimmed.parse::<f64>() {
3295 if let Some(n) = Number::from_f64(v) {
3296 return Value::Number(n);
3297 }
3298 }
3299
3300 Value::String(trimmed.to_string())
3301}
3302
3303fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
3304 if is_todo_status_update_args(&args) {
3305 return args;
3306 }
3307
3308 let mut obj = match args {
3309 Value::Object(map) => map,
3310 Value::Array(items) => {
3311 return json!({ "todos": normalize_todo_arg_items(items) });
3312 }
3313 Value::String(text) => {
3314 let derived = extract_todo_candidates_from_text(&text);
3315 if !derived.is_empty() {
3316 return json!({ "todos": derived });
3317 }
3318 return json!({});
3319 }
3320 _ => return json!({}),
3321 };
3322
3323 if obj
3324 .get("todos")
3325 .and_then(|v| v.as_array())
3326 .map(|arr| !arr.is_empty())
3327 .unwrap_or(false)
3328 {
3329 return Value::Object(obj);
3330 }
3331
3332 for alias in ["tasks", "items", "list", "checklist"] {
3333 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
3334 let normalized = normalize_todo_arg_items(items.clone());
3335 if !normalized.is_empty() {
3336 obj.insert("todos".to_string(), Value::Array(normalized));
3337 return Value::Object(obj);
3338 }
3339 }
3340 }
3341
3342 let derived = extract_todo_candidates_from_text(completion);
3343 if !derived.is_empty() {
3344 obj.insert("todos".to_string(), Value::Array(derived));
3345 }
3346 Value::Object(obj)
3347}
3348
3349fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
3350 items
3351 .into_iter()
3352 .filter_map(|item| match item {
3353 Value::String(text) => {
3354 let content = text.trim();
3355 if content.is_empty() {
3356 None
3357 } else {
3358 Some(json!({"content": content}))
3359 }
3360 }
3361 Value::Object(mut obj) => {
3362 if !obj.contains_key("content") {
3363 if let Some(text) = obj.get("text").cloned() {
3364 obj.insert("content".to_string(), text);
3365 } else if let Some(title) = obj.get("title").cloned() {
3366 obj.insert("content".to_string(), title);
3367 } else if let Some(name) = obj.get("name").cloned() {
3368 obj.insert("content".to_string(), name);
3369 }
3370 }
3371 let content = obj
3372 .get("content")
3373 .and_then(|v| v.as_str())
3374 .map(str::trim)
3375 .unwrap_or("");
3376 if content.is_empty() {
3377 None
3378 } else {
3379 Some(Value::Object(obj))
3380 }
3381 }
3382 _ => None,
3383 })
3384 .collect()
3385}
3386
3387fn is_todo_status_update_args(args: &Value) -> bool {
3388 let Some(obj) = args.as_object() else {
3389 return false;
3390 };
3391 let has_status = obj
3392 .get("status")
3393 .and_then(|v| v.as_str())
3394 .map(|s| !s.trim().is_empty())
3395 .unwrap_or(false);
3396 let has_target =
3397 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
3398 has_status && has_target
3399}
3400
3401fn is_empty_todo_write_args(args: &Value) -> bool {
3402 if is_todo_status_update_args(args) {
3403 return false;
3404 }
3405 let Some(obj) = args.as_object() else {
3406 return true;
3407 };
3408 !obj.get("todos")
3409 .and_then(|v| v.as_array())
3410 .map(|arr| !arr.is_empty())
3411 .unwrap_or(false)
3412}
3413
3414fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
3415 let trimmed = raw_args.trim();
3416 if trimmed.is_empty() {
3417 return json!({});
3418 }
3419
3420 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3421 return normalize_streamed_tool_args(tool_name, parsed, trimmed);
3422 }
3423
3424 let kv_args = parse_function_style_args(trimmed);
3427 if !kv_args.is_empty() {
3428 return normalize_streamed_tool_args(tool_name, Value::Object(kv_args), trimmed);
3429 }
3430
3431 if normalize_tool_name(tool_name) == "websearch" {
3432 if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
3433 return json!({ "query": query });
3434 }
3435 return json!({});
3436 }
3437
3438 json!({})
3439}
3440
3441fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
3442 let normalized_tool = normalize_tool_name(tool_name);
3443 if normalized_tool != "websearch" {
3444 return parsed;
3445 }
3446
3447 match parsed {
3448 Value::Object(mut obj) => {
3449 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
3450 if let Some(query) = sanitize_websearch_query_candidate(raw) {
3451 obj.insert("query".to_string(), Value::String(query));
3452 }
3453 }
3454 Value::Object(obj)
3455 }
3456 Value::String(s) => match sanitize_websearch_query_candidate(&s) {
3457 Some(query) => json!({ "query": query }),
3458 None => json!({}),
3459 },
3460 other => other,
3461 }
3462}
3463
3464fn has_websearch_query(obj: &Map<String, Value>) -> bool {
3465 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
3466 QUERY_KEYS.iter().any(|key| {
3467 obj.get(*key)
3468 .and_then(|v| v.as_str())
3469 .map(|s| !s.trim().is_empty())
3470 .unwrap_or(false)
3471 })
3472}
3473
3474fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
3475 if let Some(obj) = value.as_object() {
3476 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
3477 return Some((
3478 normalize_tool_name(tool),
3479 obj.get("args").cloned().unwrap_or_else(|| json!({})),
3480 ));
3481 }
3482
3483 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
3484 let args = obj
3485 .get("args")
3486 .cloned()
3487 .or_else(|| obj.get("arguments").cloned())
3488 .unwrap_or_else(|| json!({}));
3489 let normalized_tool = normalize_tool_name(tool);
3490 let args = if let Some(raw) = args.as_str() {
3491 parse_streamed_tool_args(&normalized_tool, raw)
3492 } else {
3493 args
3494 };
3495 return Some((normalized_tool, args));
3496 }
3497
3498 for key in [
3499 "tool_call",
3500 "toolCall",
3501 "call",
3502 "function_call",
3503 "functionCall",
3504 ] {
3505 if let Some(nested) = obj.get(key) {
3506 if let Some(found) = extract_tool_call_from_value(nested) {
3507 return Some(found);
3508 }
3509 }
3510 }
3511 }
3512
3513 if let Some(items) = value.as_array() {
3514 for item in items {
3515 if let Some(found) = extract_tool_call_from_value(item) {
3516 return Some(found);
3517 }
3518 }
3519 }
3520
3521 None
3522}
3523
3524fn extract_first_json_object(input: &str) -> Option<String> {
3525 let mut start = None;
3526 let mut depth = 0usize;
3527 for (idx, ch) in input.char_indices() {
3528 if ch == '{' {
3529 if start.is_none() {
3530 start = Some(idx);
3531 }
3532 depth += 1;
3533 } else if ch == '}' {
3534 if depth == 0 {
3535 continue;
3536 }
3537 depth -= 1;
3538 if depth == 0 {
3539 let begin = start?;
3540 let block = input.get(begin..=idx)?;
3541 return Some(block.to_string());
3542 }
3543 }
3544 }
3545 None
3546}
3547
3548fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
3549 let mut seen = HashSet::<String>::new();
3550 let mut todos = Vec::new();
3551
3552 for raw_line in input.lines() {
3553 let mut line = raw_line.trim();
3554 let mut structured_line = false;
3555 if line.is_empty() {
3556 continue;
3557 }
3558 if line.starts_with("```") {
3559 continue;
3560 }
3561 if line.ends_with(':') {
3562 continue;
3563 }
3564 if let Some(rest) = line
3565 .strip_prefix("- [ ]")
3566 .or_else(|| line.strip_prefix("* [ ]"))
3567 .or_else(|| line.strip_prefix("- [x]"))
3568 .or_else(|| line.strip_prefix("* [x]"))
3569 {
3570 line = rest.trim();
3571 structured_line = true;
3572 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
3573 line = rest.trim();
3574 structured_line = true;
3575 } else {
3576 let bytes = line.as_bytes();
3577 let mut i = 0usize;
3578 while i < bytes.len() && bytes[i].is_ascii_digit() {
3579 i += 1;
3580 }
3581 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
3582 line = line[i + 1..].trim();
3583 structured_line = true;
3584 }
3585 }
3586 if !structured_line {
3587 continue;
3588 }
3589
3590 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
3591 if content.len() < 5 || content.len() > 180 {
3592 continue;
3593 }
3594 let key = content.to_lowercase();
3595 if seen.contains(&key) {
3596 continue;
3597 }
3598 seen.insert(key);
3599 todos.push(json!({ "content": content }));
3600 if todos.len() >= 25 {
3601 break;
3602 }
3603 }
3604
3605 todos
3606}
3607
3608async fn emit_plan_todo_fallback(
3609 storage: std::sync::Arc<Storage>,
3610 bus: &EventBus,
3611 session_id: &str,
3612 message_id: &str,
3613 completion: &str,
3614) {
3615 let todos = extract_todo_candidates_from_text(completion);
3616 if todos.is_empty() {
3617 return;
3618 }
3619
3620 let invoke_part = WireMessagePart::tool_invocation(
3621 session_id,
3622 message_id,
3623 "todo_write",
3624 json!({"todos": todos.clone()}),
3625 );
3626 let call_id = invoke_part.id.clone();
3627 bus.publish(EngineEvent::new(
3628 "message.part.updated",
3629 json!({"part": invoke_part}),
3630 ));
3631
3632 if storage.set_todos(session_id, todos).await.is_err() {
3633 let mut failed_part =
3634 WireMessagePart::tool_result(session_id, message_id, "todo_write", json!(null));
3635 failed_part.id = call_id;
3636 failed_part.state = Some("failed".to_string());
3637 failed_part.error = Some("failed to persist plan todos".to_string());
3638 bus.publish(EngineEvent::new(
3639 "message.part.updated",
3640 json!({"part": failed_part}),
3641 ));
3642 return;
3643 }
3644
3645 let normalized = storage.get_todos(session_id).await;
3646 let mut result_part = WireMessagePart::tool_result(
3647 session_id,
3648 message_id,
3649 "todo_write",
3650 json!({ "todos": normalized }),
3651 );
3652 result_part.id = call_id;
3653 bus.publish(EngineEvent::new(
3654 "message.part.updated",
3655 json!({"part": result_part}),
3656 ));
3657 bus.publish(EngineEvent::new(
3658 "todo.updated",
3659 json!({
3660 "sessionID": session_id,
3661 "todos": normalized
3662 }),
3663 ));
3664}
3665
3666async fn emit_plan_question_fallback(
3667 storage: std::sync::Arc<Storage>,
3668 bus: &EventBus,
3669 session_id: &str,
3670 message_id: &str,
3671 completion: &str,
3672) {
3673 let trimmed = completion.trim();
3674 if trimmed.is_empty() {
3675 return;
3676 }
3677
3678 let hints = extract_todo_candidates_from_text(trimmed)
3679 .into_iter()
3680 .take(6)
3681 .filter_map(|v| {
3682 v.get("content")
3683 .and_then(|c| c.as_str())
3684 .map(ToString::to_string)
3685 })
3686 .collect::<Vec<_>>();
3687
3688 let mut options = hints
3689 .iter()
3690 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
3691 .collect::<Vec<_>>();
3692 if options.is_empty() {
3693 options = vec![
3694 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
3695 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
3696 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
3697 ];
3698 }
3699
3700 let question_payload = vec![json!({
3701 "header":"Planning Input",
3702 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
3703 "options": options,
3704 "multiple": true,
3705 "custom": true
3706 })];
3707
3708 let request = storage
3709 .add_question_request(session_id, message_id, question_payload.clone())
3710 .await
3711 .ok();
3712 bus.publish(EngineEvent::new(
3713 "question.asked",
3714 json!({
3715 "id": request
3716 .as_ref()
3717 .map(|req| req.id.clone())
3718 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3719 "sessionID": session_id,
3720 "messageID": message_id,
3721 "questions": question_payload,
3722 "tool": request.and_then(|req| {
3723 req.tool.map(|tool| {
3724 json!({
3725 "callID": tool.call_id,
3726 "messageID": tool.message_id
3727 })
3728 })
3729 })
3730 }),
3731 ));
3732}
3733
3734async fn load_chat_history(storage: std::sync::Arc<Storage>, session_id: &str) -> Vec<ChatMessage> {
3735 let Some(session) = storage.get_session(session_id).await else {
3736 return Vec::new();
3737 };
3738 let messages = session
3739 .messages
3740 .into_iter()
3741 .map(|m| {
3742 let role = format!("{:?}", m.role).to_lowercase();
3743 let content = m
3744 .parts
3745 .into_iter()
3746 .map(|part| match part {
3747 MessagePart::Text { text } => text,
3748 MessagePart::Reasoning { text } => text,
3749 MessagePart::ToolInvocation { tool, result, .. } => {
3750 format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
3751 }
3752 })
3753 .collect::<Vec<_>>()
3754 .join("\n");
3755 ChatMessage { role, content }
3756 })
3757 .collect::<Vec<_>>();
3758 compact_chat_history(messages)
3759}
3760
3761struct ToolSideEventContext<'a> {
3762 session_id: &'a str,
3763 message_id: &'a str,
3764 tool: &'a str,
3765 args: &'a serde_json::Value,
3766 metadata: &'a serde_json::Value,
3767 workspace_root: Option<&'a str>,
3768 effective_cwd: Option<&'a str>,
3769}
3770
3771async fn emit_tool_side_events(
3772 storage: std::sync::Arc<Storage>,
3773 bus: &EventBus,
3774 ctx: ToolSideEventContext<'_>,
3775) {
3776 let ToolSideEventContext {
3777 session_id,
3778 message_id,
3779 tool,
3780 args,
3781 metadata,
3782 workspace_root,
3783 effective_cwd,
3784 } = ctx;
3785 if tool == "todo_write" {
3786 let todos_from_metadata = metadata
3787 .get("todos")
3788 .and_then(|v| v.as_array())
3789 .cloned()
3790 .unwrap_or_default();
3791
3792 if !todos_from_metadata.is_empty() {
3793 let _ = storage.set_todos(session_id, todos_from_metadata).await;
3794 } else {
3795 let current = storage.get_todos(session_id).await;
3796 if let Some(updated) = apply_todo_updates_from_args(current, args) {
3797 let _ = storage.set_todos(session_id, updated).await;
3798 }
3799 }
3800
3801 let normalized = storage.get_todos(session_id).await;
3802 bus.publish(EngineEvent::new(
3803 "todo.updated",
3804 json!({
3805 "sessionID": session_id,
3806 "todos": normalized,
3807 "workspaceRoot": workspace_root,
3808 "effectiveCwd": effective_cwd
3809 }),
3810 ));
3811 }
3812 if tool == "question" {
3813 let questions = metadata
3814 .get("questions")
3815 .and_then(|v| v.as_array())
3816 .cloned()
3817 .unwrap_or_default();
3818 if questions.is_empty() {
3819 tracing::warn!(
3820 "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
3821 session_id,
3822 message_id
3823 );
3824 } else {
3825 let request = storage
3826 .add_question_request(session_id, message_id, questions.clone())
3827 .await
3828 .ok();
3829 bus.publish(EngineEvent::new(
3830 "question.asked",
3831 json!({
3832 "id": request
3833 .as_ref()
3834 .map(|req| req.id.clone())
3835 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3836 "sessionID": session_id,
3837 "messageID": message_id,
3838 "questions": questions,
3839 "tool": request.and_then(|req| {
3840 req.tool.map(|tool| {
3841 json!({
3842 "callID": tool.call_id,
3843 "messageID": tool.message_id
3844 })
3845 })
3846 }),
3847 "workspaceRoot": workspace_root,
3848 "effectiveCwd": effective_cwd
3849 }),
3850 ));
3851 }
3852 }
3853 if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
3854 for event in events {
3855 let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
3856 continue;
3857 };
3858 if !event_type.starts_with("agent_team.") {
3859 continue;
3860 }
3861 let mut properties = event
3862 .get("properties")
3863 .and_then(|v| v.as_object())
3864 .cloned()
3865 .unwrap_or_default();
3866 properties
3867 .entry("sessionID".to_string())
3868 .or_insert(json!(session_id));
3869 properties
3870 .entry("messageID".to_string())
3871 .or_insert(json!(message_id));
3872 properties
3873 .entry("workspaceRoot".to_string())
3874 .or_insert(json!(workspace_root));
3875 properties
3876 .entry("effectiveCwd".to_string())
3877 .or_insert(json!(effective_cwd));
3878 bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
3879 }
3880 }
3881}
3882
3883fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
3884 let obj = args.as_object()?;
3885 let mut todos = current;
3886 let mut changed = false;
3887
3888 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
3889 for item in items {
3890 let Some(item_obj) = item.as_object() else {
3891 continue;
3892 };
3893 let status = item_obj
3894 .get("status")
3895 .and_then(|v| v.as_str())
3896 .map(normalize_todo_status);
3897 let target = item_obj
3898 .get("task_id")
3899 .or_else(|| item_obj.get("todo_id"))
3900 .or_else(|| item_obj.get("id"));
3901
3902 if let (Some(status), Some(target)) = (status, target) {
3903 changed |= apply_single_todo_status_update(&mut todos, target, &status);
3904 }
3905 }
3906 }
3907
3908 let status = obj
3909 .get("status")
3910 .and_then(|v| v.as_str())
3911 .map(normalize_todo_status);
3912 let target = obj
3913 .get("task_id")
3914 .or_else(|| obj.get("todo_id"))
3915 .or_else(|| obj.get("id"));
3916 if let (Some(status), Some(target)) = (status, target) {
3917 changed |= apply_single_todo_status_update(&mut todos, target, &status);
3918 }
3919
3920 if changed {
3921 Some(todos)
3922 } else {
3923 None
3924 }
3925}
3926
3927fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
3928 let idx_from_value = match target {
3929 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
3930 Value::String(s) => {
3931 let trimmed = s.trim();
3932 trimmed
3933 .parse::<usize>()
3934 .ok()
3935 .map(|v| v.saturating_sub(1))
3936 .or_else(|| {
3937 let digits = trimmed
3938 .chars()
3939 .rev()
3940 .take_while(|c| c.is_ascii_digit())
3941 .collect::<String>()
3942 .chars()
3943 .rev()
3944 .collect::<String>();
3945 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
3946 })
3947 }
3948 _ => None,
3949 };
3950
3951 if let Some(idx) = idx_from_value {
3952 if idx < todos.len() {
3953 if let Some(obj) = todos[idx].as_object_mut() {
3954 obj.insert("status".to_string(), Value::String(status.to_string()));
3955 return true;
3956 }
3957 }
3958 }
3959
3960 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
3961 if let Some(id_target) = id_target {
3962 for todo in todos.iter_mut() {
3963 if let Some(obj) = todo.as_object_mut() {
3964 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
3965 obj.insert("status".to_string(), Value::String(status.to_string()));
3966 return true;
3967 }
3968 }
3969 }
3970 }
3971
3972 false
3973}
3974
3975fn normalize_todo_status(raw: &str) -> String {
3976 match raw.trim().to_lowercase().as_str() {
3977 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
3978 "done" | "complete" | "completed" => "completed".to_string(),
3979 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
3980 "open" | "todo" | "pending" => "pending".to_string(),
3981 other => other.to_string(),
3982 }
3983}
3984
3985fn compact_chat_history(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
3986 const MAX_CONTEXT_CHARS: usize = 80_000;
3987 const KEEP_RECENT_MESSAGES: usize = 40;
3988
3989 if messages.len() <= KEEP_RECENT_MESSAGES {
3990 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
3991 if total_chars <= MAX_CONTEXT_CHARS {
3992 return messages;
3993 }
3994 }
3995
3996 let mut kept = messages;
3997 let mut dropped_count = 0usize;
3998 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
3999
4000 while kept.len() > KEEP_RECENT_MESSAGES || total_chars > MAX_CONTEXT_CHARS {
4001 if kept.is_empty() {
4002 break;
4003 }
4004 let removed = kept.remove(0);
4005 total_chars = total_chars.saturating_sub(removed.content.len());
4006 dropped_count += 1;
4007 }
4008
4009 if dropped_count > 0 {
4010 kept.insert(
4011 0,
4012 ChatMessage {
4013 role: "system".to_string(),
4014 content: format!(
4015 "[history compacted: omitted {} older messages to fit context window]",
4016 dropped_count
4017 ),
4018 },
4019 );
4020 }
4021 kept
4022}
4023
4024#[cfg(test)]
4025mod tests {
4026 use super::*;
4027 use crate::{EventBus, Storage};
4028 use uuid::Uuid;
4029
4030 #[tokio::test]
4031 async fn todo_updated_event_is_normalized() {
4032 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
4033 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
4034 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
4035 let session_id = session.id.clone();
4036 storage.save_session(session).await.expect("save session");
4037
4038 let bus = EventBus::new();
4039 let mut rx = bus.subscribe();
4040 emit_tool_side_events(
4041 storage.clone(),
4042 &bus,
4043 ToolSideEventContext {
4044 session_id: &session_id,
4045 message_id: "m1",
4046 tool: "todo_write",
4047 args: &json!({"todos":[{"content":"ship parity"}]}),
4048 metadata: &json!({"todos":[{"content":"ship parity"}]}),
4049 workspace_root: Some("."),
4050 effective_cwd: Some("."),
4051 },
4052 )
4053 .await;
4054
4055 let event = rx.recv().await.expect("event");
4056 assert_eq!(event.event_type, "todo.updated");
4057 let todos = event
4058 .properties
4059 .get("todos")
4060 .and_then(|v| v.as_array())
4061 .cloned()
4062 .unwrap_or_default();
4063 assert_eq!(todos.len(), 1);
4064 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
4065 assert_eq!(
4066 todos[0].get("content").and_then(|v| v.as_str()),
4067 Some("ship parity")
4068 );
4069 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
4070 }
4071
4072 #[tokio::test]
4073 async fn question_asked_event_contains_tool_reference() {
4074 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
4075 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
4076 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
4077 let session_id = session.id.clone();
4078 storage.save_session(session).await.expect("save session");
4079
4080 let bus = EventBus::new();
4081 let mut rx = bus.subscribe();
4082 emit_tool_side_events(
4083 storage,
4084 &bus,
4085 ToolSideEventContext {
4086 session_id: &session_id,
4087 message_id: "msg-1",
4088 tool: "question",
4089 args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
4090 metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
4091 workspace_root: Some("."),
4092 effective_cwd: Some("."),
4093 },
4094 )
4095 .await;
4096
4097 let event = rx.recv().await.expect("event");
4098 assert_eq!(event.event_type, "question.asked");
4099 assert_eq!(
4100 event
4101 .properties
4102 .get("sessionID")
4103 .and_then(|v| v.as_str())
4104 .unwrap_or(""),
4105 session_id
4106 );
4107 let tool = event
4108 .properties
4109 .get("tool")
4110 .cloned()
4111 .unwrap_or_else(|| json!({}));
4112 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
4113 assert_eq!(
4114 tool.get("messageID").and_then(|v| v.as_str()),
4115 Some("msg-1")
4116 );
4117 }
4118
4119 #[test]
4120 fn compact_chat_history_keeps_recent_and_inserts_summary() {
4121 let mut messages = Vec::new();
4122 for i in 0..60 {
4123 messages.push(ChatMessage {
4124 role: "user".to_string(),
4125 content: format!("message-{i}"),
4126 });
4127 }
4128 let compacted = compact_chat_history(messages);
4129 assert!(compacted.len() <= 41);
4130 assert_eq!(compacted[0].role, "system");
4131 assert!(compacted[0].content.contains("history compacted"));
4132 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
4133 }
4134
4135 #[test]
4136 fn extracts_todos_from_checklist_and_numbered_lines() {
4137 let input = r#"
4138Plan:
4139- [ ] Audit current implementation
4140- [ ] Add planner fallback
41411. Add regression test coverage
4142"#;
4143 let todos = extract_todo_candidates_from_text(input);
4144 assert_eq!(todos.len(), 3);
4145 assert_eq!(
4146 todos[0].get("content").and_then(|v| v.as_str()),
4147 Some("Audit current implementation")
4148 );
4149 }
4150
4151 #[test]
4152 fn does_not_extract_todos_from_plain_prose_lines() {
4153 let input = r#"
4154I need more information to proceed.
4155Can you tell me the event size and budget?
4156Once I have that, I can provide a detailed plan.
4157"#;
4158 let todos = extract_todo_candidates_from_text(input);
4159 assert!(todos.is_empty());
4160 }
4161
4162 #[test]
4163 fn parses_wrapped_tool_call_from_markdown_response() {
4164 let input = r#"
4165Here is the tool call:
4166```json
4167{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
4168```
4169"#;
4170 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
4171 assert_eq!(parsed.0, "todo_write");
4172 assert!(parsed.1.get("todos").is_some());
4173 }
4174
4175 #[test]
4176 fn parses_function_style_todowrite_call() {
4177 let input = r#"Status: Completed
4178Call: todowrite(task_id=2, status="completed")"#;
4179 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
4180 assert_eq!(parsed.0, "todo_write");
4181 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
4182 assert_eq!(
4183 parsed.1.get("status").and_then(|v| v.as_str()),
4184 Some("completed")
4185 );
4186 }
4187
4188 #[test]
4189 fn parses_multiple_function_style_todowrite_calls() {
4190 let input = r#"
4191Call: todowrite(task_id=2, status="completed")
4192Call: todowrite(task_id=3, status="in_progress")
4193"#;
4194 let parsed = parse_tool_invocations_from_response(input);
4195 assert_eq!(parsed.len(), 2);
4196 assert_eq!(parsed[0].0, "todo_write");
4197 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
4198 assert_eq!(
4199 parsed[0].1.get("status").and_then(|v| v.as_str()),
4200 Some("completed")
4201 );
4202 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
4203 assert_eq!(
4204 parsed[1].1.get("status").and_then(|v| v.as_str()),
4205 Some("in_progress")
4206 );
4207 }
4208
4209 #[test]
4210 fn applies_todo_status_update_from_task_id_args() {
4211 let current = vec![
4212 json!({"id":"todo-1","content":"a","status":"pending"}),
4213 json!({"id":"todo-2","content":"b","status":"pending"}),
4214 json!({"id":"todo-3","content":"c","status":"pending"}),
4215 ];
4216 let updated =
4217 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
4218 .expect("status update");
4219 assert_eq!(
4220 updated[1].get("status").and_then(|v| v.as_str()),
4221 Some("completed")
4222 );
4223 }
4224
4225 #[test]
4226 fn normalizes_todo_write_tasks_alias() {
4227 let normalized = normalize_todo_write_args(
4228 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
4229 "",
4230 );
4231 let todos = normalized
4232 .get("todos")
4233 .and_then(|v| v.as_array())
4234 .cloned()
4235 .unwrap_or_default();
4236 assert_eq!(todos.len(), 2);
4237 assert_eq!(
4238 todos[0].get("content").and_then(|v| v.as_str()),
4239 Some("Book venue")
4240 );
4241 assert_eq!(
4242 todos[1].get("content").and_then(|v| v.as_str()),
4243 Some("Send invites")
4244 );
4245 }
4246
4247 #[test]
4248 fn normalizes_todo_write_from_completion_when_args_empty() {
4249 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
4250 let normalized = normalize_todo_write_args(json!({}), completion);
4251 let todos = normalized
4252 .get("todos")
4253 .and_then(|v| v.as_array())
4254 .cloned()
4255 .unwrap_or_default();
4256 assert_eq!(todos.len(), 3);
4257 assert!(!is_empty_todo_write_args(&normalized));
4258 }
4259
4260 #[test]
4261 fn empty_todo_write_args_allows_status_updates() {
4262 let args = json!({"task_id": 2, "status":"completed"});
4263 assert!(!is_empty_todo_write_args(&args));
4264 }
4265
4266 #[test]
4267 fn streamed_websearch_args_fallback_to_query_string() {
4268 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
4269 assert_eq!(
4270 parsed.get("query").and_then(|v| v.as_str()),
4271 Some("meaning of life")
4272 );
4273 }
4274
4275 #[test]
4276 fn streamed_websearch_stringified_json_args_are_unwrapped() {
4277 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
4278 assert_eq!(
4279 parsed.get("query").and_then(|v| v.as_str()),
4280 Some("donkey gestation period")
4281 );
4282 }
4283
4284 #[test]
4285 fn streamed_websearch_args_strip_arg_key_value_wrappers() {
4286 let parsed = parse_streamed_tool_args(
4287 "websearch",
4288 "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
4289 );
4290 assert_eq!(
4291 parsed.get("query").and_then(|v| v.as_str()),
4292 Some("taj card what is it benefits how to apply")
4293 );
4294 }
4295
4296 #[test]
4297 fn normalize_tool_args_websearch_infers_from_user_text() {
4298 let normalized =
4299 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
4300 assert_eq!(
4301 normalized.args.get("query").and_then(|v| v.as_str()),
4302 Some("meaning of life")
4303 );
4304 assert_eq!(normalized.args_source, "inferred_from_user");
4305 assert_eq!(normalized.args_integrity, "recovered");
4306 }
4307
4308 #[test]
4309 fn normalize_tool_args_websearch_keeps_existing_query() {
4310 let normalized = normalize_tool_args(
4311 "websearch",
4312 json!({"query":"already set"}),
4313 "web search should not override",
4314 "",
4315 );
4316 assert_eq!(
4317 normalized.args.get("query").and_then(|v| v.as_str()),
4318 Some("already set")
4319 );
4320 assert_eq!(normalized.args_source, "provider_json");
4321 assert_eq!(normalized.args_integrity, "ok");
4322 }
4323
4324 #[test]
4325 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
4326 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
4327 assert!(normalized.query.is_none());
4328 assert!(normalized.missing_terminal);
4329 assert_eq!(normalized.args_source, "missing");
4330 assert_eq!(normalized.args_integrity, "empty");
4331 }
4332
4333 #[test]
4334 fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
4335 let normalized = normalize_tool_args(
4336 "webfetch",
4337 json!({}),
4338 "Please fetch `https://tandem.frumu.ai/docs/` in markdown mode",
4339 "",
4340 );
4341 assert!(!normalized.missing_terminal);
4342 assert_eq!(
4343 normalized.args.get("url").and_then(|v| v.as_str()),
4344 Some("https://tandem.frumu.ai/docs/")
4345 );
4346 assert_eq!(normalized.args_source, "inferred_from_user");
4347 assert_eq!(normalized.args_integrity, "recovered");
4348 }
4349
4350 #[test]
4351 fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
4352 let normalized = normalize_tool_args(
4353 "webfetch",
4354 json!({"args":{"uri":"https://example.com/page"}}),
4355 "",
4356 "",
4357 );
4358 assert!(!normalized.missing_terminal);
4359 assert_eq!(
4360 normalized.args.get("url").and_then(|v| v.as_str()),
4361 Some("https://example.com/page")
4362 );
4363 assert_eq!(normalized.args_source, "provider_json");
4364 }
4365
4366 #[test]
4367 fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
4368 let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
4369 assert!(normalized.missing_terminal);
4370 assert_eq!(
4371 normalized.missing_terminal_reason.as_deref(),
4372 Some("WEBFETCH_URL_MISSING")
4373 );
4374 }
4375
4376 #[test]
4377 fn normalize_tool_args_write_requires_path() {
4378 let normalized = normalize_tool_args("write", json!({}), "", "");
4379 assert!(normalized.missing_terminal);
4380 assert_eq!(
4381 normalized.missing_terminal_reason.as_deref(),
4382 Some("FILE_PATH_MISSING")
4383 );
4384 }
4385
4386 #[test]
4387 fn normalize_tool_args_write_recovers_alias_path_key() {
4388 let normalized = normalize_tool_args(
4389 "write",
4390 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
4391 "",
4392 "",
4393 );
4394 assert!(!normalized.missing_terminal);
4395 assert_eq!(
4396 normalized.args.get("path").and_then(|v| v.as_str()),
4397 Some("docs/CONCEPT.md")
4398 );
4399 assert_eq!(
4400 normalized.args.get("content").and_then(|v| v.as_str()),
4401 Some("hello")
4402 );
4403 }
4404
4405 #[test]
4406 fn normalize_tool_args_read_infers_path_from_user_prompt() {
4407 let normalized = normalize_tool_args(
4408 "read",
4409 json!({}),
4410 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
4411 "",
4412 );
4413 assert!(!normalized.missing_terminal);
4414 assert_eq!(
4415 normalized.args.get("path").and_then(|v| v.as_str()),
4416 Some("FEATURE_LIST.md")
4417 );
4418 assert_eq!(normalized.args_source, "inferred_from_user");
4419 assert_eq!(normalized.args_integrity, "recovered");
4420 }
4421
4422 #[test]
4423 fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
4424 let normalized = normalize_tool_args(
4425 "read",
4426 json!({}),
4427 "generic instruction",
4428 "I will read src-tauri/src/orchestrator/engine.rs first.",
4429 );
4430 assert!(normalized.missing_terminal);
4431 assert_eq!(
4432 normalized.missing_terminal_reason.as_deref(),
4433 Some("FILE_PATH_MISSING")
4434 );
4435 }
4436
4437 #[test]
4438 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
4439 let normalized = normalize_tool_args(
4440 "write",
4441 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
4442 "",
4443 "",
4444 );
4445 assert!(!normalized.missing_terminal);
4446 assert_eq!(
4447 normalized.args.get("path").and_then(|v| v.as_str()),
4448 Some("docs/CONCEPT.md")
4449 );
4450 }
4451
4452 #[test]
4453 fn normalize_tool_args_write_recovers_content_alias() {
4454 let normalized = normalize_tool_args(
4455 "write",
4456 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
4457 "",
4458 "",
4459 );
4460 assert!(!normalized.missing_terminal);
4461 assert_eq!(
4462 normalized.args.get("content").and_then(|v| v.as_str()),
4463 Some("feature notes")
4464 );
4465 }
4466
4467 #[test]
4468 fn normalize_tool_args_write_fails_when_content_missing() {
4469 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
4470 assert!(normalized.missing_terminal);
4471 assert_eq!(
4472 normalized.missing_terminal_reason.as_deref(),
4473 Some("WRITE_CONTENT_MISSING")
4474 );
4475 }
4476
4477 #[test]
4478 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
4479 let normalized = normalize_tool_args(
4480 "write",
4481 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
4482 "",
4483 "",
4484 );
4485 assert!(!normalized.missing_terminal);
4486 assert_eq!(
4487 normalized.args.get("path").and_then(|v| v.as_str()),
4488 Some("docs/FEATURES.md")
4489 );
4490 assert_eq!(
4491 normalized.args.get("content").and_then(|v| v.as_str()),
4492 Some("Line 1\nLine 2")
4493 );
4494 }
4495
4496 #[test]
4497 fn normalize_tool_args_write_does_not_treat_path_as_content() {
4498 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
4499 assert!(normalized.missing_terminal);
4500 assert_eq!(
4501 normalized.missing_terminal_reason.as_deref(),
4502 Some("WRITE_CONTENT_MISSING")
4503 );
4504 }
4505
4506 #[test]
4507 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
4508 let normalized = normalize_tool_args(
4509 "read",
4510 json!({}),
4511 "Please read **FEATURE_LIST.md** and summarize.",
4512 "",
4513 );
4514 assert!(!normalized.missing_terminal);
4515 assert_eq!(
4516 normalized.args.get("path").and_then(|v| v.as_str()),
4517 Some("FEATURE_LIST.md")
4518 );
4519 }
4520
4521 #[test]
4522 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
4523 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
4524 assert!(!normalized.missing_terminal);
4525 assert_eq!(
4526 normalized.args.get("command").and_then(|v| v.as_str()),
4527 Some("rg -n \"TODO\" .")
4528 );
4529 assert_eq!(normalized.args_source, "inferred_from_user");
4530 assert_eq!(normalized.args_integrity, "recovered");
4531 }
4532
4533 #[test]
4534 fn normalize_tool_args_read_rejects_root_only_path() {
4535 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
4536 assert!(normalized.missing_terminal);
4537 assert_eq!(
4538 normalized.missing_terminal_reason.as_deref(),
4539 Some("FILE_PATH_MISSING")
4540 );
4541 }
4542
4543 #[test]
4544 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
4545 let normalized =
4546 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
4547 assert!(!normalized.missing_terminal);
4548 assert_eq!(
4549 normalized.args.get("path").and_then(|v| v.as_str()),
4550 Some("CONCEPT.md")
4551 );
4552 assert_eq!(normalized.args_source, "inferred_from_user");
4553 assert_eq!(normalized.args_integrity, "recovered");
4554 }
4555
4556 #[test]
4557 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
4558 let normalized = normalize_tool_args(
4559 "read",
4560 json!({
4561 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
4562 }),
4563 "",
4564 "",
4565 );
4566 assert!(normalized.missing_terminal);
4567 assert_eq!(
4568 normalized.missing_terminal_reason.as_deref(),
4569 Some("FILE_PATH_MISSING")
4570 );
4571 }
4572
4573 #[test]
4574 fn normalize_tool_args_read_rejects_glob_pattern_path() {
4575 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
4576 assert!(normalized.missing_terminal);
4577 assert_eq!(
4578 normalized.missing_terminal_reason.as_deref(),
4579 Some("FILE_PATH_MISSING")
4580 );
4581 }
4582
4583 #[test]
4584 fn normalize_tool_args_read_rejects_placeholder_path() {
4585 let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
4586 assert!(normalized.missing_terminal);
4587 assert_eq!(
4588 normalized.missing_terminal_reason.as_deref(),
4589 Some("FILE_PATH_MISSING")
4590 );
4591 }
4592
4593 #[test]
4594 fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
4595 let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
4596 assert!(normalized.missing_terminal);
4597 assert_eq!(
4598 normalized.missing_terminal_reason.as_deref(),
4599 Some("FILE_PATH_MISSING")
4600 );
4601 }
4602
4603 #[test]
4604 fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
4605 let normalized = normalize_tool_args(
4606 "read",
4607 json!({"path":"tool/policy"}),
4608 "Read `T1011U kitöltési útmutató.pdf` and summarize.",
4609 "",
4610 );
4611 assert!(!normalized.missing_terminal);
4612 assert_eq!(
4613 normalized.args.get("path").and_then(|v| v.as_str()),
4614 Some("T1011U kitöltési útmutató.pdf")
4615 );
4616 assert_eq!(normalized.args_source, "inferred_from_user");
4617 assert_eq!(normalized.args_integrity, "recovered");
4618 }
4619
4620 #[test]
4621 fn normalize_tool_name_strips_default_api_namespace() {
4622 assert_eq!(normalize_tool_name("default_api:read"), "read");
4623 assert_eq!(normalize_tool_name("functions.shell"), "bash");
4624 }
4625
4626 #[test]
4627 fn mcp_server_from_tool_name_parses_server_segment() {
4628 assert_eq!(
4629 mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
4630 Some("arcade")
4631 );
4632 assert_eq!(mcp_server_from_tool_name("read"), None);
4633 assert_eq!(mcp_server_from_tool_name("mcp"), None);
4634 }
4635
4636 #[test]
4637 fn batch_helpers_use_name_when_tool_is_wrapper() {
4638 let args = json!({
4639 "tool_calls":[
4640 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
4641 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
4642 ]
4643 });
4644 let calls = extract_batch_calls(&args);
4645 assert_eq!(calls.len(), 2);
4646 assert_eq!(calls[0].0, "read");
4647 assert_eq!(calls[1].0, "glob");
4648 assert!(is_read_only_batch_call(&args));
4649 let sig = batch_tool_signature(&args).unwrap_or_default();
4650 assert!(sig.contains("read:"));
4651 assert!(sig.contains("glob:"));
4652 }
4653
4654 #[test]
4655 fn batch_helpers_resolve_nested_function_name() {
4656 let args = json!({
4657 "tool_calls":[
4658 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
4659 ]
4660 });
4661 let calls = extract_batch_calls(&args);
4662 assert_eq!(calls.len(), 1);
4663 assert_eq!(calls[0].0, "read");
4664 assert!(is_read_only_batch_call(&args));
4665 }
4666
4667 #[test]
4668 fn batch_output_classifier_detects_non_productive_unknown_results() {
4669 let output = r#"
4670[
4671 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
4672 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
4673]
4674"#;
4675 assert!(is_non_productive_batch_output(output));
4676 }
4677
4678 #[test]
4679 fn runtime_prompt_includes_execution_environment_block() {
4680 let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {
4681 os: HostOs::Windows,
4682 arch: "x86_64".to_string(),
4683 shell_family: ShellFamily::Powershell,
4684 path_style: PathStyle::Windows,
4685 });
4686 assert!(prompt.contains("[Execution Environment]"));
4687 assert!(prompt.contains("Host OS: windows"));
4688 assert!(prompt.contains("Shell: powershell"));
4689 assert!(prompt.contains("Path style: windows"));
4690 }
4691
4692 #[test]
4693 fn extract_mcp_auth_required_metadata_parses_expected_shape() {
4694 let metadata = json!({
4695 "server": "arcade",
4696 "mcpAuth": {
4697 "required": true,
4698 "challengeId": "abc123",
4699 "authorizationUrl": "https://example.com/oauth",
4700 "message": "Authorize first",
4701 "pending": true,
4702 "blocked": true,
4703 "retryAfterMs": 8000
4704 }
4705 });
4706 let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
4707 assert_eq!(parsed.challenge_id, "abc123");
4708 assert_eq!(parsed.authorization_url, "https://example.com/oauth");
4709 assert_eq!(parsed.message, "Authorize first");
4710 assert_eq!(parsed.server.as_deref(), Some("arcade"));
4711 assert!(parsed.pending);
4712 assert!(parsed.blocked);
4713 assert_eq!(parsed.retry_after_ms, Some(8000));
4714 }
4715
4716 #[test]
4717 fn auth_required_output_detector_matches_auth_text() {
4718 assert!(is_auth_required_tool_output(
4719 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
4720 ));
4721 assert!(is_auth_required_tool_output(
4722 "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
4723 ));
4724 assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
4725 }
4726
4727 #[test]
4728 fn guard_budget_output_detector_matches_expected_text() {
4729 assert!(is_guard_budget_tool_output(
4730 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
4731 ));
4732 assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
4733 }
4734
4735 #[test]
4736 fn summarize_guard_budget_outputs_returns_run_scoped_message() {
4737 let outputs = vec![
4738 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
4739 .to_string(),
4740 "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
4741 .to_string(),
4742 ];
4743 let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
4744 assert!(summary.contains("per-run tool guard budget"));
4745 assert!(summary.contains("fresh run"));
4746 }
4747
4748 #[test]
4749 fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
4750 let outputs = vec![
4751 "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
4752 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
4753 ];
4754 let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
4755 assert!(summary.contains("Authorization is required before I can continue"));
4756 assert!(summary.contains("gmail_sendemail"));
4757 assert!(summary.contains("gmail_whoami"));
4758 }
4759
4760 #[test]
4761 fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
4762 let outputs = vec![
4763 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
4764 "Tool `read` result:\nok".to_string(),
4765 ];
4766 assert!(summarize_auth_pending_outputs(&outputs).is_none());
4767 }
4768}