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_document"
1464 )
1465}
1466
1467fn is_batch_wrapper_tool_name(name: &str) -> bool {
1468 matches!(
1469 normalize_tool_name(name).as_str(),
1470 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1471 )
1472}
1473
1474fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1475 obj.get(key)
1476 .and_then(|v| v.as_str())
1477 .map(str::trim)
1478 .filter(|s| !s.is_empty())
1479}
1480
1481fn nested_non_empty_string_at<'a>(
1482 obj: &'a Map<String, Value>,
1483 parent: &str,
1484 key: &str,
1485) -> Option<&'a str> {
1486 obj.get(parent)
1487 .and_then(|v| v.as_object())
1488 .and_then(|nested| nested.get(key))
1489 .and_then(|v| v.as_str())
1490 .map(str::trim)
1491 .filter(|s| !s.is_empty())
1492}
1493
1494fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1495 let calls = args
1496 .get("tool_calls")
1497 .and_then(|v| v.as_array())
1498 .cloned()
1499 .unwrap_or_default();
1500 calls
1501 .into_iter()
1502 .filter_map(|call| {
1503 let obj = call.as_object()?;
1504 let tool_raw = non_empty_string_at(obj, "tool")
1505 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
1506 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
1507 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
1508 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
1509 let name_raw = non_empty_string_at(obj, "name")
1510 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
1511 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
1512 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
1513 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
1514 let effective = match (tool_raw, name_raw) {
1515 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1516 (Some(t), _) => t,
1517 (None, Some(n)) => n,
1518 (None, None) => return None,
1519 };
1520 let normalized = normalize_tool_name(effective);
1521 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1522 Some((normalized, call_args))
1523 })
1524 .collect()
1525}
1526
1527fn is_read_only_batch_call(args: &Value) -> bool {
1528 let calls = extract_batch_calls(args);
1529 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1530}
1531
1532fn batch_tool_signature(args: &Value) -> Option<String> {
1533 let calls = extract_batch_calls(args);
1534 if calls.is_empty() {
1535 return None;
1536 }
1537 let parts = calls
1538 .into_iter()
1539 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
1540 .collect::<Vec<_>>();
1541 Some(format!("batch:{}", parts.join("|")))
1542}
1543
1544fn is_non_productive_batch_output(output: &str) -> bool {
1545 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
1546 return false;
1547 };
1548 let Some(items) = value.as_array() else {
1549 return false;
1550 };
1551 if items.is_empty() {
1552 return true;
1553 }
1554 items.iter().all(|item| {
1555 let text = item
1556 .get("output")
1557 .and_then(|v| v.as_str())
1558 .map(str::trim)
1559 .unwrap_or_default()
1560 .to_ascii_lowercase();
1561 text.is_empty()
1562 || text.starts_with("unknown tool:")
1563 || text.contains("call skipped")
1564 || text.contains("guard budget exceeded")
1565 })
1566}
1567
1568fn tool_budget_for(tool_name: &str) -> usize {
1569 match normalize_tool_name(tool_name).as_str() {
1570 "glob" => 4,
1571 "read" => 8,
1572 "websearch" => 3,
1573 "batch" => 4,
1574 "grep" | "search" | "codesearch" => 6,
1575 _ => 10,
1576 }
1577}
1578
1579#[derive(Debug, Clone)]
1580struct NormalizedToolArgs {
1581 args: Value,
1582 args_source: String,
1583 args_integrity: String,
1584 query: Option<String>,
1585 missing_terminal: bool,
1586 missing_terminal_reason: Option<String>,
1587}
1588
1589fn normalize_tool_args(
1590 tool_name: &str,
1591 raw_args: Value,
1592 latest_user_text: &str,
1593 latest_assistant_context: &str,
1594) -> NormalizedToolArgs {
1595 let normalized_tool = normalize_tool_name(tool_name);
1596 let mut args = raw_args;
1597 let mut args_source = if args.is_string() {
1598 "provider_string".to_string()
1599 } else {
1600 "provider_json".to_string()
1601 };
1602 let mut args_integrity = "ok".to_string();
1603 let mut query = None;
1604 let mut missing_terminal = false;
1605 let mut missing_terminal_reason = None;
1606
1607 if normalized_tool == "websearch" {
1608 if let Some(found) = extract_websearch_query(&args) {
1609 query = Some(found);
1610 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
1611 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
1612 args_source = "inferred_from_user".to_string();
1613 args_integrity = "recovered".to_string();
1614 query = Some(inferred);
1615 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
1616 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
1617 args_source = "recovered_from_context".to_string();
1618 args_integrity = "recovered".to_string();
1619 query = Some(recovered);
1620 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
1621 } else {
1622 args_source = "missing".to_string();
1623 args_integrity = "empty".to_string();
1624 missing_terminal = true;
1625 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
1626 }
1627 } else if is_shell_tool_name(&normalized_tool) {
1628 if let Some(command) = extract_shell_command(&args) {
1629 args = set_shell_command(args, command);
1630 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
1631 args_source = "inferred_from_context".to_string();
1632 args_integrity = "recovered".to_string();
1633 args = set_shell_command(args, inferred);
1634 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
1635 args_source = "inferred_from_user".to_string();
1636 args_integrity = "recovered".to_string();
1637 args = set_shell_command(args, inferred);
1638 } else {
1639 args_source = "missing".to_string();
1640 args_integrity = "empty".to_string();
1641 missing_terminal = true;
1642 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
1643 }
1644 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
1645 if let Some(path) = extract_file_path_arg(&args) {
1646 args = set_file_path_arg(args, path);
1647 } else if let Some(inferred) = infer_file_path_from_text(latest_assistant_context) {
1648 args_source = "inferred_from_context".to_string();
1649 args_integrity = "recovered".to_string();
1650 args = set_file_path_arg(args, inferred);
1651 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
1652 args_source = "inferred_from_user".to_string();
1653 args_integrity = "recovered".to_string();
1654 args = set_file_path_arg(args, inferred);
1655 } else {
1656 args_source = "missing".to_string();
1657 args_integrity = "empty".to_string();
1658 missing_terminal = true;
1659 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
1660 }
1661
1662 if !missing_terminal && normalized_tool == "write" {
1663 if let Some(content) = extract_write_content_arg(&args) {
1664 args = set_write_content_arg(args, content);
1665 } else {
1666 args_source = "missing".to_string();
1667 args_integrity = "empty".to_string();
1668 missing_terminal = true;
1669 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
1670 }
1671 }
1672 }
1673
1674 NormalizedToolArgs {
1675 args,
1676 args_source,
1677 args_integrity,
1678 query,
1679 missing_terminal,
1680 missing_terminal_reason,
1681 }
1682}
1683
1684fn is_shell_tool_name(tool_name: &str) -> bool {
1685 matches!(
1686 tool_name.trim().to_ascii_lowercase().as_str(),
1687 "bash" | "shell" | "powershell" | "cmd"
1688 )
1689}
1690
1691fn set_file_path_arg(args: Value, path: String) -> Value {
1692 let mut obj = args.as_object().cloned().unwrap_or_default();
1693 obj.insert("path".to_string(), Value::String(path));
1694 Value::Object(obj)
1695}
1696
1697fn set_write_content_arg(args: Value, content: String) -> Value {
1698 let mut obj = args.as_object().cloned().unwrap_or_default();
1699 obj.insert("content".to_string(), Value::String(content));
1700 Value::Object(obj)
1701}
1702
1703fn extract_file_path_arg(args: &Value) -> Option<String> {
1704 extract_file_path_arg_internal(args, 0)
1705}
1706
1707fn extract_write_content_arg(args: &Value) -> Option<String> {
1708 extract_write_content_arg_internal(args, 0)
1709}
1710
1711fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
1712 if depth > 5 {
1713 return None;
1714 }
1715
1716 match args {
1717 Value::String(raw) => {
1718 let trimmed = raw.trim();
1719 if trimmed.is_empty() {
1720 return None;
1721 }
1722 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1724 return sanitize_path_candidate(trimmed);
1725 }
1726 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1727 return extract_file_path_arg_internal(&parsed, depth + 1);
1728 }
1729 sanitize_path_candidate(trimmed)
1730 }
1731 Value::Array(items) => items
1732 .iter()
1733 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
1734 Value::Object(obj) => {
1735 for key in FILE_PATH_KEYS {
1736 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1737 if let Some(path) = sanitize_path_candidate(raw) {
1738 return Some(path);
1739 }
1740 }
1741 }
1742 for container in NESTED_ARGS_KEYS {
1743 if let Some(nested) = obj.get(container) {
1744 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
1745 return Some(path);
1746 }
1747 }
1748 }
1749 None
1750 }
1751 _ => None,
1752 }
1753}
1754
1755fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
1756 if depth > 5 {
1757 return None;
1758 }
1759
1760 match args {
1761 Value::String(raw) => {
1762 let trimmed = raw.trim();
1763 if trimmed.is_empty() {
1764 return None;
1765 }
1766 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1767 return extract_write_content_arg_internal(&parsed, depth + 1);
1768 }
1769 if sanitize_path_candidate(trimmed).is_some()
1772 && !trimmed.contains('\n')
1773 && trimmed.split_whitespace().count() <= 3
1774 {
1775 return None;
1776 }
1777 Some(trimmed.to_string())
1778 }
1779 Value::Array(items) => items
1780 .iter()
1781 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
1782 Value::Object(obj) => {
1783 for key in WRITE_CONTENT_KEYS {
1784 if let Some(value) = obj.get(key) {
1785 if let Some(raw) = value.as_str() {
1786 if !raw.is_empty() {
1787 return Some(raw.to_string());
1788 }
1789 } else if let Some(recovered) =
1790 extract_write_content_arg_internal(value, depth + 1)
1791 {
1792 return Some(recovered);
1793 }
1794 }
1795 }
1796 for container in NESTED_ARGS_KEYS {
1797 if let Some(nested) = obj.get(container) {
1798 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
1799 return Some(content);
1800 }
1801 }
1802 }
1803 None
1804 }
1805 _ => None,
1806 }
1807}
1808
1809fn set_shell_command(args: Value, command: String) -> Value {
1810 let mut obj = args.as_object().cloned().unwrap_or_default();
1811 obj.insert("command".to_string(), Value::String(command));
1812 Value::Object(obj)
1813}
1814
1815fn extract_shell_command(args: &Value) -> Option<String> {
1816 extract_shell_command_internal(args, 0)
1817}
1818
1819fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
1820 if depth > 5 {
1821 return None;
1822 }
1823
1824 match args {
1825 Value::String(raw) => {
1826 let trimmed = raw.trim();
1827 if trimmed.is_empty() {
1828 return None;
1829 }
1830 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1831 return sanitize_shell_command_candidate(trimmed);
1832 }
1833 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1834 return extract_shell_command_internal(&parsed, depth + 1);
1835 }
1836 sanitize_shell_command_candidate(trimmed)
1837 }
1838 Value::Array(items) => items
1839 .iter()
1840 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
1841 Value::Object(obj) => {
1842 for key in SHELL_COMMAND_KEYS {
1843 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1844 if let Some(command) = sanitize_shell_command_candidate(raw) {
1845 return Some(command);
1846 }
1847 }
1848 }
1849 for container in NESTED_ARGS_KEYS {
1850 if let Some(nested) = obj.get(container) {
1851 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
1852 return Some(command);
1853 }
1854 }
1855 }
1856 None
1857 }
1858 _ => None,
1859 }
1860}
1861
1862fn infer_shell_command_from_text(text: &str) -> Option<String> {
1863 let trimmed = text.trim();
1864 if trimmed.is_empty() {
1865 return None;
1866 }
1867
1868 let mut in_tick = false;
1870 let mut tick_buf = String::new();
1871 for ch in trimmed.chars() {
1872 if ch == '`' {
1873 if in_tick {
1874 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
1875 if looks_like_shell_command(&candidate) {
1876 return Some(candidate);
1877 }
1878 }
1879 tick_buf.clear();
1880 }
1881 in_tick = !in_tick;
1882 continue;
1883 }
1884 if in_tick {
1885 tick_buf.push(ch);
1886 }
1887 }
1888
1889 for line in trimmed.lines() {
1890 let line = line.trim();
1891 if line.is_empty() {
1892 continue;
1893 }
1894 let lower = line.to_ascii_lowercase();
1895 for prefix in [
1896 "run ",
1897 "execute ",
1898 "call ",
1899 "use bash ",
1900 "use shell ",
1901 "bash ",
1902 "shell ",
1903 "powershell ",
1904 "pwsh ",
1905 ] {
1906 if lower.starts_with(prefix) {
1907 let candidate = line[prefix.len()..].trim();
1908 if let Some(command) = sanitize_shell_command_candidate(candidate) {
1909 if looks_like_shell_command(&command) {
1910 return Some(command);
1911 }
1912 }
1913 }
1914 }
1915 }
1916
1917 None
1918}
1919
1920fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
1921 let mut obj = args.as_object().cloned().unwrap_or_default();
1922 if let Some(q) = query {
1923 obj.insert("query".to_string(), Value::String(q));
1924 }
1925 obj.insert(
1926 "__query_source".to_string(),
1927 Value::String(query_source.to_string()),
1928 );
1929 Value::Object(obj)
1930}
1931
1932fn extract_websearch_query(args: &Value) -> Option<String> {
1933 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
1934 for key in QUERY_KEYS {
1935 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
1936 let trimmed = value.trim();
1937 if !trimmed.is_empty() {
1938 return Some(trimmed.to_string());
1939 }
1940 }
1941 }
1942 for container in ["arguments", "args", "input", "params"] {
1943 if let Some(obj) = args.get(container) {
1944 for key in QUERY_KEYS {
1945 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
1946 let trimmed = value.trim();
1947 if !trimmed.is_empty() {
1948 return Some(trimmed.to_string());
1949 }
1950 }
1951 }
1952 }
1953 }
1954 args.as_str()
1955 .map(str::trim)
1956 .filter(|s| !s.is_empty())
1957 .map(ToString::to_string)
1958}
1959
1960fn infer_websearch_query_from_text(text: &str) -> Option<String> {
1961 let trimmed = text.trim();
1962 if trimmed.is_empty() {
1963 return None;
1964 }
1965
1966 let lower = trimmed.to_lowercase();
1967 const PREFIXES: [&str; 11] = [
1968 "web search",
1969 "websearch",
1970 "search web for",
1971 "search web",
1972 "search for",
1973 "search",
1974 "look up",
1975 "lookup",
1976 "find",
1977 "web lookup",
1978 "query",
1979 ];
1980
1981 let mut candidate = trimmed;
1982 for prefix in PREFIXES {
1983 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
1984 let remainder = trimmed[prefix.len()..]
1985 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
1986 candidate = remainder;
1987 break;
1988 }
1989 }
1990
1991 let normalized = candidate
1992 .trim()
1993 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
1994 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
1995 .trim()
1996 .to_string();
1997
1998 if normalized.split_whitespace().count() < 2 {
1999 return None;
2000 }
2001 Some(normalized)
2002}
2003
2004fn infer_file_path_from_text(text: &str) -> Option<String> {
2005 let trimmed = text.trim();
2006 if trimmed.is_empty() {
2007 return None;
2008 }
2009
2010 let mut candidates: Vec<String> = Vec::new();
2011
2012 let mut in_tick = false;
2014 let mut tick_buf = String::new();
2015 for ch in trimmed.chars() {
2016 if ch == '`' {
2017 if in_tick {
2018 let cand = sanitize_path_candidate(&tick_buf);
2019 if let Some(path) = cand {
2020 candidates.push(path);
2021 }
2022 tick_buf.clear();
2023 }
2024 in_tick = !in_tick;
2025 continue;
2026 }
2027 if in_tick {
2028 tick_buf.push(ch);
2029 }
2030 }
2031
2032 for raw in trimmed.split_whitespace() {
2034 if let Some(path) = sanitize_path_candidate(raw) {
2035 candidates.push(path);
2036 }
2037 }
2038
2039 let mut deduped = Vec::new();
2040 let mut seen = HashSet::new();
2041 for candidate in candidates {
2042 if seen.insert(candidate.clone()) {
2043 deduped.push(candidate);
2044 }
2045 }
2046
2047 deduped.into_iter().next()
2048}
2049
2050fn sanitize_path_candidate(raw: &str) -> Option<String> {
2051 let token = raw
2052 .trim()
2053 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2054 .trim_start_matches(['(', '[', '{', '<'])
2055 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2056 .trim_end_matches('.')
2057 .trim();
2058
2059 if token.is_empty() {
2060 return None;
2061 }
2062 let lower = token.to_ascii_lowercase();
2063 if lower.starts_with("http://") || lower.starts_with("https://") {
2064 return None;
2065 }
2066 if is_malformed_tool_path_token(token) {
2067 return None;
2068 }
2069 if is_root_only_path_token(token) {
2070 return None;
2071 }
2072
2073 let looks_like_path = token.contains('/') || token.contains('\\');
2074 let has_file_ext = [
2075 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
2076 ".py", ".go", ".java", ".cpp", ".c", ".h",
2077 ]
2078 .iter()
2079 .any(|ext| lower.ends_with(ext));
2080
2081 if !looks_like_path && !has_file_ext {
2082 return None;
2083 }
2084
2085 Some(token.to_string())
2086}
2087
2088fn is_malformed_tool_path_token(token: &str) -> bool {
2089 let lower = token.to_ascii_lowercase();
2090 if lower.contains("<tool_call")
2092 || lower.contains("</tool_call")
2093 || lower.contains("<function=")
2094 || lower.contains("<parameter=")
2095 || lower.contains("</function>")
2096 || lower.contains("</parameter>")
2097 {
2098 return true;
2099 }
2100 if token.contains('\n') || token.contains('\r') {
2102 return true;
2103 }
2104 if token.contains('*') || token.contains('?') {
2106 return true;
2107 }
2108 false
2109}
2110
2111fn is_root_only_path_token(token: &str) -> bool {
2112 let trimmed = token.trim();
2113 if trimmed.is_empty() {
2114 return true;
2115 }
2116 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
2117 return true;
2118 }
2119 let bytes = trimmed.as_bytes();
2121 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
2122 return true;
2123 }
2124 if bytes.len() == 3
2125 && bytes[1] == b':'
2126 && (bytes[0] as char).is_ascii_alphabetic()
2127 && (bytes[2] == b'\\' || bytes[2] == b'/')
2128 {
2129 return true;
2130 }
2131 false
2132}
2133
2134fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
2135 let token = raw
2136 .trim()
2137 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
2138 .trim();
2139 if token.is_empty() {
2140 return None;
2141 }
2142 Some(token.to_string())
2143}
2144
2145fn looks_like_shell_command(candidate: &str) -> bool {
2146 let lower = candidate.to_ascii_lowercase();
2147 if lower.is_empty() {
2148 return false;
2149 }
2150 let first = lower.split_whitespace().next().unwrap_or_default();
2151 let common = [
2152 "rg",
2153 "git",
2154 "cargo",
2155 "pnpm",
2156 "npm",
2157 "node",
2158 "python",
2159 "pytest",
2160 "pwsh",
2161 "powershell",
2162 "cmd",
2163 "dir",
2164 "ls",
2165 "cat",
2166 "type",
2167 "echo",
2168 "cd",
2169 "mkdir",
2170 "cp",
2171 "copy",
2172 "move",
2173 "del",
2174 "rm",
2175 ];
2176 common.contains(&first)
2177 || first.starts_with("get-")
2178 || first.starts_with("./")
2179 || first.starts_with(".\\")
2180 || lower.contains(" | ")
2181 || lower.contains(" && ")
2182 || lower.contains(" ; ")
2183}
2184
2185const FILE_PATH_KEYS: [&str; 10] = [
2186 "path",
2187 "file_path",
2188 "filePath",
2189 "filepath",
2190 "filename",
2191 "file",
2192 "target",
2193 "targetFile",
2194 "absolutePath",
2195 "uri",
2196];
2197
2198const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
2199
2200const WRITE_CONTENT_KEYS: [&str; 8] = [
2201 "content",
2202 "text",
2203 "body",
2204 "value",
2205 "markdown",
2206 "document",
2207 "output",
2208 "file_content",
2209];
2210
2211const NESTED_ARGS_KEYS: [&str; 10] = [
2212 "arguments",
2213 "args",
2214 "input",
2215 "params",
2216 "payload",
2217 "data",
2218 "tool_input",
2219 "toolInput",
2220 "tool_args",
2221 "toolArgs",
2222];
2223
2224fn tool_signature(tool_name: &str, args: &Value) -> String {
2225 let normalized = normalize_tool_name(tool_name);
2226 if normalized == "websearch" {
2227 let query = extract_websearch_query(args)
2228 .unwrap_or_default()
2229 .to_lowercase();
2230 let limit = args
2231 .get("limit")
2232 .or_else(|| args.get("numResults"))
2233 .or_else(|| args.get("num_results"))
2234 .and_then(|v| v.as_u64())
2235 .unwrap_or(8);
2236 let domains = args
2237 .get("domains")
2238 .or_else(|| args.get("domain"))
2239 .map(|v| v.to_string())
2240 .unwrap_or_default();
2241 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
2242 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
2243 }
2244 format!("{}:{}", normalized, args)
2245}
2246
2247fn stable_hash(input: &str) -> String {
2248 let mut hasher = DefaultHasher::new();
2249 input.hash(&mut hasher);
2250 format!("{:016x}", hasher.finish())
2251}
2252
2253fn summarize_tool_outputs(outputs: &[String]) -> String {
2254 outputs
2255 .iter()
2256 .take(6)
2257 .map(|output| truncate_text(output, 600))
2258 .collect::<Vec<_>>()
2259 .join("\n\n")
2260}
2261
2262fn is_os_mismatch_tool_output(output: &str) -> bool {
2263 let lower = output.to_ascii_lowercase();
2264 lower.contains("os error 3")
2265 || lower.contains("system cannot find the path specified")
2266 || lower.contains("command not found")
2267 || lower.contains("is not recognized as an internal or external command")
2268 || lower.contains("shell command blocked on windows")
2269}
2270
2271fn tandem_runtime_system_prompt(host: &HostRuntimeContext) -> String {
2272 let mut sections = Vec::new();
2273 if os_aware_prompts_enabled() {
2274 sections.push(format!(
2275 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
2276 host_os_label(host.os),
2277 shell_family_label(host.shell_family),
2278 path_style_label(host.path_style),
2279 host.arch
2280 ));
2281 }
2282 sections.push(
2283 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
2284Use tool calls to inspect and modify the workspace when needed instead of asking the user
2285to manually run basic discovery steps. Permission prompts may occur for some tools; if
2286a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
2287 .to_string(),
2288 );
2289 if host.os == HostOs::Windows {
2290 sections.push(
2291 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
2292Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
2293If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
2294 .to_string(),
2295 );
2296 } else {
2297 sections.push(
2298 "POSIX guidance: standard shell commands are available.
2299Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
2300 .to_string(),
2301 );
2302 }
2303 sections.join("\n\n")
2304}
2305
2306fn os_aware_prompts_enabled() -> bool {
2307 std::env::var("TANDEM_OS_AWARE_PROMPTS")
2308 .ok()
2309 .map(|v| {
2310 let normalized = v.trim().to_ascii_lowercase();
2311 !(normalized == "0" || normalized == "false" || normalized == "off")
2312 })
2313 .unwrap_or(true)
2314}
2315
2316fn host_os_label(os: HostOs) -> &'static str {
2317 match os {
2318 HostOs::Windows => "windows",
2319 HostOs::Linux => "linux",
2320 HostOs::Macos => "macos",
2321 }
2322}
2323
2324fn shell_family_label(shell: ShellFamily) -> &'static str {
2325 match shell {
2326 ShellFamily::Powershell => "powershell",
2327 ShellFamily::Posix => "posix",
2328 }
2329}
2330
2331fn path_style_label(path_style: PathStyle) -> &'static str {
2332 match path_style {
2333 PathStyle::Windows => "windows",
2334 PathStyle::Posix => "posix",
2335 }
2336}
2337
2338fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
2339 let user = user_text.to_lowercase();
2340 let reply = completion.to_lowercase();
2341
2342 let asked_for_project_context = [
2343 "what is this project",
2344 "what's this project",
2345 "explain this project",
2346 "analyze this project",
2347 "inspect this project",
2348 "look at the project",
2349 "use glob",
2350 "run glob",
2351 ]
2352 .iter()
2353 .any(|needle| user.contains(needle));
2354
2355 if !asked_for_project_context {
2356 return false;
2357 }
2358
2359 let assistant_claimed_no_access = [
2360 "can't inspect",
2361 "cannot inspect",
2362 "don't have visibility",
2363 "haven't been able to inspect",
2364 "i don't know what this project is",
2365 "need your help to",
2366 "sandbox",
2367 "system restriction",
2368 ]
2369 .iter()
2370 .any(|needle| reply.contains(needle));
2371
2372 asked_for_project_context && assistant_claimed_no_access
2375}
2376
2377fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
2378 let raw = input.trim();
2379 if !raw.starts_with("/tool ") {
2380 return None;
2381 }
2382 let rest = raw.trim_start_matches("/tool ").trim();
2383 let mut split = rest.splitn(2, ' ');
2384 let tool = normalize_tool_name(split.next()?.trim());
2385 let args = split
2386 .next()
2387 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
2388 .unwrap_or_else(|| json!({}));
2389 Some((tool, args))
2390}
2391
2392fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
2393 let trimmed = input.trim();
2394 if trimmed.is_empty() {
2395 return Vec::new();
2396 }
2397
2398 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
2399 if let Some(found) = extract_tool_call_from_value(&parsed) {
2400 return vec![found];
2401 }
2402 }
2403
2404 if let Some(block) = extract_first_json_object(trimmed) {
2405 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
2406 if let Some(found) = extract_tool_call_from_value(&parsed) {
2407 return vec![found];
2408 }
2409 }
2410 }
2411
2412 parse_function_style_tool_calls(trimmed)
2413}
2414
2415#[cfg(test)]
2416fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
2417 parse_tool_invocations_from_response(input)
2418 .into_iter()
2419 .next()
2420}
2421
2422fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
2423 let mut calls = Vec::new();
2424 let lower = input.to_lowercase();
2425 let names = [
2426 "todo_write",
2427 "todowrite",
2428 "update_todo_list",
2429 "update_todos",
2430 ];
2431 let mut cursor = 0usize;
2432
2433 while cursor < lower.len() {
2434 let mut best: Option<(usize, &str)> = None;
2435 for name in names {
2436 let needle = format!("{name}(");
2437 if let Some(rel_idx) = lower[cursor..].find(&needle) {
2438 let idx = cursor + rel_idx;
2439 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
2440 best = Some((idx, name));
2441 }
2442 }
2443 }
2444
2445 let Some((tool_start, tool_name)) = best else {
2446 break;
2447 };
2448
2449 let open_paren = tool_start + tool_name.len();
2450 if let Some(close_paren) = find_matching_paren(input, open_paren) {
2451 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
2452 let args = parse_function_style_args(args_text.trim());
2453 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
2454 }
2455 cursor = close_paren.saturating_add(1);
2456 } else {
2457 cursor = tool_start.saturating_add(tool_name.len());
2458 }
2459 }
2460
2461 calls
2462}
2463
2464fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
2465 if input.as_bytes().get(open_paren).copied()? != b'(' {
2466 return None;
2467 }
2468
2469 let mut depth = 0usize;
2470 let mut in_single = false;
2471 let mut in_double = false;
2472 let mut escaped = false;
2473
2474 for (offset, ch) in input.get(open_paren..)?.char_indices() {
2475 if escaped {
2476 escaped = false;
2477 continue;
2478 }
2479 if ch == '\\' && (in_single || in_double) {
2480 escaped = true;
2481 continue;
2482 }
2483 if ch == '\'' && !in_double {
2484 in_single = !in_single;
2485 continue;
2486 }
2487 if ch == '"' && !in_single {
2488 in_double = !in_double;
2489 continue;
2490 }
2491 if in_single || in_double {
2492 continue;
2493 }
2494
2495 match ch {
2496 '(' => depth += 1,
2497 ')' => {
2498 depth = depth.saturating_sub(1);
2499 if depth == 0 {
2500 return Some(open_paren + offset);
2501 }
2502 }
2503 _ => {}
2504 }
2505 }
2506
2507 None
2508}
2509
2510fn parse_function_style_args(input: &str) -> Map<String, Value> {
2511 let mut args = Map::new();
2512 if input.trim().is_empty() {
2513 return args;
2514 }
2515
2516 let mut parts = Vec::<String>::new();
2517 let mut current = String::new();
2518 let mut in_single = false;
2519 let mut in_double = false;
2520 let mut escaped = false;
2521 let mut depth_paren = 0usize;
2522 let mut depth_bracket = 0usize;
2523 let mut depth_brace = 0usize;
2524
2525 for ch in input.chars() {
2526 if escaped {
2527 current.push(ch);
2528 escaped = false;
2529 continue;
2530 }
2531 if ch == '\\' && (in_single || in_double) {
2532 current.push(ch);
2533 escaped = true;
2534 continue;
2535 }
2536 if ch == '\'' && !in_double {
2537 in_single = !in_single;
2538 current.push(ch);
2539 continue;
2540 }
2541 if ch == '"' && !in_single {
2542 in_double = !in_double;
2543 current.push(ch);
2544 continue;
2545 }
2546 if in_single || in_double {
2547 current.push(ch);
2548 continue;
2549 }
2550
2551 match ch {
2552 '(' => depth_paren += 1,
2553 ')' => depth_paren = depth_paren.saturating_sub(1),
2554 '[' => depth_bracket += 1,
2555 ']' => depth_bracket = depth_bracket.saturating_sub(1),
2556 '{' => depth_brace += 1,
2557 '}' => depth_brace = depth_brace.saturating_sub(1),
2558 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
2559 let part = current.trim();
2560 if !part.is_empty() {
2561 parts.push(part.to_string());
2562 }
2563 current.clear();
2564 continue;
2565 }
2566 _ => {}
2567 }
2568 current.push(ch);
2569 }
2570 let tail = current.trim();
2571 if !tail.is_empty() {
2572 parts.push(tail.to_string());
2573 }
2574
2575 for part in parts {
2576 let Some((raw_key, raw_value)) = part
2577 .split_once('=')
2578 .or_else(|| part.split_once(':'))
2579 .map(|(k, v)| (k.trim(), v.trim()))
2580 else {
2581 continue;
2582 };
2583 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
2584 if key.is_empty() {
2585 continue;
2586 }
2587 let value = parse_scalar_like_value(raw_value);
2588 args.insert(key.to_string(), value);
2589 }
2590
2591 args
2592}
2593
2594fn parse_scalar_like_value(raw: &str) -> Value {
2595 let trimmed = raw.trim();
2596 if trimmed.is_empty() {
2597 return Value::Null;
2598 }
2599
2600 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
2601 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
2602 {
2603 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
2604 }
2605
2606 if trimmed.eq_ignore_ascii_case("true") {
2607 return Value::Bool(true);
2608 }
2609 if trimmed.eq_ignore_ascii_case("false") {
2610 return Value::Bool(false);
2611 }
2612 if trimmed.eq_ignore_ascii_case("null") {
2613 return Value::Null;
2614 }
2615
2616 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
2617 return v;
2618 }
2619 if let Ok(v) = trimmed.parse::<i64>() {
2620 return Value::Number(Number::from(v));
2621 }
2622 if let Ok(v) = trimmed.parse::<f64>() {
2623 if let Some(n) = Number::from_f64(v) {
2624 return Value::Number(n);
2625 }
2626 }
2627
2628 Value::String(trimmed.to_string())
2629}
2630
2631fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
2632 if is_todo_status_update_args(&args) {
2633 return args;
2634 }
2635
2636 let mut obj = match args {
2637 Value::Object(map) => map,
2638 Value::Array(items) => {
2639 return json!({ "todos": normalize_todo_arg_items(items) });
2640 }
2641 Value::String(text) => {
2642 let derived = extract_todo_candidates_from_text(&text);
2643 if !derived.is_empty() {
2644 return json!({ "todos": derived });
2645 }
2646 return json!({});
2647 }
2648 _ => return json!({}),
2649 };
2650
2651 if obj
2652 .get("todos")
2653 .and_then(|v| v.as_array())
2654 .map(|arr| !arr.is_empty())
2655 .unwrap_or(false)
2656 {
2657 return Value::Object(obj);
2658 }
2659
2660 for alias in ["tasks", "items", "list", "checklist"] {
2661 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
2662 let normalized = normalize_todo_arg_items(items.clone());
2663 if !normalized.is_empty() {
2664 obj.insert("todos".to_string(), Value::Array(normalized));
2665 return Value::Object(obj);
2666 }
2667 }
2668 }
2669
2670 let derived = extract_todo_candidates_from_text(completion);
2671 if !derived.is_empty() {
2672 obj.insert("todos".to_string(), Value::Array(derived));
2673 }
2674 Value::Object(obj)
2675}
2676
2677fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
2678 items
2679 .into_iter()
2680 .filter_map(|item| match item {
2681 Value::String(text) => {
2682 let content = text.trim();
2683 if content.is_empty() {
2684 None
2685 } else {
2686 Some(json!({"content": content}))
2687 }
2688 }
2689 Value::Object(mut obj) => {
2690 if !obj.contains_key("content") {
2691 if let Some(text) = obj.get("text").cloned() {
2692 obj.insert("content".to_string(), text);
2693 } else if let Some(title) = obj.get("title").cloned() {
2694 obj.insert("content".to_string(), title);
2695 } else if let Some(name) = obj.get("name").cloned() {
2696 obj.insert("content".to_string(), name);
2697 }
2698 }
2699 let content = obj
2700 .get("content")
2701 .and_then(|v| v.as_str())
2702 .map(str::trim)
2703 .unwrap_or("");
2704 if content.is_empty() {
2705 None
2706 } else {
2707 Some(Value::Object(obj))
2708 }
2709 }
2710 _ => None,
2711 })
2712 .collect()
2713}
2714
2715fn is_todo_status_update_args(args: &Value) -> bool {
2716 let Some(obj) = args.as_object() else {
2717 return false;
2718 };
2719 let has_status = obj
2720 .get("status")
2721 .and_then(|v| v.as_str())
2722 .map(|s| !s.trim().is_empty())
2723 .unwrap_or(false);
2724 let has_target =
2725 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
2726 has_status && has_target
2727}
2728
2729fn is_empty_todo_write_args(args: &Value) -> bool {
2730 if is_todo_status_update_args(args) {
2731 return false;
2732 }
2733 let Some(obj) = args.as_object() else {
2734 return true;
2735 };
2736 !obj.get("todos")
2737 .and_then(|v| v.as_array())
2738 .map(|arr| !arr.is_empty())
2739 .unwrap_or(false)
2740}
2741
2742fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
2743 let trimmed = raw_args.trim();
2744 if trimmed.is_empty() {
2745 return json!({});
2746 }
2747
2748 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2749 return normalize_streamed_tool_args(tool_name, parsed, trimmed);
2750 }
2751
2752 let kv_args = parse_function_style_args(trimmed);
2755 if !kv_args.is_empty() {
2756 return normalize_streamed_tool_args(tool_name, Value::Object(kv_args), trimmed);
2757 }
2758
2759 if normalize_tool_name(tool_name) == "websearch" {
2760 return json!({ "query": trimmed });
2761 }
2762
2763 json!({})
2764}
2765
2766fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
2767 let normalized_tool = normalize_tool_name(tool_name);
2768 if normalized_tool != "websearch" {
2769 return parsed;
2770 }
2771
2772 match parsed {
2773 Value::Object(mut obj) => {
2774 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
2775 obj.insert("query".to_string(), Value::String(raw.trim().to_string()));
2776 }
2777 Value::Object(obj)
2778 }
2779 Value::String(s) => {
2780 let q = s.trim();
2781 if q.is_empty() {
2782 json!({})
2783 } else {
2784 json!({ "query": q })
2785 }
2786 }
2787 other => other,
2788 }
2789}
2790
2791fn has_websearch_query(obj: &Map<String, Value>) -> bool {
2792 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2793 QUERY_KEYS.iter().any(|key| {
2794 obj.get(*key)
2795 .and_then(|v| v.as_str())
2796 .map(|s| !s.trim().is_empty())
2797 .unwrap_or(false)
2798 })
2799}
2800
2801fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
2802 if let Some(obj) = value.as_object() {
2803 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
2804 return Some((
2805 normalize_tool_name(tool),
2806 obj.get("args").cloned().unwrap_or_else(|| json!({})),
2807 ));
2808 }
2809
2810 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
2811 let args = obj
2812 .get("args")
2813 .cloned()
2814 .or_else(|| obj.get("arguments").cloned())
2815 .unwrap_or_else(|| json!({}));
2816 let normalized_tool = normalize_tool_name(tool);
2817 let args = if let Some(raw) = args.as_str() {
2818 parse_streamed_tool_args(&normalized_tool, raw)
2819 } else {
2820 args
2821 };
2822 return Some((normalized_tool, args));
2823 }
2824
2825 for key in [
2826 "tool_call",
2827 "toolCall",
2828 "call",
2829 "function_call",
2830 "functionCall",
2831 ] {
2832 if let Some(nested) = obj.get(key) {
2833 if let Some(found) = extract_tool_call_from_value(nested) {
2834 return Some(found);
2835 }
2836 }
2837 }
2838 }
2839
2840 if let Some(items) = value.as_array() {
2841 for item in items {
2842 if let Some(found) = extract_tool_call_from_value(item) {
2843 return Some(found);
2844 }
2845 }
2846 }
2847
2848 None
2849}
2850
2851fn extract_first_json_object(input: &str) -> Option<String> {
2852 let mut start = None;
2853 let mut depth = 0usize;
2854 for (idx, ch) in input.char_indices() {
2855 if ch == '{' {
2856 if start.is_none() {
2857 start = Some(idx);
2858 }
2859 depth += 1;
2860 } else if ch == '}' {
2861 if depth == 0 {
2862 continue;
2863 }
2864 depth -= 1;
2865 if depth == 0 {
2866 let begin = start?;
2867 let block = input.get(begin..=idx)?;
2868 return Some(block.to_string());
2869 }
2870 }
2871 }
2872 None
2873}
2874
2875fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
2876 let mut seen = HashSet::<String>::new();
2877 let mut todos = Vec::new();
2878
2879 for raw_line in input.lines() {
2880 let mut line = raw_line.trim();
2881 let mut structured_line = false;
2882 if line.is_empty() {
2883 continue;
2884 }
2885 if line.starts_with("```") {
2886 continue;
2887 }
2888 if line.ends_with(':') {
2889 continue;
2890 }
2891 if let Some(rest) = line
2892 .strip_prefix("- [ ]")
2893 .or_else(|| line.strip_prefix("* [ ]"))
2894 .or_else(|| line.strip_prefix("- [x]"))
2895 .or_else(|| line.strip_prefix("* [x]"))
2896 {
2897 line = rest.trim();
2898 structured_line = true;
2899 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
2900 line = rest.trim();
2901 structured_line = true;
2902 } else {
2903 let bytes = line.as_bytes();
2904 let mut i = 0usize;
2905 while i < bytes.len() && bytes[i].is_ascii_digit() {
2906 i += 1;
2907 }
2908 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
2909 line = line[i + 1..].trim();
2910 structured_line = true;
2911 }
2912 }
2913 if !structured_line {
2914 continue;
2915 }
2916
2917 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
2918 if content.len() < 5 || content.len() > 180 {
2919 continue;
2920 }
2921 let key = content.to_lowercase();
2922 if seen.contains(&key) {
2923 continue;
2924 }
2925 seen.insert(key);
2926 todos.push(json!({ "content": content }));
2927 if todos.len() >= 25 {
2928 break;
2929 }
2930 }
2931
2932 todos
2933}
2934
2935async fn emit_plan_todo_fallback(
2936 storage: std::sync::Arc<Storage>,
2937 bus: &EventBus,
2938 session_id: &str,
2939 message_id: &str,
2940 completion: &str,
2941) {
2942 let todos = extract_todo_candidates_from_text(completion);
2943 if todos.is_empty() {
2944 return;
2945 }
2946
2947 let invoke_part = WireMessagePart::tool_invocation(
2948 session_id,
2949 message_id,
2950 "todo_write",
2951 json!({"todos": todos.clone()}),
2952 );
2953 let call_id = invoke_part.id.clone();
2954 bus.publish(EngineEvent::new(
2955 "message.part.updated",
2956 json!({"part": invoke_part}),
2957 ));
2958
2959 if storage.set_todos(session_id, todos).await.is_err() {
2960 let mut failed_part =
2961 WireMessagePart::tool_result(session_id, message_id, "todo_write", json!(null));
2962 failed_part.id = call_id;
2963 failed_part.state = Some("failed".to_string());
2964 failed_part.error = Some("failed to persist plan todos".to_string());
2965 bus.publish(EngineEvent::new(
2966 "message.part.updated",
2967 json!({"part": failed_part}),
2968 ));
2969 return;
2970 }
2971
2972 let normalized = storage.get_todos(session_id).await;
2973 let mut result_part = WireMessagePart::tool_result(
2974 session_id,
2975 message_id,
2976 "todo_write",
2977 json!({ "todos": normalized }),
2978 );
2979 result_part.id = call_id;
2980 bus.publish(EngineEvent::new(
2981 "message.part.updated",
2982 json!({"part": result_part}),
2983 ));
2984 bus.publish(EngineEvent::new(
2985 "todo.updated",
2986 json!({
2987 "sessionID": session_id,
2988 "todos": normalized
2989 }),
2990 ));
2991}
2992
2993async fn emit_plan_question_fallback(
2994 storage: std::sync::Arc<Storage>,
2995 bus: &EventBus,
2996 session_id: &str,
2997 message_id: &str,
2998 completion: &str,
2999) {
3000 let trimmed = completion.trim();
3001 if trimmed.is_empty() {
3002 return;
3003 }
3004
3005 let hints = extract_todo_candidates_from_text(trimmed)
3006 .into_iter()
3007 .take(6)
3008 .filter_map(|v| {
3009 v.get("content")
3010 .and_then(|c| c.as_str())
3011 .map(ToString::to_string)
3012 })
3013 .collect::<Vec<_>>();
3014
3015 let mut options = hints
3016 .iter()
3017 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
3018 .collect::<Vec<_>>();
3019 if options.is_empty() {
3020 options = vec![
3021 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
3022 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
3023 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
3024 ];
3025 }
3026
3027 let question_payload = vec![json!({
3028 "header":"Planning Input",
3029 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
3030 "options": options,
3031 "multiple": true,
3032 "custom": true
3033 })];
3034
3035 let request = storage
3036 .add_question_request(session_id, message_id, question_payload.clone())
3037 .await
3038 .ok();
3039 bus.publish(EngineEvent::new(
3040 "question.asked",
3041 json!({
3042 "id": request
3043 .as_ref()
3044 .map(|req| req.id.clone())
3045 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3046 "sessionID": session_id,
3047 "messageID": message_id,
3048 "questions": question_payload,
3049 "tool": request.and_then(|req| {
3050 req.tool.map(|tool| {
3051 json!({
3052 "callID": tool.call_id,
3053 "messageID": tool.message_id
3054 })
3055 })
3056 })
3057 }),
3058 ));
3059}
3060
3061async fn load_chat_history(storage: std::sync::Arc<Storage>, session_id: &str) -> Vec<ChatMessage> {
3062 let Some(session) = storage.get_session(session_id).await else {
3063 return Vec::new();
3064 };
3065 let messages = session
3066 .messages
3067 .into_iter()
3068 .map(|m| {
3069 let role = format!("{:?}", m.role).to_lowercase();
3070 let content = m
3071 .parts
3072 .into_iter()
3073 .map(|part| match part {
3074 MessagePart::Text { text } => text,
3075 MessagePart::Reasoning { text } => text,
3076 MessagePart::ToolInvocation { tool, result, .. } => {
3077 format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
3078 }
3079 })
3080 .collect::<Vec<_>>()
3081 .join("\n");
3082 ChatMessage { role, content }
3083 })
3084 .collect::<Vec<_>>();
3085 compact_chat_history(messages)
3086}
3087
3088async fn emit_tool_side_events(
3089 storage: std::sync::Arc<Storage>,
3090 bus: &EventBus,
3091 session_id: &str,
3092 message_id: &str,
3093 tool: &str,
3094 args: &serde_json::Value,
3095 metadata: &serde_json::Value,
3096 workspace_root: Option<&str>,
3097 effective_cwd: Option<&str>,
3098) {
3099 if tool == "todo_write" {
3100 let todos_from_metadata = metadata
3101 .get("todos")
3102 .and_then(|v| v.as_array())
3103 .cloned()
3104 .unwrap_or_default();
3105
3106 if !todos_from_metadata.is_empty() {
3107 let _ = storage.set_todos(session_id, todos_from_metadata).await;
3108 } else {
3109 let current = storage.get_todos(session_id).await;
3110 if let Some(updated) = apply_todo_updates_from_args(current, args) {
3111 let _ = storage.set_todos(session_id, updated).await;
3112 }
3113 }
3114
3115 let normalized = storage.get_todos(session_id).await;
3116 bus.publish(EngineEvent::new(
3117 "todo.updated",
3118 json!({
3119 "sessionID": session_id,
3120 "todos": normalized,
3121 "workspaceRoot": workspace_root,
3122 "effectiveCwd": effective_cwd
3123 }),
3124 ));
3125 }
3126 if tool == "question" {
3127 let questions = metadata
3128 .get("questions")
3129 .and_then(|v| v.as_array())
3130 .cloned()
3131 .unwrap_or_default();
3132 let request = storage
3133 .add_question_request(session_id, message_id, questions.clone())
3134 .await
3135 .ok();
3136 bus.publish(EngineEvent::new(
3137 "question.asked",
3138 json!({
3139 "id": request
3140 .as_ref()
3141 .map(|req| req.id.clone())
3142 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3143 "sessionID": session_id,
3144 "messageID": message_id,
3145 "questions": questions,
3146 "tool": request.and_then(|req| {
3147 req.tool.map(|tool| {
3148 json!({
3149 "callID": tool.call_id,
3150 "messageID": tool.message_id
3151 })
3152 })
3153 }),
3154 "workspaceRoot": workspace_root,
3155 "effectiveCwd": effective_cwd
3156 }),
3157 ));
3158 }
3159}
3160
3161fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
3162 let obj = args.as_object()?;
3163 let mut todos = current;
3164 let mut changed = false;
3165
3166 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
3167 for item in items {
3168 let Some(item_obj) = item.as_object() else {
3169 continue;
3170 };
3171 let status = item_obj
3172 .get("status")
3173 .and_then(|v| v.as_str())
3174 .map(normalize_todo_status);
3175 let target = item_obj
3176 .get("task_id")
3177 .or_else(|| item_obj.get("todo_id"))
3178 .or_else(|| item_obj.get("id"));
3179
3180 if let (Some(status), Some(target)) = (status, target) {
3181 changed |= apply_single_todo_status_update(&mut todos, target, &status);
3182 }
3183 }
3184 }
3185
3186 let status = obj
3187 .get("status")
3188 .and_then(|v| v.as_str())
3189 .map(normalize_todo_status);
3190 let target = obj
3191 .get("task_id")
3192 .or_else(|| obj.get("todo_id"))
3193 .or_else(|| obj.get("id"));
3194 if let (Some(status), Some(target)) = (status, target) {
3195 changed |= apply_single_todo_status_update(&mut todos, target, &status);
3196 }
3197
3198 if changed {
3199 Some(todos)
3200 } else {
3201 None
3202 }
3203}
3204
3205fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
3206 let idx_from_value = match target {
3207 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
3208 Value::String(s) => {
3209 let trimmed = s.trim();
3210 trimmed
3211 .parse::<usize>()
3212 .ok()
3213 .map(|v| v.saturating_sub(1))
3214 .or_else(|| {
3215 let digits = trimmed
3216 .chars()
3217 .rev()
3218 .take_while(|c| c.is_ascii_digit())
3219 .collect::<String>()
3220 .chars()
3221 .rev()
3222 .collect::<String>();
3223 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
3224 })
3225 }
3226 _ => None,
3227 };
3228
3229 if let Some(idx) = idx_from_value {
3230 if idx < todos.len() {
3231 if let Some(obj) = todos[idx].as_object_mut() {
3232 obj.insert("status".to_string(), Value::String(status.to_string()));
3233 return true;
3234 }
3235 }
3236 }
3237
3238 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
3239 if let Some(id_target) = id_target {
3240 for todo in todos.iter_mut() {
3241 if let Some(obj) = todo.as_object_mut() {
3242 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
3243 obj.insert("status".to_string(), Value::String(status.to_string()));
3244 return true;
3245 }
3246 }
3247 }
3248 }
3249
3250 false
3251}
3252
3253fn normalize_todo_status(raw: &str) -> String {
3254 match raw.trim().to_lowercase().as_str() {
3255 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
3256 "done" | "complete" | "completed" => "completed".to_string(),
3257 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
3258 "open" | "todo" | "pending" => "pending".to_string(),
3259 other => other.to_string(),
3260 }
3261}
3262
3263fn compact_chat_history(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
3264 const MAX_CONTEXT_CHARS: usize = 80_000;
3265 const KEEP_RECENT_MESSAGES: usize = 40;
3266
3267 if messages.len() <= KEEP_RECENT_MESSAGES {
3268 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
3269 if total_chars <= MAX_CONTEXT_CHARS {
3270 return messages;
3271 }
3272 }
3273
3274 let mut kept = messages;
3275 let mut dropped_count = 0usize;
3276 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
3277
3278 while kept.len() > KEEP_RECENT_MESSAGES || total_chars > MAX_CONTEXT_CHARS {
3279 if kept.is_empty() {
3280 break;
3281 }
3282 let removed = kept.remove(0);
3283 total_chars = total_chars.saturating_sub(removed.content.len());
3284 dropped_count += 1;
3285 }
3286
3287 if dropped_count > 0 {
3288 kept.insert(
3289 0,
3290 ChatMessage {
3291 role: "system".to_string(),
3292 content: format!(
3293 "[history compacted: omitted {} older messages to fit context window]",
3294 dropped_count
3295 ),
3296 },
3297 );
3298 }
3299 kept
3300}
3301
3302#[cfg(test)]
3303mod tests {
3304 use super::*;
3305 use crate::{EventBus, Storage};
3306 use uuid::Uuid;
3307
3308 #[tokio::test]
3309 async fn todo_updated_event_is_normalized() {
3310 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3311 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3312 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3313 let session_id = session.id.clone();
3314 storage.save_session(session).await.expect("save session");
3315
3316 let bus = EventBus::new();
3317 let mut rx = bus.subscribe();
3318 emit_tool_side_events(
3319 storage.clone(),
3320 &bus,
3321 &session_id,
3322 "m1",
3323 "todo_write",
3324 &json!({"todos":[{"content":"ship parity"}]}),
3325 &json!({"todos":[{"content":"ship parity"}]}),
3326 Some("."),
3327 Some("."),
3328 )
3329 .await;
3330
3331 let event = rx.recv().await.expect("event");
3332 assert_eq!(event.event_type, "todo.updated");
3333 let todos = event
3334 .properties
3335 .get("todos")
3336 .and_then(|v| v.as_array())
3337 .cloned()
3338 .unwrap_or_default();
3339 assert_eq!(todos.len(), 1);
3340 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
3341 assert_eq!(
3342 todos[0].get("content").and_then(|v| v.as_str()),
3343 Some("ship parity")
3344 );
3345 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
3346 }
3347
3348 #[tokio::test]
3349 async fn question_asked_event_contains_tool_reference() {
3350 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3351 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3352 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3353 let session_id = session.id.clone();
3354 storage.save_session(session).await.expect("save session");
3355
3356 let bus = EventBus::new();
3357 let mut rx = bus.subscribe();
3358 emit_tool_side_events(
3359 storage,
3360 &bus,
3361 &session_id,
3362 "msg-1",
3363 "question",
3364 &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3365 &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3366 Some("."),
3367 Some("."),
3368 )
3369 .await;
3370
3371 let event = rx.recv().await.expect("event");
3372 assert_eq!(event.event_type, "question.asked");
3373 assert_eq!(
3374 event
3375 .properties
3376 .get("sessionID")
3377 .and_then(|v| v.as_str())
3378 .unwrap_or(""),
3379 session_id
3380 );
3381 let tool = event
3382 .properties
3383 .get("tool")
3384 .cloned()
3385 .unwrap_or_else(|| json!({}));
3386 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
3387 assert_eq!(
3388 tool.get("messageID").and_then(|v| v.as_str()),
3389 Some("msg-1")
3390 );
3391 }
3392
3393 #[test]
3394 fn compact_chat_history_keeps_recent_and_inserts_summary() {
3395 let mut messages = Vec::new();
3396 for i in 0..60 {
3397 messages.push(ChatMessage {
3398 role: "user".to_string(),
3399 content: format!("message-{i}"),
3400 });
3401 }
3402 let compacted = compact_chat_history(messages);
3403 assert!(compacted.len() <= 41);
3404 assert_eq!(compacted[0].role, "system");
3405 assert!(compacted[0].content.contains("history compacted"));
3406 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
3407 }
3408
3409 #[test]
3410 fn extracts_todos_from_checklist_and_numbered_lines() {
3411 let input = r#"
3412Plan:
3413- [ ] Audit current implementation
3414- [ ] Add planner fallback
34151. Add regression test coverage
3416"#;
3417 let todos = extract_todo_candidates_from_text(input);
3418 assert_eq!(todos.len(), 3);
3419 assert_eq!(
3420 todos[0].get("content").and_then(|v| v.as_str()),
3421 Some("Audit current implementation")
3422 );
3423 }
3424
3425 #[test]
3426 fn does_not_extract_todos_from_plain_prose_lines() {
3427 let input = r#"
3428I need more information to proceed.
3429Can you tell me the event size and budget?
3430Once I have that, I can provide a detailed plan.
3431"#;
3432 let todos = extract_todo_candidates_from_text(input);
3433 assert!(todos.is_empty());
3434 }
3435
3436 #[test]
3437 fn parses_wrapped_tool_call_from_markdown_response() {
3438 let input = r#"
3439Here is the tool call:
3440```json
3441{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
3442```
3443"#;
3444 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
3445 assert_eq!(parsed.0, "todo_write");
3446 assert!(parsed.1.get("todos").is_some());
3447 }
3448
3449 #[test]
3450 fn parses_function_style_todowrite_call() {
3451 let input = r#"Status: Completed
3452Call: todowrite(task_id=2, status="completed")"#;
3453 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
3454 assert_eq!(parsed.0, "todo_write");
3455 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3456 assert_eq!(
3457 parsed.1.get("status").and_then(|v| v.as_str()),
3458 Some("completed")
3459 );
3460 }
3461
3462 #[test]
3463 fn parses_multiple_function_style_todowrite_calls() {
3464 let input = r#"
3465Call: todowrite(task_id=2, status="completed")
3466Call: todowrite(task_id=3, status="in_progress")
3467"#;
3468 let parsed = parse_tool_invocations_from_response(input);
3469 assert_eq!(parsed.len(), 2);
3470 assert_eq!(parsed[0].0, "todo_write");
3471 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3472 assert_eq!(
3473 parsed[0].1.get("status").and_then(|v| v.as_str()),
3474 Some("completed")
3475 );
3476 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
3477 assert_eq!(
3478 parsed[1].1.get("status").and_then(|v| v.as_str()),
3479 Some("in_progress")
3480 );
3481 }
3482
3483 #[test]
3484 fn applies_todo_status_update_from_task_id_args() {
3485 let current = vec![
3486 json!({"id":"todo-1","content":"a","status":"pending"}),
3487 json!({"id":"todo-2","content":"b","status":"pending"}),
3488 json!({"id":"todo-3","content":"c","status":"pending"}),
3489 ];
3490 let updated =
3491 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
3492 .expect("status update");
3493 assert_eq!(
3494 updated[1].get("status").and_then(|v| v.as_str()),
3495 Some("completed")
3496 );
3497 }
3498
3499 #[test]
3500 fn normalizes_todo_write_tasks_alias() {
3501 let normalized = normalize_todo_write_args(
3502 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
3503 "",
3504 );
3505 let todos = normalized
3506 .get("todos")
3507 .and_then(|v| v.as_array())
3508 .cloned()
3509 .unwrap_or_default();
3510 assert_eq!(todos.len(), 2);
3511 assert_eq!(
3512 todos[0].get("content").and_then(|v| v.as_str()),
3513 Some("Book venue")
3514 );
3515 assert_eq!(
3516 todos[1].get("content").and_then(|v| v.as_str()),
3517 Some("Send invites")
3518 );
3519 }
3520
3521 #[test]
3522 fn normalizes_todo_write_from_completion_when_args_empty() {
3523 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
3524 let normalized = normalize_todo_write_args(json!({}), completion);
3525 let todos = normalized
3526 .get("todos")
3527 .and_then(|v| v.as_array())
3528 .cloned()
3529 .unwrap_or_default();
3530 assert_eq!(todos.len(), 3);
3531 assert!(!is_empty_todo_write_args(&normalized));
3532 }
3533
3534 #[test]
3535 fn empty_todo_write_args_allows_status_updates() {
3536 let args = json!({"task_id": 2, "status":"completed"});
3537 assert!(!is_empty_todo_write_args(&args));
3538 }
3539
3540 #[test]
3541 fn streamed_websearch_args_fallback_to_query_string() {
3542 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
3543 assert_eq!(
3544 parsed.get("query").and_then(|v| v.as_str()),
3545 Some("meaning of life")
3546 );
3547 }
3548
3549 #[test]
3550 fn streamed_websearch_stringified_json_args_are_unwrapped() {
3551 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
3552 assert_eq!(
3553 parsed.get("query").and_then(|v| v.as_str()),
3554 Some("donkey gestation period")
3555 );
3556 }
3557
3558 #[test]
3559 fn normalize_tool_args_websearch_infers_from_user_text() {
3560 let normalized =
3561 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
3562 assert_eq!(
3563 normalized.args.get("query").and_then(|v| v.as_str()),
3564 Some("meaning of life")
3565 );
3566 assert_eq!(normalized.args_source, "inferred_from_user");
3567 assert_eq!(normalized.args_integrity, "recovered");
3568 }
3569
3570 #[test]
3571 fn normalize_tool_args_websearch_keeps_existing_query() {
3572 let normalized = normalize_tool_args(
3573 "websearch",
3574 json!({"query":"already set"}),
3575 "web search should not override",
3576 "",
3577 );
3578 assert_eq!(
3579 normalized.args.get("query").and_then(|v| v.as_str()),
3580 Some("already set")
3581 );
3582 assert_eq!(normalized.args_source, "provider_json");
3583 assert_eq!(normalized.args_integrity, "ok");
3584 }
3585
3586 #[test]
3587 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
3588 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
3589 assert!(normalized.query.is_none());
3590 assert!(normalized.missing_terminal);
3591 assert_eq!(normalized.args_source, "missing");
3592 assert_eq!(normalized.args_integrity, "empty");
3593 }
3594
3595 #[test]
3596 fn normalize_tool_args_write_requires_path() {
3597 let normalized = normalize_tool_args("write", json!({}), "", "");
3598 assert!(normalized.missing_terminal);
3599 assert_eq!(
3600 normalized.missing_terminal_reason.as_deref(),
3601 Some("FILE_PATH_MISSING")
3602 );
3603 }
3604
3605 #[test]
3606 fn normalize_tool_args_write_recovers_alias_path_key() {
3607 let normalized = normalize_tool_args(
3608 "write",
3609 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
3610 "",
3611 "",
3612 );
3613 assert!(!normalized.missing_terminal);
3614 assert_eq!(
3615 normalized.args.get("path").and_then(|v| v.as_str()),
3616 Some("docs/CONCEPT.md")
3617 );
3618 assert_eq!(
3619 normalized.args.get("content").and_then(|v| v.as_str()),
3620 Some("hello")
3621 );
3622 }
3623
3624 #[test]
3625 fn normalize_tool_args_read_infers_path_from_user_prompt() {
3626 let normalized = normalize_tool_args(
3627 "read",
3628 json!({}),
3629 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
3630 "",
3631 );
3632 assert!(!normalized.missing_terminal);
3633 assert_eq!(
3634 normalized.args.get("path").and_then(|v| v.as_str()),
3635 Some("FEATURE_LIST.md")
3636 );
3637 assert_eq!(normalized.args_source, "inferred_from_user");
3638 assert_eq!(normalized.args_integrity, "recovered");
3639 }
3640
3641 #[test]
3642 fn normalize_tool_args_read_infers_path_from_assistant_context() {
3643 let normalized = normalize_tool_args(
3644 "read",
3645 json!({}),
3646 "generic instruction",
3647 "I will read src-tauri/src/orchestrator/engine.rs first.",
3648 );
3649 assert!(!normalized.missing_terminal);
3650 assert_eq!(
3651 normalized.args.get("path").and_then(|v| v.as_str()),
3652 Some("src-tauri/src/orchestrator/engine.rs")
3653 );
3654 assert_eq!(normalized.args_source, "inferred_from_context");
3655 assert_eq!(normalized.args_integrity, "recovered");
3656 }
3657
3658 #[test]
3659 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
3660 let normalized = normalize_tool_args(
3661 "write",
3662 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
3663 "",
3664 "",
3665 );
3666 assert!(!normalized.missing_terminal);
3667 assert_eq!(
3668 normalized.args.get("path").and_then(|v| v.as_str()),
3669 Some("docs/CONCEPT.md")
3670 );
3671 }
3672
3673 #[test]
3674 fn normalize_tool_args_write_recovers_content_alias() {
3675 let normalized = normalize_tool_args(
3676 "write",
3677 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
3678 "",
3679 "",
3680 );
3681 assert!(!normalized.missing_terminal);
3682 assert_eq!(
3683 normalized.args.get("content").and_then(|v| v.as_str()),
3684 Some("feature notes")
3685 );
3686 }
3687
3688 #[test]
3689 fn normalize_tool_args_write_fails_when_content_missing() {
3690 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
3691 assert!(normalized.missing_terminal);
3692 assert_eq!(
3693 normalized.missing_terminal_reason.as_deref(),
3694 Some("WRITE_CONTENT_MISSING")
3695 );
3696 }
3697
3698 #[test]
3699 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
3700 let normalized = normalize_tool_args(
3701 "write",
3702 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
3703 "",
3704 "",
3705 );
3706 assert!(!normalized.missing_terminal);
3707 assert_eq!(
3708 normalized.args.get("path").and_then(|v| v.as_str()),
3709 Some("docs/FEATURES.md")
3710 );
3711 assert_eq!(
3712 normalized.args.get("content").and_then(|v| v.as_str()),
3713 Some("Line 1\nLine 2")
3714 );
3715 }
3716
3717 #[test]
3718 fn normalize_tool_args_write_does_not_treat_path_as_content() {
3719 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
3720 assert!(normalized.missing_terminal);
3721 assert_eq!(
3722 normalized.missing_terminal_reason.as_deref(),
3723 Some("WRITE_CONTENT_MISSING")
3724 );
3725 }
3726
3727 #[test]
3728 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
3729 let normalized = normalize_tool_args(
3730 "read",
3731 json!({}),
3732 "Please read **FEATURE_LIST.md** and summarize.",
3733 "",
3734 );
3735 assert!(!normalized.missing_terminal);
3736 assert_eq!(
3737 normalized.args.get("path").and_then(|v| v.as_str()),
3738 Some("FEATURE_LIST.md")
3739 );
3740 }
3741
3742 #[test]
3743 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
3744 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
3745 assert!(!normalized.missing_terminal);
3746 assert_eq!(
3747 normalized.args.get("command").and_then(|v| v.as_str()),
3748 Some("rg -n \"TODO\" .")
3749 );
3750 assert_eq!(normalized.args_source, "inferred_from_user");
3751 assert_eq!(normalized.args_integrity, "recovered");
3752 }
3753
3754 #[test]
3755 fn normalize_tool_args_read_rejects_root_only_path() {
3756 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
3757 assert!(normalized.missing_terminal);
3758 assert_eq!(
3759 normalized.missing_terminal_reason.as_deref(),
3760 Some("FILE_PATH_MISSING")
3761 );
3762 }
3763
3764 #[test]
3765 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
3766 let normalized =
3767 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
3768 assert!(!normalized.missing_terminal);
3769 assert_eq!(
3770 normalized.args.get("path").and_then(|v| v.as_str()),
3771 Some("CONCEPT.md")
3772 );
3773 assert_eq!(normalized.args_source, "inferred_from_user");
3774 assert_eq!(normalized.args_integrity, "recovered");
3775 }
3776
3777 #[test]
3778 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
3779 let normalized = normalize_tool_args(
3780 "read",
3781 json!({
3782 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
3783 }),
3784 "",
3785 "",
3786 );
3787 assert!(normalized.missing_terminal);
3788 assert_eq!(
3789 normalized.missing_terminal_reason.as_deref(),
3790 Some("FILE_PATH_MISSING")
3791 );
3792 }
3793
3794 #[test]
3795 fn normalize_tool_args_read_rejects_glob_pattern_path() {
3796 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
3797 assert!(normalized.missing_terminal);
3798 assert_eq!(
3799 normalized.missing_terminal_reason.as_deref(),
3800 Some("FILE_PATH_MISSING")
3801 );
3802 }
3803
3804 #[test]
3805 fn normalize_tool_name_strips_default_api_namespace() {
3806 assert_eq!(normalize_tool_name("default_api:read"), "read");
3807 assert_eq!(normalize_tool_name("functions.shell"), "bash");
3808 }
3809
3810 #[test]
3811 fn batch_helpers_use_name_when_tool_is_wrapper() {
3812 let args = json!({
3813 "tool_calls":[
3814 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
3815 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
3816 ]
3817 });
3818 let calls = extract_batch_calls(&args);
3819 assert_eq!(calls.len(), 2);
3820 assert_eq!(calls[0].0, "read");
3821 assert_eq!(calls[1].0, "glob");
3822 assert!(is_read_only_batch_call(&args));
3823 let sig = batch_tool_signature(&args).unwrap_or_default();
3824 assert!(sig.contains("read:"));
3825 assert!(sig.contains("glob:"));
3826 }
3827
3828 #[test]
3829 fn batch_helpers_resolve_nested_function_name() {
3830 let args = json!({
3831 "tool_calls":[
3832 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
3833 ]
3834 });
3835 let calls = extract_batch_calls(&args);
3836 assert_eq!(calls.len(), 1);
3837 assert_eq!(calls[0].0, "read");
3838 assert!(is_read_only_batch_call(&args));
3839 }
3840
3841 #[test]
3842 fn batch_output_classifier_detects_non_productive_unknown_results() {
3843 let output = r#"
3844[
3845 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
3846 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
3847]
3848"#;
3849 assert!(is_non_productive_batch_output(output));
3850 }
3851
3852 #[test]
3853 fn runtime_prompt_includes_execution_environment_block() {
3854 let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {
3855 os: HostOs::Windows,
3856 arch: "x86_64".to_string(),
3857 shell_family: ShellFamily::Powershell,
3858 path_style: PathStyle::Windows,
3859 });
3860 assert!(prompt.contains("[Execution Environment]"));
3861 assert!(prompt.contains("Host OS: windows"));
3862 assert!(prompt.contains("Shell: powershell"));
3863 assert!(prompt.contains("Path style: windows"));
3864 }
3865}