返回 CodeWhale
canonical_action.rs
根目录 / crates / tui / src / tools / canonical_action.rs
1 //! Semantic aliases for the model-facing action tools.
2 //!
3 //! `Bash`, `File`, `Git`, `Run`, `Web`, and `rlm` deliberately keep their
4 //! canonical names at the execution and audit boundaries. Presentation and
5 //! policy consumers, however, still understand the older per-action names.
6 //! Resolve that semantic name in one place so live calls and saved legacy
7 //! transcripts receive identical downstream behavior without rewriting the
8 //! original call.
9 //!
10 //! This table is not documentation — it is the **action-policy seam**. A
11 //! permission check that denies `fetch_url` only reaches `Web{action:"fetch"}`
12 //! because the pair is listed here. A family that is missing from the table is
13 //! a family whose actions no deny list can see, which is why `rlm` was added:
14 //! `rlm{action:"open", url:...}` calls `FetchUrlTool` *inside the process*,
15 //! under its own name, and a name-keyed deny list never sees that call.
16
17 use serde_json::Value;
18
19 pub(crate) const CANONICAL_ACTION_ALIASES: &[(&str, &str, &str)] = &[
20 ("Bash", "run", "exec_shell"),
21 ("Bash", "wait", "exec_shell_wait"),
22 ("Bash", "interact", "exec_shell_interact"),
23 ("Bash", "cancel", "exec_shell_cancel"),
24 ("File", "read", "read_file"),
25 ("File", "list", "list_dir"),
26 ("File", "search_name", "file_search"),
27 ("File", "search_content", "grep_files"),
28 ("File", "write", "write_file"),
29 ("File", "edit", "edit_file"),
30 ("File", "patch", "apply_patch"),
31 ("Git", "status", "git_status"),
32 ("Git", "diff", "git_diff"),
33 ("Git", "log", "git_log"),
34 ("Git", "show", "git_show"),
35 ("Git", "blame", "git_blame"),
36 ("Run", "tests", "run_tests"),
37 ("Run", "verifiers", "run_verifiers"),
38 ("Web", "search", "web_search"),
39 ("Web", "fetch", "fetch_url"),
40 ("Web", "wait", "wait_for_dev_server"),
41 // The RLM session family. `open` reaches the network (it fetches a `url`
42 // through `FetchUrlTool` in-process) and `eval` runs operator-supplied
43 // Python against a live kernel — sockets and filesystem both. The other
44 // three actions are bounded local metadata. Listing every pair is what lets
45 // a deny list keep the local half and remove the reaching half, instead of
46 // having to choose between the whole family and nothing.
47 ("rlm", "session_objects", "rlm_session_objects"),
48 ("rlm", "open", "rlm_open"),
49 ("rlm", "eval", "rlm_eval"),
50 ("rlm", "configure", "rlm_configure"),
51 ("rlm", "close", "rlm_close"),
52 // The durable-work families. These were absent for the same reason `rlm`
53 // was: they are model-visible under one canonical name (`tasks`,
54 // `automation`, `github`) and their per-action legacy names are registered
55 // as *hidden* aliases. A deny list naming `task_gate_run` therefore never
56 // saw `tasks{action:"gate_run"}`, and the action-enum pruner never saw the
57 // family at all — so an operator ceiling could not express "durable task
58 // bookkeeping, yes; running a gate command, no".
59 //
60 // `gate_run` runs an operator-supplied command, `automation.run` executes a
61 // stored automation, and the mutating `automation.*` actions schedule agent
62 // runs with their own cwd. All three are execution primitives spelled as
63 // bookkeeping, which is exactly the shape
64 // [`crate::tools::execution_envelope`] classifies from capabilities.
65 ("tasks", "create", "task_create"),
66 ("tasks", "list", "task_list"),
67 ("tasks", "read", "task_read"),
68 ("tasks", "cancel", "task_cancel"),
69 ("tasks", "gate_run", "task_gate_run"),
70 ("tasks", "pr_attempt_record", "pr_attempt_record"),
71 ("tasks", "pr_attempt_list", "pr_attempt_list"),
72 ("tasks", "pr_attempt_read", "pr_attempt_read"),
73 ("tasks", "pr_attempt_preflight", "pr_attempt_preflight"),
74 ("automation", "create", "automation_create"),
75 ("automation", "list", "automation_list"),
76 ("automation", "read", "automation_read"),
77 ("automation", "update", "automation_update"),
78 ("automation", "pause", "automation_pause"),
79 ("automation", "resume", "automation_resume"),
80 ("automation", "delete", "automation_delete"),
81 ("automation", "run", "automation_run"),
82 ("github", "issue_context", "github_issue_context"),
83 ("github", "pr_context", "github_pr_context"),
84 ("github", "comment", "github_comment"),
85 ("github", "close_issue", "github_close_issue"),
86 ("github", "close_pr", "github_close_pr"),
87 ];
88
89 /// The conservative action label policy uses when the model omits `action`.
90 ///
91 /// This is a *policy* fallback only. Execution rejects an actionless call in
92 /// every family (see [`required_action`]); approval and parallel-safety
93 /// predicates cannot return an error, so they still need a label, and it must
94 /// be the family's least dangerous action.
95 ///
96 /// `None` means the family never had even a policy default — `rlm`'s contract:
97 /// [`crate::tools::rlm::RlmTool::resolve_action`] errors rather than guessing.
98 /// Policy still resolves an *explicit* action for such a family — see
99 /// [`canonical_action_alias`].
100 #[must_use]
101 pub(crate) fn action_family_default(tool_name: &str) -> Option<Option<&'static str>> {
102 match tool_name {
103 "Bash" => Some(Some("run")),
104 "File" => Some(Some("read")),
105 "Git" => Some(Some("status")),
106 "Run" => Some(Some("tests")),
107 "Web" => Some(Some("search")),
108 // Families whose wrappers reject an actionless call rather than
109 // guessing. Policy still resolves an *explicit* action for them.
110 "rlm" | "tasks" | "automation" | "github" => Some(None),
111 _ => None,
112 }
113 }
114
115 /// Whether `tool_name` is a model-facing action family whose `action` enum
116 /// policy may prune.
117 ///
118 /// Derived from [`action_family_default`] rather than spelled out at each call
119 /// site: a hard-coded family list that falls behind the alias table is a family
120 /// whose actions stay visible after policy removed them.
121 #[must_use]
122 pub(crate) fn is_action_family(tool_name: &str) -> bool {
123 action_family_default(tool_name).is_some()
124 }
125
126 /// Require the `action` discriminator on a canonical action-family call.
127 ///
128 /// Every family schema marks `action` required, but the wrappers used to
129 /// default a missing one (`File` → read, `Git` → status, `Web` → search,
130 /// `Run` → tests). A call that merely omitted or misspelled the discriminator
131 /// therefore ran a *different* operation and returned that operation's success
132 /// receipt: `File{path, content}` answered an intended write with the file's
133 /// current contents, so the write silently never happened. Same shape as
134 /// #5209 — refuse, and name the values that actually dispatch.
135 ///
136 /// `actions` must be the set this tool instance can really run, so a mode that
137 /// hides `write` never suggests it.
138 pub(crate) fn required_action(
139 input: &Value,
140 tool: &str,
141 actions: &[&str],
142 ) -> Result<String, crate::tools::spec::ToolError> {
143 use crate::tools::spec::ToolError;
144 match input.get("action") {
145 Some(Value::String(action)) => Ok(action.clone()),
146 Some(other) => Err(ToolError::invalid_input(format!(
147 "{tool} requires `action` to be a string, got {other}; nothing was run. Pass one of: {}.",
148 actions.join(", ")
149 ))),
150 None => Err(ToolError::invalid_input(format!(
151 "{tool} requires an `action` parameter; nothing was run. Pass one of: {}.",
152 actions.join(", ")
153 ))),
154 }
155 }
156
157 /// Resolve a canonical action tool to the legacy name for that exact action.
158 ///
159 /// A missing action falls back to the family's conservative default so the
160 /// *policy* label is never absent; execution itself refuses the call (see
161 /// `required_action`). Unknown actions stay canonical so policy remains
162 /// conservative and the eventual tool error is attributed to the call the
163 /// model actually made.
164 ///
165 /// A family with no default (`rlm`) still resolves an **explicit** action. The
166 /// earlier shape returned the family name for any such call, which meant
167 /// `rlm{action:"eval"}` never resolved to `rlm_eval` and therefore never met a
168 /// deny list entry naming it.
169 #[must_use]
170 pub(crate) fn canonical_action_alias<'a>(tool_name: &'a str, input: &Value) -> &'a str {
171 let Some(default_action) = action_family_default(tool_name) else {
172 return tool_name;
173 };
174 let Some(action) = input
175 .get("action")
176 .and_then(Value::as_str)
177 .or(default_action)
178 else {
179 return tool_name;
180 };
181
182 CANONICAL_ACTION_ALIASES
183 .iter()
184 .find_map(|(family, candidate_action, alias)| {
185 (*family == tool_name && *candidate_action == action).then_some(*alias)
186 })
187 .unwrap_or(tool_name)
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193 use serde_json::json;
194
195 /// Names the v0.9.3 consolidation retired. None of them can dispatch —
196 /// `ToolRegistry::resolve` has no fuzzy step — so any one of them inside a
197 /// model-visible description or schema teaches a call that cannot work.
198 const RETIRED_TOOL_NAMES: &[&str] = &[
199 "read_file",
200 "write_file",
201 "edit_file",
202 "list_dir",
203 "file_search",
204 "grep_files",
205 "git_status",
206 "git_diff",
207 "git_log",
208 "git_show",
209 "git_blame",
210 "run_tests",
211 "run_verifiers",
212 "web_search",
213 "fetch_url",
214 "wait_for_dev_server",
215 "exec_shell",
216 "exec_shell_wait",
217 "exec_shell_interact",
218 "exec_shell_cancel",
219 ];
220
221 /// The catalog is re-sent on every request, so a retired name in it is a
222 /// per-turn lie to every model. `verifier.rs` already guarded one such
223 /// description by hand; this covers the whole advertised surface at once.
224 #[test]
225 fn no_advertised_tool_teaches_a_retired_name() {
226 use crate::tools::registry::ToolRegistryBuilder;
227 use crate::tools::spec::ToolContext;
228
229 let tmp = tempfile::tempdir().expect("tempdir");
230 let registry = ToolRegistryBuilder::new()
231 .with_file_tools()
232 .with_search_tools()
233 .with_git_tools()
234 .with_git_history_tools()
235 .with_test_runner_tool()
236 .with_web_tools()
237 .with_patch_tools()
238 .build(ToolContext::new(tmp.path().to_path_buf()));
239
240 for tool in registry.to_api_tools() {
241 let advertised = format!("{} {}", tool.description, tool.input_schema);
242 for retired in RETIRED_TOOL_NAMES {
243 assert!(
244 !advertised.contains(retired),
245 "tool `{}` advertises the retired name `{retired}`; \
246 name the canonical action form instead",
247 tool.name
248 );
249 }
250 }
251 }
252
253 #[test]
254 fn every_canonical_action_resolves_to_its_legacy_semantic_alias() {
255 for (family, action, alias) in CANONICAL_ACTION_ALIASES {
256 assert_eq!(
257 canonical_action_alias(family, &json!({"action": action})),
258 *alias,
259 "{family}.{action}"
260 );
261 }
262 }
263
264 /// Execution refuses an actionless call; policy still needs a label for
265 /// it, and that label must stay the family's most conservative action.
266 #[test]
267 fn actionless_calls_keep_a_conservative_policy_label() {
268 for (family, alias) in [
269 ("Bash", "exec_shell"),
270 ("File", "read_file"),
271 ("Git", "git_status"),
272 ("Run", "run_tests"),
273 ("Web", "web_search"),
274 ] {
275 assert_eq!(
276 canonical_action_alias(family, &json!({})),
277 alias,
278 "{family}"
279 );
280 }
281 }
282
283 #[test]
284 fn legacy_unknown_and_invalid_calls_keep_their_original_names() {
285 for name in ["exec_shell", "read_file", "future_tool"] {
286 assert_eq!(canonical_action_alias(name, &json!({})), name);
287 }
288 assert_eq!(
289 canonical_action_alias("File", &json!({"action": "delete"})),
290 "File"
291 );
292 assert_eq!(
293 canonical_action_alias("Bash", &json!({"action": 42})),
294 "exec_shell"
295 );
296 }
297
298 /// A family with no execution default still resolves an explicit action.
299 /// Without this, `rlm{action:"eval"}` resolves to `rlm` and slips past every
300 /// deny list entry that names `rlm_eval`.
301 #[test]
302 fn a_family_without_a_default_still_resolves_an_explicit_action() {
303 assert_eq!(
304 canonical_action_alias("rlm", &json!({"action": "eval"})),
305 "rlm_eval"
306 );
307 assert_eq!(
308 canonical_action_alias("rlm", &json!({"action": "open", "url": "https://x.test/a"})),
309 "rlm_open"
310 );
311 // No action, no default: nothing to resolve, and `RlmTool` will reject
312 // the call on its own terms.
313 assert_eq!(canonical_action_alias("rlm", &json!({})), "rlm");
314 assert_eq!(
315 canonical_action_alias("rlm", &json!({"action": "nope"})),
316 "rlm"
317 );
318 }
319
320 /// Every family named in the alias table must be recognised as a family, or
321 /// its actions are unprunable by the visibility filter.
322 #[test]
323 fn every_aliased_family_is_a_known_action_family() {
324 for (family, _, _) in CANONICAL_ACTION_ALIASES {
325 assert!(
326 is_action_family(family),
327 "{family} is aliased but not registered as an action family"
328 );
329 }
330 assert!(!is_action_family("read_file"));
331 assert!(!is_action_family("future_tool"));
332 }
333 }
334
334 lines RUST