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