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