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