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