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