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