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}