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