Skip to main content

navi_core/tool/
mod.rs

1use crate::capability::{
2    CapabilityDecision, CapabilityLedgerEntry, CapabilityScope, capabilities_from_tool_metadata,
3};
4use crate::effect::PostDecision;
5use crate::event::AgentEvent;
6use crate::runtime_components::{DefaultToolSecurityPolicy, ToolSecurityPolicy};
7use crate::security::{SecurityDecision, SecurityPolicy};
8use anyhow::Result;
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value, json};
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::Arc;
15use tokio::sync::mpsc;
16
17pub mod background;
18pub(crate) mod builtin;
19pub mod metadata;
20pub mod registry;
21#[cfg(test)]
22mod tests;
23
24#[cfg(feature = "browser")]
25use builtin::BrowserTool;
26use builtin::{
27    AppendNoteTool, BashTool, CodeExecTool, ContextRemainingTool, CurrentTimeTool, EditTool,
28    HistoryOpsTool, InitSessionTool, MarkFeatureDoneTool, MemoryTool, MultiEditTool,
29    NewContextWindowTool, PackageManagerTool, PlanTool, QuestionTool, ReadTool,
30    RepoIntelligenceAction, RepoIntelligenceTool, RequestUserInputTool, RuntimeInfoTool,
31    SandboxTool, SearchTool, SleepTool, ToolSearchTool, ViewImageTool, WriteTool, builtin_metadata,
32    truncate_tool_result,
33};
34#[cfg(feature = "code-vfs")]
35use builtin::{CodeEditTool, CodeReadTool};
36
37pub use builtin::{
38    AgentBackend, AgentProfile, ApprovalMode, MockAgentBackend, PolicyAgentBackend,
39    ProviderBuilderFn, RepoExploreTool, SubagentBridgeBackend, SubagentTool, WorkerProbeBackend,
40    WorkflowTool, workflow_tool_description,
41};
42pub use metadata::{ToolExposure, ToolMetadata, ToolRisk, capabilities};
43pub use registry::{ToolRegistry, ToolSet, phases};
44
45#[async_trait]
46pub trait Tool: Send + Sync {
47    fn definition(&self) -> ToolDefinition;
48    async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolResult>;
49    async fn invoke_with_context(
50        &self,
51        invocation: ToolInvocation,
52        context: ToolInvocationContext,
53    ) -> Result<ToolResult> {
54        let _ = context;
55        self.invoke(invocation).await
56    }
57}
58
59#[derive(Clone, Default)]
60pub struct ToolInvocationContext {
61    pub event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
62    /// When set, bash can request a sudo password without exposing it to the model.
63    pub sudo_password_resolver: Option<crate::runtime::SudoPasswordResolver>,
64    pub cancel_token: Option<crate::cancel::CancelToken>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ToolDefinition {
69    pub name: String,
70    pub description: String,
71    pub kind: ToolKind,
72    #[serde(default)]
73    pub input_schema: Value,
74    /// Rich metadata for routing, policy, UI, traces, concurrency, and verifiers.
75    /// Backward-compatible: defaults to empty/unspecified when not present.
76    #[serde(default)]
77    pub metadata: ToolMetadata,
78}
79
80impl Default for ToolDefinition {
81    fn default() -> Self {
82        Self {
83            name: String::new(),
84            description: String::new(),
85            kind: ToolKind::Custom,
86            input_schema: Value::Object(Default::default()),
87            metadata: ToolMetadata::default(),
88        }
89    }
90}
91
92impl ToolDefinition {
93    /// Creates a new tool definition with the given fields and default metadata.
94    pub fn new(
95        name: impl Into<String>,
96        description: impl Into<String>,
97        kind: ToolKind,
98        input_schema: Value,
99    ) -> Self {
100        Self {
101            name: name.into(),
102            description: description.into(),
103            kind,
104            input_schema,
105            metadata: ToolMetadata::default(),
106        }
107    }
108
109    /// Creates a new tool definition with rich metadata.
110    pub fn with_metadata(
111        name: impl Into<String>,
112        description: impl Into<String>,
113        kind: ToolKind,
114        input_schema: Value,
115        metadata: ToolMetadata,
116    ) -> Self {
117        Self {
118            name: name.into(),
119            description: description.into(),
120            kind,
121            input_schema,
122            metadata,
123        }
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub enum ToolKind {
129    Read,
130    Write,
131    Command,
132    Custom,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub enum ToolParallelism {
137    /// The tool can run concurrently with other shared tool calls.
138    Shared,
139    /// The tool needs exclusive execution within a model-emitted tool batch.
140    Exclusive,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct ToolInvocation {
145    pub id: String,
146    pub tool_name: String,
147    pub input: Value,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct ToolResult {
152    pub invocation_id: String,
153    pub ok: bool,
154    pub output: Value,
155}
156
157/// Internal key used by tools (e.g. `view_image`) to pass multimodal content
158/// to the turn loop without putting base64 into the text observation.
159pub const NAVI_CONTENT_PARTS_KEY: &str = "_navi_content_parts";
160
161/// Extract and remove multimodal content parts from a tool result output.
162///
163/// Tools may embed a `_navi_content_parts` array (serialized [`ContentPart`]s)
164/// in their JSON output. The turn loop calls this before building observations
165/// and `ToolCompleted` events so large base64 payloads never enter the transcript.
166pub fn take_tool_content_parts(result: &mut ToolResult) -> Vec<crate::model::ContentPart> {
167    let Some(obj) = result.output.as_object_mut() else {
168        return Vec::new();
169    };
170    let Some(raw) = obj.remove(NAVI_CONTENT_PARTS_KEY) else {
171        return Vec::new();
172    };
173    match serde_json::from_value::<Vec<crate::model::ContentPart>>(raw) {
174        Ok(parts) => parts,
175        Err(err) => {
176            tracing::warn!(error = %err, "failed to deserialize tool content_parts");
177            Vec::new()
178        }
179    }
180}
181
182pub struct ToolExecutor {
183    tools: HashMap<String, Arc<dyn Tool>>,
184    validators: HashMap<String, Arc<jsonschema::Validator>>,
185    invalid_schemas: HashMap<String, String>,
186    policy: SecurityPolicy,
187    security: Arc<dyn ToolSecurityPolicy>,
188    harness_profile: String,
189    registry: ToolRegistry,
190    /// Optional session rewind store for file restore on rewind.
191    rewind_store: Option<Arc<std::sync::Mutex<crate::rewind::RewindStore>>>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ToolCallInvalid {
196    UnknownTool {
197        tool_name: String,
198        available_tools: Vec<String>,
199    },
200    InvalidSchema {
201        tool_name: String,
202        message: String,
203    },
204    MalformedArguments {
205        tool_name: String,
206        raw_arguments_preview: String,
207        example: Value,
208    },
209    InvalidArguments {
210        tool_name: String,
211        problems: Vec<String>,
212        example: Value,
213    },
214}
215
216const EXCLUSIVE_BATCH_TOOL_NAMES: &[&str] = &[
217    "plan", "question",
218    // Nested model turns: serialize so parallel spawn storms cannot hang the
219    // parent batch or thrash provider quotas.
220    "subagent", "workflow",
221    // repo_explore is BM25+symbols (shared-safe); not exclusive.
222];
223
224impl ToolExecutor {
225    pub fn new(policy: SecurityPolicy) -> Self {
226        Self::with_security_policy(policy, Arc::new(DefaultToolSecurityPolicy))
227    }
228
229    pub fn empty(policy: SecurityPolicy) -> Self {
230        Self::empty_with_security_policy(policy, Arc::new(DefaultToolSecurityPolicy))
231    }
232
233    pub fn empty_with_security_policy(
234        policy: SecurityPolicy,
235        security: Arc<dyn ToolSecurityPolicy>,
236    ) -> Self {
237        Self {
238            tools: HashMap::new(),
239            validators: HashMap::new(),
240            invalid_schemas: HashMap::new(),
241            policy,
242            security,
243            harness_profile: "medium".to_string(),
244            registry: ToolRegistry::new(),
245            rewind_store: None,
246        }
247    }
248
249    /// Attach a session rewind store so successful writes track dirty paths.
250    pub fn set_rewind_store(
251        &mut self,
252        store: Option<Arc<std::sync::Mutex<crate::rewind::RewindStore>>>,
253    ) {
254        self.rewind_store = store;
255    }
256
257    /// Shared handle for write tools / runtime rebind.
258    pub fn rewind_store(&self) -> Option<Arc<std::sync::Mutex<crate::rewind::RewindStore>>> {
259        self.rewind_store.clone()
260    }
261
262    pub fn with_security_policy(
263        policy: SecurityPolicy,
264        security: Arc<dyn ToolSecurityPolicy>,
265    ) -> Self {
266        let mut executor = Self::empty_with_security_policy(policy, security);
267        executor.register_builtin_tools();
268        executor
269    }
270
271    pub fn registry(&self) -> &ToolRegistry {
272        &self.registry
273    }
274
275    pub fn registry_mut(&mut self) -> &mut ToolRegistry {
276        &mut self.registry
277    }
278
279    /// Searches tools by keyword across name, description, tags, and capabilities.
280    pub fn search_tools(&self, query: &str, max_results: usize) -> Vec<ToolDefinition> {
281        self.registry.search(query, max_results)
282    }
283
284    pub fn set_harness_profile(&mut self, profile: String) {
285        self.harness_profile = profile;
286        self.register(RuntimeInfoTool::new(
287            self.policy.clone(),
288            self.harness_profile.clone(),
289        ));
290    }
291
292    /// Replaces the security policy used for subsequent tool validations.
293    pub fn set_security_policy(&mut self, policy: SecurityPolicy) {
294        self.policy = policy;
295        self.register(RuntimeInfoTool::new(
296            self.policy.clone(),
297            self.harness_profile.clone(),
298        ));
299    }
300
301    /// Returns a reference to the active security policy.
302    pub fn security_policy(&self) -> &SecurityPolicy {
303        &self.policy
304    }
305
306    pub fn register_skill_loader(
307        &mut self,
308        project_dir: std::path::PathBuf,
309        data_dir: std::path::PathBuf,
310        config: std::sync::Arc<std::sync::RwLock<crate::config::NaviConfig>>,
311    ) {
312        // load_skill + skill store management tools (create-skill workflow).
313        let loader = crate::tool::builtin::SkillTool::new(
314            project_dir.clone(),
315            data_dir.clone(),
316            config.clone(),
317        );
318        self.register_tool(std::sync::Arc::new(loader));
319        self.register_tool(std::sync::Arc::new(
320            crate::tool::builtin::SkillListTool::new(
321                project_dir.clone(),
322                data_dir.clone(),
323                config.clone(),
324            ),
325        ));
326        self.register_tool(std::sync::Arc::new(
327            crate::tool::builtin::SkillGetTool::new(project_dir.clone(), data_dir.clone(), config),
328        ));
329        self.register_tool(std::sync::Arc::new(
330            crate::tool::builtin::SkillSaveTool::new(project_dir.clone(), data_dir.clone()),
331        ));
332        self.register_tool(std::sync::Arc::new(
333            crate::tool::builtin::SkillDeleteTool::new(project_dir, data_dir),
334        ));
335    }
336
337    pub(crate) fn new_code_exec_host(policy: SecurityPolicy) -> Self {
338        let pr = policy.project_root().to_path_buf();
339        let mut executor = Self {
340            tools: HashMap::new(),
341            validators: HashMap::new(),
342            invalid_schemas: HashMap::new(),
343            policy: policy.clone(),
344            security: Arc::new(DefaultToolSecurityPolicy),
345            harness_profile: "medium".to_string(),
346            registry: ToolRegistry::new(),
347            rewind_store: None,
348        };
349        executor.register(ReadTool::new(pr.clone()));
350        executor.register(ReadTool::alias(pr.clone(), "read"));
351        executor.register(SearchTool::new(pr.clone()));
352        executor.register(SearchTool::grep(pr.clone()));
353        executor.register(SearchTool::fs_browser(pr.clone()));
354        executor.register(WriteTool::apply_patch(pr.clone()));
355        executor.register(BashTool::new(pr.clone()));
356        executor.register(RepoIntelligenceTool::new(
357            policy.clone(),
358            RepoIntelligenceAction::AstSearch,
359        ));
360        executor.register(RepoIntelligenceTool::new(
361            policy,
362            RepoIntelligenceAction::TestDiscovery,
363        ));
364        executor
365    }
366
367    pub fn definitions(&self) -> Vec<ToolDefinition> {
368        // Use registry exposure info to filter, but get definitions from live tools.
369        // Merge in the enriched metadata from the registry so that schema
370        // simplification is applied and MCP/plugin tools remain current while
371        // respecting exposure levels.
372        let visible_names: std::collections::HashSet<String> =
373            self.registry.visible_tool_names().into_iter().collect();
374
375        let mut result: Vec<ToolDefinition> = self
376            .tools
377            .values()
378            .filter(|tool| {
379                let def = tool.definition();
380                visible_names.contains(&def.name)
381            })
382            .map(|tool| {
383                let mut def = model_friendly_definition(tool.definition());
384                // Merge enriched metadata from the registry
385                if let Some(registered) = self.registry.get(&def.name) {
386                    def.metadata = registered.definition.metadata.clone();
387                }
388                def
389            })
390            .collect();
391        result.sort_by(|a, b| a.name.cmp(&b.name));
392        result
393    }
394
395    pub fn all_definitions(&self) -> Vec<ToolDefinition> {
396        let mut result = self
397            .tools
398            .values()
399            .map(|tool| model_friendly_definition(self.enriched_definition(tool.as_ref())))
400            .collect::<Vec<_>>();
401        result.sort_by(|a, b| a.name.cmp(&b.name));
402        result
403    }
404
405    pub fn definition(&self, name: &str) -> Option<ToolDefinition> {
406        self.tools
407            .get(name)
408            .map(|tool| self.enriched_definition(tool.as_ref()))
409    }
410
411    pub fn parallelism_for(&self, tool_name: &str) -> ToolParallelism {
412        if EXCLUSIVE_BATCH_TOOL_NAMES.contains(&tool_name) {
413            return ToolParallelism::Exclusive;
414        }
415
416        let Some(definition) = self.definition(tool_name) else {
417            return ToolParallelism::Shared;
418        };
419
420        if definition.metadata.is_read_only && definition.metadata.is_concurrency_safe {
421            ToolParallelism::Shared
422        } else {
423            ToolParallelism::Exclusive
424        }
425    }
426
427    pub fn register_tool(&mut self, tool: Arc<dyn Tool>) -> Option<Arc<dyn Tool>> {
428        let mut def = tool.definition();
429        let name = def.name.clone();
430
431        // Inject builtin metadata (enriches tool definitions without changing each tool struct)
432        if def.metadata.is_default() {
433            let builtin = builtin_metadata(&name, def.kind);
434            def.metadata = builtin;
435        }
436
437        // Register in the tool registry for search/discovery
438        self.registry.register(def.clone());
439
440        match jsonschema::validator_for(&def.input_schema) {
441            Ok(v) => {
442                self.validators.insert(name.clone(), Arc::new(v));
443                self.invalid_schemas.remove(&name);
444            }
445            Err(e) => {
446                self.validators.remove(&name);
447                self.invalid_schemas.insert(name.clone(), e.to_string());
448                tracing::warn!(tool = %name, error = %e, "invalid schema");
449            }
450        }
451        self.tools.insert(name, tool)
452    }
453
454    pub fn validate_arguments(
455        &self,
456        inv: &ToolInvocation,
457    ) -> std::result::Result<(), ToolCallInvalid> {
458        let Some(_) = self.definition(&inv.tool_name) else {
459            return Err(ToolCallInvalid::UnknownTool {
460                tool_name: inv.tool_name.clone(),
461                available_tools: self.tool_names(),
462            });
463        };
464        if let Some(e) = self.invalid_schemas.get(&inv.tool_name) {
465            return Err(ToolCallInvalid::InvalidSchema {
466                tool_name: inv.tool_name.clone(),
467                message: e.clone(),
468            });
469        }
470        if let Some(raw) = inv.input.get("raw_arguments").and_then(Value::as_str) {
471            return Err(ToolCallInvalid::MalformedArguments {
472                tool_name: inv.tool_name.clone(),
473                raw_arguments_preview: raw.chars().take(200).collect(),
474                example: self
475                    .definition(&inv.tool_name)
476                    .map(|d| example_from_schema(&d.input_schema))
477                    .unwrap_or(json!({})),
478            });
479        }
480        let Some(v) = self.validators.get(&inv.tool_name) else {
481            return Err(ToolCallInvalid::InvalidSchema {
482                tool_name: inv.tool_name.clone(),
483                message: "missing validator".into(),
484            });
485        };
486        let errors: Vec<String> = v
487            .iter_errors(&inv.input)
488            .take(4)
489            .map(|e| {
490                let p = e.instance_path().to_string();
491                if p.is_empty() {
492                    e.to_string()
493                } else {
494                    format!("{e} at {p}")
495                }
496            })
497            .collect();
498        if !errors.is_empty() {
499            return Err(ToolCallInvalid::InvalidArguments {
500                tool_name: inv.tool_name.clone(),
501                problems: errors,
502                example: self
503                    .definition(&inv.tool_name)
504                    .map(|d| example_from_schema(&d.input_schema))
505                    .unwrap_or(json!({})),
506            });
507        }
508        Ok(())
509    }
510
511    pub fn tool_names(&self) -> Vec<String> {
512        let mut n: Vec<String> = self.tools.keys().cloned().collect();
513        n.sort();
514        n
515    }
516
517    /// Keep only tools whose names satisfy `pred`. Removes matching entries from
518    /// the live tool map, validators, invalid-schema cache, and registry.
519    pub fn retain_tools<F>(&mut self, mut pred: F)
520    where
521        F: FnMut(&str) -> bool,
522    {
523        self.tools.retain(|n, _| pred(n));
524        self.validators.retain(|n, _| pred(n));
525        self.invalid_schemas.retain(|n, _| pred(n));
526        self.registry.retain_tools(|n| pred(n));
527    }
528
529    /// Remove every registered tool (model schema becomes empty).
530    pub fn clear_tools(&mut self) {
531        self.tools.clear();
532        self.validators.clear();
533        self.invalid_schemas.clear();
534        self.registry.clear();
535    }
536
537    pub fn unregister_plugin_tools(&mut self) {
538        self.tools.retain(|n, _| !n.starts_with("plugin__"));
539        self.validators.retain(|n, _| !n.starts_with("plugin__"));
540        self.invalid_schemas
541            .retain(|n, _| !n.starts_with("plugin__"));
542        self.registry.unregister_prefix("plugin__");
543    }
544
545    pub fn invalid_tool_result(&self, inv: &ToolInvocation, err: ToolCallInvalid) -> ToolResult {
546        ToolResult {
547            invocation_id: inv.id.clone(),
548            ok: false,
549            output: tool_call_advice(err),
550        }
551    }
552
553    pub fn validate(&self, inv: &ToolInvocation) -> SecurityDecision {
554        if let Err(e) = self.validate_arguments(inv) {
555            return SecurityDecision::Deny(tool_call_advice_message(&e));
556        }
557        let Some(def) = self.definition(&inv.tool_name) else {
558            return SecurityDecision::Deny(format!("unknown `{}`", inv.tool_name));
559        };
560        self.security.validate_tool(&self.policy, &def, inv)
561    }
562
563    /// Clone this executor with a different security policy and only the named
564    /// tools (used by workflow workers for write_allow / path gates).
565    pub fn fork_with_policy_and_tools(
566        &self,
567        policy: SecurityPolicy,
568        allowed_tool_names: &[String],
569    ) -> Self {
570        let mut forked = Self::empty_with_security_policy(policy, self.security.clone());
571        forked.harness_profile = self.harness_profile.clone();
572        forked.rewind_store = self.rewind_store.clone();
573        for name in allowed_tool_names {
574            if let Some(tool) = self.tools.get(name) {
575                forked.register_tool(tool.clone());
576            }
577        }
578        // Always strip nested orchestration on worker forks.
579        forked.retain_tools(|n| n != "subagent" && n != "workflow");
580        forked
581    }
582
583    pub fn policy(&self) -> &SecurityPolicy {
584        &self.policy
585    }
586
587    pub async fn invoke(&self, invocation: ToolInvocation) -> ToolResult {
588        self.invoke_with_event_tx(invocation, None).await
589    }
590
591    pub async fn invoke_with_event_tx(
592        &self,
593        invocation: ToolInvocation,
594        event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
595    ) -> ToolResult {
596        self.invoke_with_context_inner(
597            invocation,
598            ToolInvocationContext {
599                event_tx,
600                ..Default::default()
601            },
602            false,
603        )
604        .await
605    }
606
607    pub async fn invoke_approved_with_event_tx(
608        &self,
609        invocation: ToolInvocation,
610        event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
611    ) -> ToolResult {
612        self.invoke_with_context_inner(
613            invocation,
614            ToolInvocationContext {
615                event_tx,
616                ..Default::default()
617            },
618            true,
619        )
620        .await
621    }
622
623    pub async fn invoke_with_full_context(
624        &self,
625        invocation: ToolInvocation,
626        context: ToolInvocationContext,
627        approval_granted: bool,
628    ) -> ToolResult {
629        self.invoke_with_context_inner(invocation, context, approval_granted)
630            .await
631    }
632
633    async fn invoke_with_context_inner(
634        &self,
635        invocation: ToolInvocation,
636        context: ToolInvocationContext,
637        approval_granted: bool,
638    ) -> ToolResult {
639        let event_tx = context.event_tx.clone();
640        let inv_id = invocation.id.clone();
641        let started = std::time::Instant::now();
642        let invocation = self.policy.normalize_invocation_paths(&invocation);
643        // Weak models sometimes emit a path or bare action as the tool name.
644        // Recover high-confidence mistakes before validation so analysis turns
645        // don't die on three consecutive unknown tools.
646        let invocation =
647            recover_misnamed_tool_invocation(&invocation, |name| self.definition(name).is_some())
648                .unwrap_or(invocation);
649        let tool_name = invocation.tool_name.clone();
650
651        // Determine tool kind and metadata before consuming the invocation.
652        let tool_def = self.definition(&invocation.tool_name);
653        let tool_kind = tool_def.as_ref().map(|d| d.kind);
654        let tool_verifier_hint = tool_def
655            .as_ref()
656            .and_then(|d| d.metadata.verifier.as_deref())
657            .map(|v| v.to_string());
658        let capability_event_tx = event_tx.clone();
659        emit_capability_events(
660            capability_event_tx.as_ref(),
661            &invocation,
662            tool_def.as_ref(),
663            CapabilityDecision::Requested,
664            "tool invocation requested",
665        );
666
667        if let Err(e) = self.validate_arguments(&invocation) {
668            return self.invalid_tool_result(&invocation, e);
669        }
670        match self.validate(&invocation) {
671            SecurityDecision::Allow => {}
672            SecurityDecision::NeedsApproval(risk) if approval_granted => {
673                tracing::debug!(tool = %invocation.tool_name, ?risk, "tool approval already granted");
674            }
675            SecurityDecision::NeedsApproval(risk) => {
676                let message = format!(
677                    "approval required for tool `{}`: {:?}",
678                    invocation.tool_name, risk
679                );
680                emit_capability_events(
681                    capability_event_tx.as_ref(),
682                    &invocation,
683                    tool_def.as_ref(),
684                    CapabilityDecision::Denied,
685                    &message,
686                );
687                return ToolResult {
688                    invocation_id: inv_id,
689                    ok: false,
690                    output: json!({
691                        "error_code": "approval_required",
692                        "error": message,
693                        "message": message,
694                        "recoverable": true,
695                        "hint": "Approve the tool request or switch permission mode (AcceptEdits/Auto/Yolo) if this should not require approval.",
696                    }),
697                };
698            }
699            SecurityDecision::Deny(r) => {
700                emit_capability_events(
701                    capability_event_tx.as_ref(),
702                    &invocation,
703                    tool_def.as_ref(),
704                    CapabilityDecision::Denied,
705                    &r,
706                );
707                return ToolResult {
708                    invocation_id: inv_id,
709                    ok: false,
710                    output: json!({
711                        "error_code": "security_denied",
712                        "error": r,
713                        "message": r,
714                        "recoverable": true,
715                        "hint": "Adjust the path/command or permission mode. Restricted mode keeps a project path jail; YOLO/AcceptEdits allow broader agency.",
716                    }),
717                };
718            }
719        }
720        let Some(tool) = self.tools.get(&invocation.tool_name).cloned() else {
721            let message = format!("unknown `{}`", invocation.tool_name);
722            return ToolResult {
723                invocation_id: inv_id,
724                ok: false,
725                output: json!({
726                    "error_code": "unknown_tool",
727                    "error": message,
728                    "message": message,
729                    "recoverable": true,
730                    "hint": "Use a registered tool name, or call tool_search to discover tools.",
731                }),
732            };
733        };
734        if invocation.tool_name == "tool_search" {
735            return self.invoke_tool_search(invocation);
736        }
737
738        let pre_execution_snapshot = if tool_kind == Some(crate::tool::ToolKind::Write) {
739            let paths = self.snapshot_paths_for_invocation(&invocation);
740            // First-touch paths: capture pre-write bytes into the current rewind point.
741            if let Some(store) = &self.rewind_store {
742                if !paths.is_empty() {
743                    if let Ok(mut store) = store.lock() {
744                        if let Err(e) = store.ensure_pre_write_capture(paths.iter().cloned()) {
745                            tracing::debug!(error = %e, "rewind: pre-write capture failed");
746                        }
747                    }
748                }
749            }
750            if paths.is_empty() {
751                None
752            } else {
753                Some(crate::sandbox::SandboxManager::create_snapshot(&paths))
754            }
755        } else {
756            None
757        };
758
759        // Snapshot the invocation input for post-execution effect analysis
760        // before it is consumed by invoke_with_context.
761        let inv_input = invocation.input.clone();
762
763        let mut result = match tool.invoke_with_context(invocation, context).await {
764            Ok(r) => truncate_tool_result(r),
765            Err(e) => ToolResult {
766                invocation_id: inv_id.clone(),
767                ok: false,
768                output: json!({"error": format!("{e:#}")}),
769            },
770        };
771
772        // Track successful write paths for session rewind (dirty set + created).
773        if result.ok && tool_kind == Some(crate::tool::ToolKind::Write) {
774            if let Some(store) = &self.rewind_store {
775                let paths = crate::effect::extract_paths(
776                    &result,
777                    &ToolInvocation {
778                        id: inv_id.clone(),
779                        tool_name: tool_name.clone(),
780                        input: inv_input.clone(),
781                    },
782                );
783                let abs_paths: Vec<std::path::PathBuf> = paths
784                    .into_iter()
785                    .map(|p| self.policy.resolve_project_path(&p))
786                    .collect();
787                if !abs_paths.is_empty() {
788                    if let Ok(mut store) = store.lock() {
789                        store.note_written_paths(abs_paths);
790                    }
791                }
792            }
793        }
794
795        // Post-execution effect check for successful write/command tools.
796        if result.ok {
797            let should_check = match tool_kind {
798                Some(crate::tool::ToolKind::Write) => true,
799                Some(crate::tool::ToolKind::Command) => true,
800                _ => false,
801            };
802
803            if should_check {
804                let paths = crate::effect::extract_paths(
805                    &result,
806                    &ToolInvocation {
807                        id: inv_id.clone(),
808                        tool_name: tool_name.clone(),
809                        input: inv_input,
810                    },
811                );
812
813                if !paths.is_empty() {
814                    let command = None;
815                    let decision = self
816                        .policy
817                        .post_execution_effect_check(&tool_name, &paths, command);
818
819                    match decision {
820                        PostDecision::Allow => {
821                            // No action needed; result stands.
822                        }
823                        PostDecision::Ask(reason) => {
824                            tracing::warn!(
825                                tool = %tool_name,
826                                reason = %reason,
827                                "post-execution effect check: ask user"
828                            );
829                            if let Value::Object(ref mut map) = result.output {
830                                map.insert(
831                                    "effect_warning".to_string(),
832                                    json!({
833                                        "decision": "ask",
834                                        "message": reason,
835                                    }),
836                                );
837                            }
838                        }
839                        PostDecision::Deny(reason) => {
840                            tracing::warn!(
841                                tool = %tool_name,
842                                reason = %reason,
843                                "post-execution effect check: denied"
844                            );
845                            let rollback = pre_execution_snapshot
846                                .as_ref()
847                                .map(crate::sandbox::SandboxManager::rollback);
848                            let (rolled_back, rollback_error) = rollback_outcome(rollback);
849                            emit_capability_events(
850                                capability_event_tx.as_ref(),
851                                &ToolInvocation {
852                                    id: inv_id.clone(),
853                                    tool_name: tool_name.clone(),
854                                    input: json!({}),
855                                },
856                                tool_def.as_ref(),
857                                CapabilityDecision::Violated,
858                                &reason,
859                            );
860                            return ToolResult {
861                                invocation_id: inv_id,
862                                ok: false,
863                                output: json!({
864                                    "error": reason,
865                                    "error_code": "effect_denied",
866                                    "rolled_back": rolled_back,
867                                    "rollback_error": rollback_error,
868                                }),
869                            };
870                        }
871                        PostDecision::Rollback(reason) => {
872                            tracing::warn!(
873                                tool = %tool_name,
874                                reason = %reason,
875                                "post-execution effect check: rollback recommended"
876                            );
877                            let rollback = pre_execution_snapshot
878                                .as_ref()
879                                .map(crate::sandbox::SandboxManager::rollback);
880                            let (rolled_back, rollback_error) = rollback_outcome(rollback);
881                            emit_capability_events(
882                                capability_event_tx.as_ref(),
883                                &ToolInvocation {
884                                    id: inv_id.clone(),
885                                    tool_name: tool_name.clone(),
886                                    input: json!({}),
887                                },
888                                tool_def.as_ref(),
889                                CapabilityDecision::Violated,
890                                &reason,
891                            );
892                            return ToolResult {
893                                invocation_id: inv_id,
894                                ok: false,
895                                output: json!({
896                                    "error": reason,
897                                    "error_code": "effect_rollback",
898                                    "rolled_back": rolled_back,
899                                    "rollback_error": rollback_error,
900                                }),
901                            };
902                        }
903                    }
904                }
905            }
906        }
907
908        emit_capability_events(
909            capability_event_tx.as_ref(),
910            &ToolInvocation {
911                id: inv_id.clone(),
912                tool_name: tool_name.clone(),
913                input: json!({}),
914            },
915            tool_def.as_ref(),
916            if result.ok {
917                CapabilityDecision::Consumed
918            } else {
919                CapabilityDecision::Violated
920            },
921            if result.ok {
922                "tool invocation completed"
923            } else {
924                "tool invocation failed"
925            },
926        );
927
928        // Post-execution verifier hint injection: if the tool metadata advertises
929        // a verifier hint, surface it in the result so the harness (and model)
930        // can suggest or run verification after mutation tools.
931        if result.ok && tool_kind == Some(crate::tool::ToolKind::Write) {
932            if let Some(verifier_cmd) = tool_verifier_hint {
933                if let Value::Object(ref mut map) = result.output {
934                    map.insert(
935                        "verifier_hint".to_string(),
936                        json!({
937                            "suggested": true,
938                            "command": verifier_cmd,
939                            "message": format!(
940                                "After writing, verify with: verifier(action='run', verifier='command', command='{}')",
941                                verifier_cmd
942                            ),
943                        }),
944                    );
945                }
946            }
947        }
948
949        tracing::info!(tool = %tool_name, ok = result.ok, dur_ms = started.elapsed().as_millis() as u64, "invoke finished");
950        result
951    }
952
953    fn invoke_tool_search(&self, invocation: ToolInvocation) -> ToolResult {
954        let query = invocation
955            .input
956            .get("query")
957            .and_then(Value::as_str)
958            .unwrap_or_default()
959            .to_string();
960        let max_results = invocation
961            .input
962            .get("max_results")
963            .and_then(Value::as_u64)
964            .unwrap_or(10)
965            .min(50) as usize;
966        let results = self.registry.search(&query, max_results);
967        let results = results
968            .into_iter()
969            .map(model_friendly_definition)
970            .map(|def| {
971                json!({
972                    "name": def.name,
973                    "description": def.description,
974                    "kind": def.kind,
975                    "metadata": def.metadata,
976                    "input_schema": def.input_schema,
977                })
978            })
979            .collect::<Vec<_>>();
980
981        ToolResult {
982            invocation_id: invocation.id,
983            ok: true,
984            output: json!({
985                "query": query,
986                "results": results,
987                "total": results.len(),
988                "hint": if results.is_empty() {
989                    "No tools found. Try broader terms like: code, browser, package, memory, subagent, sandbox, goal."
990                } else {
991                    "These tools may be deferred (not always in the schema). Call a returned tool by its `name` with arguments matching `input_schema`."
992                },
993                "power_catalog": [
994                    "code / code_edit / ast_search / symbol_*: symbols and structured code nav",
995                    "repo_explore: BM25 semantic search",
996                    "package_manager: dependency install/add/update",
997                    "browser: headless UI testing",
998                    "subagent: nested agent",
999                    "apply_patch / sandbox / set_goal / history_ops: advanced workflows",
1000                ],
1001            }),
1002        }
1003    }
1004
1005    fn snapshot_paths_for_invocation(
1006        &self,
1007        invocation: &ToolInvocation,
1008    ) -> Vec<std::path::PathBuf> {
1009        let mut paths = Vec::new();
1010        for key in ["path", "file", "file_path"] {
1011            if let Some(path) = invocation.input.get(key).and_then(Value::as_str) {
1012                push_unique_snapshot_path(
1013                    &mut paths,
1014                    self.policy.resolve_project_path(Path::new(path)),
1015                );
1016            }
1017        }
1018
1019        if let Some(patch) = invocation.input.get("patch").and_then(Value::as_str) {
1020            for path in crate::security::extract_apply_patch_paths(patch) {
1021                push_unique_snapshot_path(
1022                    &mut paths,
1023                    self.policy.resolve_project_path(Path::new(&path)),
1024                );
1025            }
1026        }
1027        if let Some(patches) = invocation.input.get("patches").and_then(Value::as_array) {
1028            for patch in patches.iter().filter_map(Value::as_str) {
1029                for path in crate::security::extract_apply_patch_paths(patch) {
1030                    push_unique_snapshot_path(
1031                        &mut paths,
1032                        self.policy.resolve_project_path(Path::new(&path)),
1033                    );
1034                }
1035            }
1036        }
1037
1038        paths
1039    }
1040
1041    /// Lists all active background bash commands.
1042    pub async fn list_background_commands(&self) -> Vec<background::BackgroundCommandSnapshot> {
1043        let r = self
1044            .invoke(ToolInvocation {
1045                id: "bg-list".into(),
1046                tool_name: "bash".into(),
1047                input: json!({"action": "list"}),
1048            })
1049            .await;
1050        r.output
1051            .get("tasks")
1052            .and_then(|v| v.as_array())
1053            .map(|a| {
1054                a.iter()
1055                    .filter_map(background::BackgroundCommandSnapshot::from_json)
1056                    .collect()
1057            })
1058            .unwrap_or_default()
1059    }
1060
1061    /// Polls a specific background bash command.
1062    pub async fn poll_background_command(
1063        &self,
1064        task_id: &str,
1065    ) -> Option<background::BackgroundCommandSnapshot> {
1066        let r = self
1067            .invoke(ToolInvocation {
1068                id: "bg-poll".into(),
1069                tool_name: "bash".into(),
1070                input: json!({"task_id": task_id}),
1071            })
1072            .await;
1073        if r.ok {
1074            background::BackgroundCommandSnapshot::from_json(&r.output)
1075        } else {
1076            None
1077        }
1078    }
1079
1080    /// Cancels a specific background bash command.
1081    pub async fn cancel_background_command(
1082        &self,
1083        task_id: &str,
1084    ) -> Option<background::BackgroundCommandSnapshot> {
1085        let r = self
1086            .invoke(ToolInvocation {
1087                id: "bg-cancel".into(),
1088                tool_name: "bash".into(),
1089                input: json!({"task_id": task_id, "action": "cancel"}),
1090            })
1091            .await;
1092        if r.ok {
1093            background::BackgroundCommandSnapshot::from_json(&r.output)
1094        } else {
1095            None
1096        }
1097    }
1098
1099    fn register(&mut self, tool: impl Tool + 'static) {
1100        self.register_tool(Arc::new(tool));
1101    }
1102
1103    fn enriched_definition(&self, tool: &dyn Tool) -> ToolDefinition {
1104        let mut def = tool.definition();
1105        if let Some(registered) = self.registry.get(&def.name) {
1106            def.metadata = registered.definition.metadata.clone();
1107        }
1108        def
1109    }
1110
1111    fn register_builtin_tools(&mut self) {
1112        let pr = self.policy.project_root().to_path_buf();
1113        self.register(ReadTool::new(pr.clone())); // read_file (Direct)
1114        self.register(ReadTool::alias(pr.clone(), "read")); // Hidden alias
1115        self.register(SearchTool::new(pr.clone()));
1116        self.register(SearchTool::grep(pr.clone()));
1117        self.register(SearchTool::fs_browser(pr.clone()));
1118        self.register(SearchTool::list_dir(pr.clone()));
1119        self.register(SearchTool::glob(pr.clone()));
1120        self.register(EditTool::new(pr.clone()));
1121        self.register(MultiEditTool::new(pr.clone()));
1122        self.register(WriteTool::new(pr.clone()));
1123        self.register(WriteTool::write_file(pr.clone()));
1124        self.register(WriteTool::apply_patch(pr.clone()));
1125        self.register(BashTool::new(pr.clone()));
1126        self.register(QuestionTool);
1127        self.register(PlanTool::new(self.policy.clone()));
1128        self.register(PackageManagerTool::new(pr.clone()));
1129        self.register(RuntimeInfoTool::new(
1130            self.policy.clone(),
1131            self.harness_profile.clone(),
1132        ));
1133        #[cfg(feature = "code-vfs")]
1134        {
1135            self.register(CodeReadTool::new(self.policy.clone()));
1136            self.register(CodeEditTool::new(self.policy.clone()));
1137        }
1138        self.register(CodeExecTool::new(self.policy.clone()));
1139        self.register(RepoIntelligenceTool::new(
1140            self.policy.clone(),
1141            RepoIntelligenceAction::AstSearch,
1142        ));
1143        self.register(RepoIntelligenceTool::new(
1144            self.policy.clone(),
1145            RepoIntelligenceAction::SymbolGoto,
1146        ));
1147        self.register(RepoIntelligenceTool::new(
1148            self.policy.clone(),
1149            RepoIntelligenceAction::SymbolReferences,
1150        ));
1151        self.register(RepoIntelligenceTool::new(
1152            self.policy.clone(),
1153            RepoIntelligenceAction::DependencyGraph,
1154        ));
1155        self.register(RepoIntelligenceTool::new(
1156            self.policy.clone(),
1157            RepoIntelligenceAction::TestDiscovery,
1158        ));
1159        self.register(RepoIntelligenceTool::new(
1160            self.policy.clone(),
1161            RepoIntelligenceAction::OwnershipChurn,
1162        ));
1163        self.register(InitSessionTool::new(self.policy.clone()));
1164        self.register(MarkFeatureDoneTool::new(self.policy.clone()));
1165        self.register(AppendNoteTool::new(pr.clone()));
1166        self.register(MemoryTool::new(pr.clone()));
1167        self.register(HistoryOpsTool::new(pr.clone()));
1168        self.register(CurrentTimeTool::new());
1169        self.register(SleepTool::new());
1170        // Thread goals are model tools get_goal/create_goal/update_goal (registered
1171        // when goals are enabled). Hosts set goals via the SDK, not a deferred alias.
1172        self.register(ContextRemainingTool::new(pr.clone()));
1173        self.register(RequestUserInputTool::new());
1174        self.register(SandboxTool::new(pr.clone()));
1175        let data_dir = self.policy.data_dir().to_path_buf();
1176        self.register(ViewImageTool::new(pr.clone(), data_dir.clone()));
1177        self.register(ViewImageTool::inspect_image(pr.clone(), data_dir));
1178        #[cfg(feature = "browser")]
1179        self.register(BrowserTool::new(pr.clone()));
1180        self.register(NewContextWindowTool::new());
1181        self.register(ToolSearchTool::new(Arc::new(self.registry.clone())));
1182        // Workflow uses mock backend by default; runtime/SDK replace with a
1183        // real AgentBackend when wiring live subagent turns.
1184        self.register(WorkflowTool::new(
1185            self.policy.clone(),
1186            crate::config::WorkflowConfig::default(),
1187        ));
1188    }
1189}
1190
1191fn tool_call_advice(err: ToolCallInvalid) -> Value {
1192    // Always include both `error` (TUI header) and `message` (structured contract).
1193    match err {
1194        ToolCallInvalid::UnknownTool {
1195            tool_name,
1196            available_tools,
1197        } => {
1198            let message = "Requested tool is not registered. Use one of the available tool names."
1199                .to_string();
1200            json!({
1201                "error_code": "unknown_tool",
1202                "error_kind": "unknown_tool",
1203                "tool": tool_name,
1204                "error": message,
1205                "message": message,
1206                "hint": "Call tool_search or use a name from available_tools.",
1207                "recoverable": true,
1208                "suggestions": suggest_tool_replacements(&tool_name, &available_tools),
1209                "available_tools": available_tools.into_iter().take(20).collect::<Vec<_>>(),
1210            })
1211        }
1212        ToolCallInvalid::InvalidSchema { tool_name, message } => {
1213            let message = format!("Tool schema is invalid: {message}");
1214            json!({
1215                "error_code": "invalid_schema",
1216                "error_kind": "invalid_schema",
1217                "tool": tool_name,
1218                "error": message,
1219                "message": message,
1220                "recoverable": false,
1221            })
1222        }
1223        ToolCallInvalid::MalformedArguments {
1224            tool_name,
1225            raw_arguments_preview,
1226            example,
1227        } => {
1228            let message = "Tool arguments were not valid JSON. Emit one complete JSON object matching the schema before calling the tool again.".to_string();
1229            json!({
1230                "error_code": "invalid_arguments",
1231                "error_kind": "malformed_arguments",
1232                "tool": tool_name,
1233                "error": message,
1234                "message": message,
1235                "hint": "Emit a single complete JSON object; do not wrap arguments in markdown fences.",
1236                "recoverable": true,
1237                "raw_arguments_preview": raw_arguments_preview,
1238                "example": example,
1239            })
1240        }
1241        ToolCallInvalid::InvalidArguments {
1242            tool_name,
1243            problems,
1244            example,
1245        } => {
1246            let message = "Tool arguments do not match the JSON schema. Fix the arguments and call the tool again.".to_string();
1247            json!({
1248                "error_code": "invalid_arguments",
1249                "error_kind": "invalid_arguments",
1250                "tool": tool_name,
1251                "error": message,
1252                "message": message,
1253                "hint": "Compare your arguments to the tool schema and the example.",
1254                "recoverable": true,
1255                "problems": problems,
1256                "example": example,
1257            })
1258        }
1259    }
1260}
1261
1262fn tool_call_advice_message(err: &ToolCallInvalid) -> String {
1263    match err {
1264        ToolCallInvalid::UnknownTool { tool_name, .. } => format!("unknown tool `{tool_name}`"),
1265        ToolCallInvalid::InvalidSchema { tool_name, message } => {
1266            format!("invalid schema for `{tool_name}`: {message}")
1267        }
1268        ToolCallInvalid::MalformedArguments { tool_name, .. } => {
1269            format!("malformed args for `{tool_name}`")
1270        }
1271        ToolCallInvalid::InvalidArguments {
1272            tool_name,
1273            problems,
1274            ..
1275        } => format!("invalid args for `{tool_name}`: {}", problems.join("; ")),
1276    }
1277}
1278
1279fn model_friendly_definition(mut definition: ToolDefinition) -> ToolDefinition {
1280    definition.input_schema = simplify_schema_for_model(&definition.input_schema);
1281    definition
1282}
1283
1284fn simplify_schema_for_model(schema: &Value) -> Value {
1285    match schema {
1286        Value::Object(object) => simplify_schema_object_for_model(object),
1287        Value::Array(values) => {
1288            Value::Array(values.iter().map(simplify_schema_for_model).collect())
1289        }
1290        value => value.clone(),
1291    }
1292}
1293
1294fn simplify_schema_object_for_model(object: &Map<String, Value>) -> Value {
1295    let mut simplified = Map::new();
1296
1297    for keyword in ["oneOf", "anyOf", "allOf"] {
1298        let Some(branches) = object.get(keyword).and_then(Value::as_array) else {
1299            continue;
1300        };
1301        if let Some(Value::Object(branch)) = branches.first().map(simplify_schema_for_model) {
1302            simplified.extend(branch);
1303        }
1304        break;
1305    }
1306
1307    for (key, value) in object {
1308        if matches!(key.as_str(), "oneOf" | "anyOf" | "allOf" | "const") {
1309            continue;
1310        }
1311        simplified.insert(key.clone(), simplify_schema_for_model(value));
1312    }
1313
1314    Value::Object(simplified)
1315}
1316
1317fn suggest_tool_replacements(tool_name: &str, available_tools: &[String]) -> Vec<String> {
1318    let lower = tool_name.trim().to_ascii_lowercase();
1319    let candidates: &[&str] = match lower.as_str() {
1320        "list_files" | "ls" | "listdir" | "dir" | "list" | "list_dir" | "find" | "find_files"
1321        | "grep" | "glob" | "fs_browser" => &["search"],
1322        "cat" | "type" | "open" | "view_file" | "read" | "view" => &["read_file"],
1323        "patch" | "str_replace" | "search_replace" | "searchreplace" | "multiedit"
1324        | "multi_edit" | "multi-edit" | "batched_edit" | "apply_patch" => &["edit", "write_file"],
1325        "request_user_input" | "ask_user" | "ask" => &["question"],
1326        "shell" | "run" | "terminal" | "exec" | "sh" | "cmd" | "process" => &["bash"],
1327        "rg" | "ripgrep" | "search_code" => &["search", "code"],
1328        "symbols" | "symbol" | "symbols_overview" | "find_symbol" | "find_references"
1329        | "code_diagnostics" => &["code", "ast_search", "symbol_goto"],
1330        "replace_symbol_body"
1331        | "insert_before_symbol"
1332        | "insert_after_symbol"
1333        | "rename_symbol" => &["code_edit"],
1334        _ if looks_like_filesystem_path(tool_name) => &["read_file", "fs_browser", "grep", "bash"],
1335        _ => &[],
1336    };
1337    candidates
1338        .iter()
1339        .filter(|candidate| available_tools.iter().any(|tool| tool == **candidate))
1340        .map(|candidate| (*candidate).to_string())
1341        .collect()
1342}
1343
1344/// Heuristic: weak models often put a path (or `.`) in the tool *name* field
1345/// instead of using `read_file` / `fs_browser`. Rewrite only high-confidence cases.
1346fn recover_misnamed_tool_invocation(
1347    inv: &ToolInvocation,
1348    has_tool: impl Fn(&str) -> bool,
1349) -> Option<ToolInvocation> {
1350    if has_tool(&inv.tool_name) {
1351        return None;
1352    }
1353
1354    let name = inv.tool_name.trim();
1355    if name.is_empty() {
1356        return None;
1357    }
1358
1359    let input = inv.input.as_object();
1360    let action = input
1361        .and_then(|obj| obj.get("action"))
1362        .and_then(Value::as_str)
1363        .map(|s| s.to_ascii_lowercase());
1364    let has_path_field = input
1365        .and_then(|obj| obj.get("path"))
1366        .and_then(Value::as_str)
1367        .is_some_and(|p| !p.is_empty());
1368    let path_from_input = input
1369        .and_then(|obj| obj.get("path"))
1370        .and_then(Value::as_str)
1371        .map(str::to_string);
1372    let path_like_name = looks_like_filesystem_path(name);
1373
1374    // `{ "name": ".", "arguments": { "action": "list", "path": "." } }` → fs_browser
1375    // also covers list/tree/find/stat with a path-like tool name.
1376    if matches!(
1377        action.as_deref(),
1378        Some("list" | "tree" | "find" | "stat" | "read" | "search")
1379    ) && has_tool("fs_browser")
1380    {
1381        let mut recovered = inv.clone();
1382        recovered.tool_name = "fs_browser".to_string();
1383        if !has_path_field && path_like_name {
1384            if let Some(obj) = recovered.input.as_object_mut() {
1385                obj.insert("path".to_string(), Value::String(name.to_string()));
1386            }
1387        }
1388        tracing::info!(
1389            from = %inv.tool_name,
1390            to = "fs_browser",
1391            "recovered misnamed tool invocation"
1392        );
1393        return Some(recovered);
1394    }
1395
1396    // `{ "name": "IDEA.md", "arguments": { "path": "IDEA.md" } }` → read_file
1397    // `{ "name": "src/main.rs", "arguments": {} }` → read_file
1398    if path_like_name && has_tool("read_file") {
1399        let keys: Vec<&str> = input
1400            .map(|obj| obj.keys().map(String::as_str).collect())
1401            .unwrap_or_default();
1402        let readish = keys.is_empty()
1403            || keys.iter().all(|k| {
1404                matches!(
1405                    *k,
1406                    "path"
1407                        | "offset"
1408                        | "limit"
1409                        | "start_line"
1410                        | "end_line"
1411                        | "max_bytes"
1412                        | "encoding"
1413                )
1414            });
1415        // Don't rewrite obvious write/patch payloads.
1416        let writeish = keys
1417            .iter()
1418            .any(|k| matches!(*k, "content" | "patch" | "patches" | "edits" | "new_string"));
1419        if readish && !writeish {
1420            let mut recovered = inv.clone();
1421            recovered.tool_name = "read_file".to_string();
1422            if !has_path_field {
1423                let mut obj = serde_json::Map::new();
1424                obj.insert("path".to_string(), Value::String(name.to_string()));
1425                if let Some(input_obj) = input {
1426                    for (k, v) in input_obj {
1427                        if k != "path" {
1428                            obj.insert(k.clone(), v.clone());
1429                        }
1430                    }
1431                }
1432                recovered.input = Value::Object(obj);
1433            } else if path_from_input.as_deref() != Some(name)
1434                && path_from_input
1435                    .as_deref()
1436                    .is_some_and(|p| p == "." || p.is_empty())
1437            {
1438                // Prefer the path-like tool name when the path field is a placeholder.
1439                if let Some(obj) = recovered.input.as_object_mut() {
1440                    obj.insert("path".to_string(), Value::String(name.to_string()));
1441                }
1442            }
1443            tracing::info!(
1444                from = %inv.tool_name,
1445                to = "read_file",
1446                "recovered misnamed tool invocation"
1447            );
1448            return Some(recovered);
1449        }
1450    }
1451
1452    None
1453}
1454
1455fn looks_like_filesystem_path(name: &str) -> bool {
1456    let name = name.trim();
1457    if name.is_empty() {
1458        return false;
1459    }
1460    if name == "." || name == ".." {
1461        return true;
1462    }
1463    if name.contains('/') || name.contains('\\') {
1464        return true;
1465    }
1466    // Bare filenames with extensions: IDEA.md, main.rs, package.json
1467    if let Some((_, ext)) = name.rsplit_once('.') {
1468        let ext = ext.trim();
1469        if !ext.is_empty()
1470            && ext.len() <= 12
1471            && ext
1472                .chars()
1473                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-')
1474            && !name.starts_with('.')
1475            // Avoid treating dotted tool ids like `chrome.devtools` without path separators
1476            // only when they look like real extensions (mostly lowercase 1–8 chars).
1477            && (ext.len() <= 8)
1478        {
1479            return true;
1480        }
1481    }
1482    false
1483}
1484
1485fn push_unique_snapshot_path(paths: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
1486    if !paths.contains(&path) {
1487        paths.push(path);
1488    }
1489}
1490
1491fn rollback_outcome(rollback: Option<std::result::Result<(), String>>) -> (bool, Option<String>) {
1492    match rollback {
1493        Some(Ok(())) => (true, None),
1494        Some(Err(error)) => (false, Some(error)),
1495        None => (false, None),
1496    }
1497}
1498
1499fn emit_capability_events(
1500    event_tx: Option<&mpsc::UnboundedSender<AgentEvent>>,
1501    invocation: &ToolInvocation,
1502    definition: Option<&ToolDefinition>,
1503    decision: CapabilityDecision,
1504    justification: &str,
1505) {
1506    let Some(event_tx) = event_tx else {
1507        return;
1508    };
1509    let Some(definition) = definition else {
1510        return;
1511    };
1512    for capability in capabilities_from_tool_metadata(&definition.metadata.capabilities) {
1513        let _ = event_tx.send(AgentEvent::CapabilityRecorded(CapabilityLedgerEntry {
1514            capability,
1515            scope: CapabilityScope::SingleCall(invocation.id.clone()),
1516            decision: decision.clone(),
1517            at_ms: tool_unix_millis(),
1518            justification: format!("{}: {justification}", invocation.tool_name),
1519        }));
1520    }
1521}
1522
1523fn tool_unix_millis() -> u64 {
1524    std::time::SystemTime::now()
1525        .duration_since(std::time::UNIX_EPOCH)
1526        .map(|duration| duration.as_millis() as u64)
1527        .unwrap_or(0)
1528}
1529
1530pub fn example_from_schema(schema: &Value) -> Value {
1531    if let Some(ex) = schema
1532        .get("examples")
1533        .and_then(Value::as_array)
1534        .and_then(|a| a.first())
1535    {
1536        return ex.clone();
1537    }
1538    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
1539        return json!({});
1540    };
1541    let required: Vec<&str> = schema
1542        .get("required")
1543        .and_then(Value::as_array)
1544        .into_iter()
1545        .flatten()
1546        .filter_map(Value::as_str)
1547        .collect();
1548    let mut ex = serde_json::Map::new();
1549    for field in required {
1550        let v = properties
1551            .get(field)
1552            .and_then(|p| p.get("type"))
1553            .and_then(Value::as_str)
1554            .map(|k| match k {
1555                "integer" => json!(1),
1556                "number" => json!(1.0),
1557                "boolean" => json!(true),
1558                "array" => json!([]),
1559                "object" => json!({}),
1560                _ => json!("example"),
1561            })
1562            .unwrap_or(json!("example"));
1563        ex.insert(field.to_string(), v);
1564    }
1565    Value::Object(ex)
1566}