返回 CodeWhale
activity.rs
根目录 / crates / tui / src / tools / activity.rs
1 //! Typed operation activity for the Engine owner contract.
2 //!
3 //! The Engine reports *what kind* of work a dispatched call is doing (reading,
4 //! editing, desktop control, ...) without the tool name, arguments, command or
5 //! result. Classification reuses the resolvers dispatch already trusts:
6 //! [`canonical_action_alias`] for action families and the MCP pool's resolved
7 //! server map for MCP tools. A call that neither resolves to a concrete
8 //! operation nor to a registered tool reports nothing.
9
10 use codewhale_protocol::engine_owner::{OwnerActivityKind, OwnerOperationOutcome};
11 use serde_json::Value;
12
13 use super::canonical_action::{canonical_action_alias, is_action_family};
14 use super::spec::{RichToolResult, ToolError};
15
16 /// Activity kind for a resolved (non-MCP) operation name.
17 fn kind_for_operation(operation: &str) -> OwnerActivityKind {
18 use OwnerActivityKind as Kind;
19 match operation {
20 "read_file" | "list_dir" | "read_media" => Kind::Reading,
21 "write_file" | "edit_file" | "apply_patch" | "fim_edit" => Kind::Editing,
22 "file_search" | "grep_files" => Kind::Searching,
23 "run_tests" | "run_verifiers" => Kind::Testing,
24 "exec_shell"
25 | "exec_shell_wait"
26 | "exec_shell_interact"
27 | "exec_shell_cancel"
28 | "task_gate_run"
29 | "automation_run"
30 | "rlm_eval" => Kind::Executing,
31 "web_search" | "fetch_url" | "wait_for_dev_server" | "rlm_open" => Kind::Browsing,
32 "memory_search" => Kind::Memory,
33 _ => Kind::Tool,
34 }
35 }
36
37 /// Classify a call the tool registry is about to dispatch.
38 ///
39 /// The caller only asks for names the registry has registered; interpreter
40 /// and MCP dispatch classify separately. Action families resolve through
41 /// [`canonical_action_alias`]; an action the wrapper would refuse (missing,
42 /// or not in the alias table) reports nothing.
43 #[must_use]
44 pub(crate) fn registry_activity_kind(tool_name: &str, input: &Value) -> Option<OwnerActivityKind> {
45 if is_action_family(tool_name) {
46 // Every family wrapper but the shell refuses an actionless call
47 // (`required_action`); the shell defaults to `run`.
48 let explicit = input.get("action").and_then(Value::as_str).is_some();
49 if !explicit && !matches!(tool_name, "bash" | "Bash") {
50 return None;
51 }
52 let operation = canonical_action_alias(tool_name, input);
53 return (operation != tool_name).then(|| kind_for_operation(operation));
54 }
55 Some(kind_for_operation(canonical_action_alias(tool_name, input)))
56 }
57
58 /// Classify an MCP call by the server the pool resolved it to, not by the
59 /// model-facing name. Desktop-control servers report `Computer`; everything
60 /// else is a generic `Tool`.
61 #[must_use]
62 pub(crate) fn mcp_activity_kind(server: &str) -> OwnerActivityKind {
63 if super::subagent::is_machine_control_tool(&format!("mcp_{server}_tool")) {
64 OwnerActivityKind::Computer
65 } else {
66 OwnerActivityKind::Tool
67 }
68 }
69
70 /// Typed outcome for a finished call. `cancelled` only reclassifies a failure:
71 /// a call that succeeded before the token fired still succeeded.
72 #[must_use]
73 pub(crate) fn operation_outcome(
74 outcome: &Result<RichToolResult, ToolError>,
75 cancelled: bool,
76 ) -> OwnerOperationOutcome {
77 match outcome {
78 Ok(result) if result.result.success => OwnerOperationOutcome::Succeeded,
79 _ if cancelled => OwnerOperationOutcome::Cancelled,
80 Ok(_) => OwnerOperationOutcome::Failed,
81 Err(ToolError::Cancelled { .. }) => OwnerOperationOutcome::Cancelled,
82 // Only a policy refusal is a denial. Bad input, an escaped path or a
83 // missing tool is the call failing, and the pet should show it.
84 Err(ToolError::PermissionDenied { .. }) => OwnerOperationOutcome::Denied,
85 Err(
86 ToolError::InvalidInput { .. }
87 | ToolError::MissingField { .. }
88 | ToolError::PathEscape { .. }
89 | ToolError::NotAvailable { .. }
90 | ToolError::ExecutionFailed { .. }
91 | ToolError::Timeout { .. },
92 ) => OwnerOperationOutcome::Failed,
93 }
94 }
95
96 #[cfg(test)]
97 mod tests {
98 use super::*;
99 use crate::tools::spec::ToolResult;
100 use serde_json::json;
101
102 use OwnerActivityKind as Kind;
103
104 #[test]
105 fn primitives_and_action_families_resolve_through_the_canonical_alias() {
106 let none = json!({});
107 assert_eq!(registry_activity_kind("read", &none), Some(Kind::Reading));
108 assert_eq!(registry_activity_kind("write", &none), Some(Kind::Editing));
109 assert_eq!(registry_activity_kind("edit", &none), Some(Kind::Editing));
110 let cases = [
111 ("File", "read", Kind::Reading),
112 ("File", "patch", Kind::Editing),
113 ("File", "search_content", Kind::Searching),
114 ("Run", "tests", Kind::Testing),
115 ("Web", "fetch", Kind::Browsing),
116 ("Git", "status", Kind::Tool),
117 ("rlm", "eval", Kind::Executing),
118 ("rlm", "open", Kind::Browsing),
119 ("Bash", "run", Kind::Executing),
120 ];
121 for (family, action, kind) in cases {
122 assert_eq!(
123 registry_activity_kind(family, &json!({"action": action})),
124 Some(kind),
125 "{family}.{action}"
126 );
127 }
128 // The shell defaults an actionless call to `run`.
129 assert_eq!(
130 registry_activity_kind("bash", &json!({"command": "ls"})),
131 Some(Kind::Executing)
132 );
133 }
134
135 #[test]
136 fn refused_or_unresolved_calls_report_nothing() {
137 // Wrappers refuse an actionless or unknown action: nothing ran.
138 assert_eq!(registry_activity_kind("File", &json!({"path": "a"})), None);
139 assert_eq!(
140 registry_activity_kind("Web", &json!({"action": "teleport"})),
141 None
142 );
143 assert_eq!(registry_activity_kind("rlm", &json!({})), None);
144 assert_eq!(
145 registry_activity_kind("tasks", &json!({"action": "nope"})),
146 None
147 );
148 }
149
150 #[test]
151 fn shipped_desktop_control_servers_are_computer_activity() {
152 for server in ["codewhale-cu", "computer-use", "local-computer_use"] {
153 assert_eq!(mcp_activity_kind(server), Kind::Computer, "{server}");
154 }
155 assert_eq!(mcp_activity_kind("github"), Kind::Tool);
156 }
157
158 #[test]
159 fn cancellation_does_not_rewrite_a_successful_call() {
160 let ok = Ok(RichToolResult::plain(ToolResult::success("done")));
161 assert_eq!(
162 operation_outcome(&ok, true),
163 OwnerOperationOutcome::Succeeded,
164 "a call that finished before the token fired still succeeded"
165 );
166 let failed = Ok(RichToolResult::plain(ToolResult::error("boom")));
167 assert_eq!(
168 operation_outcome(&failed, false),
169 OwnerOperationOutcome::Failed
170 );
171 assert_eq!(
172 operation_outcome(&failed, true),
173 OwnerOperationOutcome::Cancelled
174 );
175 let denied = Err(ToolError::permission_denied("no"));
176 assert_eq!(
177 operation_outcome(&denied, false),
178 OwnerOperationOutcome::Denied
179 );
180 assert_eq!(
181 operation_outcome(&Err(ToolError::cancelled("stop")), false),
182 OwnerOperationOutcome::Cancelled
183 );
184 for failure in [
185 ToolError::Timeout { seconds: 5 },
186 ToolError::execution_failed("x"),
187 ToolError::invalid_input("bad"),
188 ToolError::missing_field("path"),
189 ToolError::not_available("gone"),
190 ] {
191 assert_eq!(
192 operation_outcome(&Err(failure), false),
193 OwnerOperationOutcome::Failed
194 );
195 }
196 }
197 }
198
198 lines RUST