| 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 |