Skip to main content

clark_agent/
tool_identity.rs

1//! `ToolIdentityPolicy` — typed declaration each tool gives the runtime
2//! about how to recognize "two calls of this tool that are the same
3//! operation on the same target".
4//!
5//! ## Why this lives next to tool definitions
6//!
7//! The `SemanticLoopDetector` and any future plugin that needs to ask
8//! "is this call a repeat of the last one?" must agree with the tool's
9//! own dispatch contract. The historical pattern — a hand-curated
10//! `match tool_name { ... }` inside the detector — drifts every time a
11//! new action-dispatched tool is registered: the detector's allowlist
12//! and the tool's `#[serde(tag = "action")]` enum live in different
13//! files with no compile-time link. The `office` tool shipped without
14//! the matching allowlist update; every `office.pdf_fields` repeat
15//! collapsed to `(office, default)` instead of `(office, pdf_fields)`,
16//! so the recovery message went generic.
17//!
18//! `ToolIdentityPolicy` is the single source of truth for that
19//! identity contract. Each tool declares it on the `AgentTool` trait;
20//! the detector reads the declaration from the `ToolRegistry`. Adding
21//! a tool can no longer drift the detector because the detector reads
22//! the tool's own declaration.
23//!
24//! ## Three independent declarations
25//!
26//! - `operation_arg`: top-level arg whose value names the operation
27//!   for action-dispatched tools. Two distinct operations of the same
28//!   tool get distinct identities.
29//! - `target`: how to extract a "persistent target" string (file
30//!   path, URL, shell command prefix). Used both as a display token in
31//!   recovery messages and as the key for per-target error counters
32//!   that survive intervening successes.
33//! - `args_key_fn`: tool-specific override for the full identity
34//!   string used to recognize repeats. Defaults to
35//!   `target={tool_name}:{target}` when only `target` is declared,
36//!   and to a canonical-JSON hash of the normalized args when nothing
37//!   else applies.
38
39use serde_json::Value;
40
41/// Tool-specific target extractor. Returns the "persistent target" of
42/// a call (file path, URL, shell-command prefix) — the thing whose
43/// repeat would mean "the same work is being attempted again." `None`
44/// means the call has no persistent target (e.g. `office.pdf_fields`
45/// — the action itself is what repeats).
46pub type TargetFn = fn(&Value) -> Option<String>;
47
48/// Tool-specific full-identity extractor. Returns the opaque identity
49/// string the detector compares for equality, or `None` to fall back
50/// to the default `target={tool}:{target}` / canonical-JSON paths.
51pub type ArgsKeyFn = fn(&Value) -> Option<String>;
52
53/// How to extract the persistent target of a call.
54#[derive(Debug, Clone, Copy)]
55pub enum TargetExtractor {
56    /// The target is the trimmed string value at this top-level arg
57    /// (e.g. `"path"` for `file_*`, `"url"` for `browser_navigate`).
58    StringArg(&'static str),
59    /// Tool-specific extractor for composite targets (e.g. `shell`'s
60    /// `action:command-prefix`).
61    Custom(TargetFn),
62}
63
64/// Typed identity contract for one tool. All fields default to `None`
65/// — a tool that does not override `identity_policy` is treated as
66/// "single operation, no persistent target, opaque args" (matches the
67/// historical fall-through behavior for unknown tools).
68#[derive(Debug, Clone, Copy, Default)]
69pub struct ToolIdentityPolicy {
70    /// Top-level argument whose string value names the operation when
71    /// the tool dispatches on `action` / `mode` / similar. The
72    /// detector pairs this with `tool_name` so two distinct operations
73    /// of the same tool do not collide into one repeat signature.
74    pub operation_arg: Option<&'static str>,
75    /// How to extract the persistent target string. `None` means the
76    /// tool has no persistent target — every call is identified by
77    /// its full args.
78    pub target: Option<TargetExtractor>,
79    /// Tool-specific full-identity extractor. Overrides the default
80    /// `target={tool}:{target}` composition when set.
81    pub args_key_fn: Option<ArgsKeyFn>,
82}
83
84impl ToolIdentityPolicy {
85    pub const fn new() -> Self {
86        Self {
87            operation_arg: None,
88            target: None,
89            args_key_fn: None,
90        }
91    }
92
93    /// Declare the top-level argument that discriminates operations
94    /// for this tool (e.g. `"action"` for `office`, `shell`, `plan`).
95    pub const fn with_operation_arg(mut self, arg: &'static str) -> Self {
96        self.operation_arg = Some(arg);
97        self
98    }
99
100    /// Declare a top-level string argument as the persistent target
101    /// (e.g. `"path"` for `file_*`, `"url"` for `browser_navigate`).
102    pub const fn with_target_arg(mut self, arg: &'static str) -> Self {
103        self.target = Some(TargetExtractor::StringArg(arg));
104        self
105    }
106
107    /// Provide a tool-specific target extractor for composite targets
108    /// (e.g. `shell`'s `action:command-prefix`).
109    pub const fn with_target_fn(mut self, f: TargetFn) -> Self {
110        self.target = Some(TargetExtractor::Custom(f));
111        self
112    }
113
114    /// Provide a tool-specific full-identity extractor. Use this only
115    /// when the identity is more than `target={tool}:{target}` — e.g.
116    /// `plan`'s `action+status+next_phase_id` composite.
117    pub const fn with_args_key_fn(mut self, f: ArgsKeyFn) -> Self {
118        self.args_key_fn = Some(f);
119        self
120    }
121}
122
123/// Operation key for a call. Returns `"default"` when the tool has
124/// not declared an `operation_arg` — same value the old hardcoded
125/// fall-through used, so downstream messaging is unchanged for
126/// non-action tools.
127pub fn extract_operation_key(policy: &ToolIdentityPolicy, args: &Value) -> String {
128    match policy.operation_arg {
129        Some(arg) => args
130            .get(arg)
131            .and_then(Value::as_str)
132            .unwrap_or("default")
133            .to_string(),
134        None => "default".to_string(),
135    }
136}
137
138/// Persistent target string for a call (file path, URL, shell command
139/// prefix). `None` means the tool has no persistent target — the
140/// detector then keys per-target counters on the full args identity
141/// instead.
142pub fn extract_target(policy: &ToolIdentityPolicy, args: &Value) -> Option<String> {
143    match &policy.target {
144        Some(TargetExtractor::StringArg(name)) => args
145            .get(name)
146            .and_then(Value::as_str)
147            .map(str::trim)
148            .filter(|s| !s.is_empty())
149            .map(str::to_string),
150        Some(TargetExtractor::Custom(f)) => f(args),
151        None => None,
152    }
153}
154
155/// Full args/identity key for two calls of a tool. `None` means the
156/// tool declares neither a custom args-key fn nor a persistent target
157/// — the caller should fall back to its own canonical-JSON identity
158/// (see `SemanticLoopDetector::semantic_args_key`).
159///
160/// `tool_name` is threaded through so the `target`-shaped default
161/// emits the historical `target={tool_name}:{target}` format without
162/// duplicating the tool name on every declaration.
163pub fn extract_args_key(
164    policy: &ToolIdentityPolicy,
165    tool_name: &str,
166    args: &Value,
167) -> Option<String> {
168    if let Some(f) = policy.args_key_fn {
169        return f(args);
170    }
171    extract_target(policy, args).map(|target| format!("target={tool_name}:{target}"))
172}