harn_vm/agent_events/tool.rs
1use serde::{Deserialize, Serialize};
2
3/// Status of a tool call. Mirrors ACP's `toolCallStatus`.
4#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum ToolCallStatus {
7 /// Dispatched by the model but not yet started.
8 Pending,
9 /// Dispatch is actively running.
10 InProgress,
11 /// Finished successfully.
12 Completed,
13 /// Finished with an error.
14 Failed,
15}
16
17impl ToolCallStatus {
18 pub const ALL: [Self; 4] = [
19 Self::Pending,
20 Self::InProgress,
21 Self::Completed,
22 Self::Failed,
23 ];
24
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::Pending => "pending",
28 Self::InProgress => "in_progress",
29 Self::Completed => "completed",
30 Self::Failed => "failed",
31 }
32 }
33}
34
35/// Wire-level classification of a `ToolCallUpdate` failure. Pairs with the
36/// human-readable `error` string so clients can render each failure type
37/// distinctly (e.g. surface a "permission denied" badge, or a different
38/// retry affordance for `network` vs `tool_error`). The enum is
39/// deliberately extensible — `unknown` is the default when the runtime
40/// could not classify a failure.
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ToolCallErrorCategory {
44 /// Host-side validation rejected the args (missing required field,
45 /// invalid type, malformed JSON).
46 SchemaValidation,
47 /// The tool ran and returned an error result (e.g. `read_file` on a
48 /// missing path) — distinguished from a transport failure.
49 ToolError,
50 /// MCP transport / server-protocol error.
51 McpServerError,
52 /// The host bridge returned an error during dispatch.
53 HostBridgeError,
54 /// `session/request_permission` denied by the client, or a policy
55 /// rule (static or dynamic) refused the call.
56 PermissionDenied,
57 /// The harn loop detector skipped this call because the same
58 /// (tool, args) pair repeated past the configured threshold.
59 RejectedLoop,
60 /// Streaming text candidate was detected (bare `name(` or
61 /// `<tool_call>` opener) but never resolved into a parseable call:
62 /// args parsed as malformed, the heredoc body broke, the tag closed
63 /// without a balanced expression, or the stream ended mid-call.
64 /// Used by the streaming candidate detector (harn#692) to retract a
65 /// `tool_call` candidate that turned out to be prose or syntactically
66 /// broken so clients can dismiss the in-flight chip.
67 ParseAborted,
68 /// The tool exceeded its time budget.
69 Timeout,
70 /// Transient network / rate-limited / 5xx provider failure.
71 Network,
72 /// The tool was cancelled (e.g. session aborted).
73 Cancelled,
74 /// Default when classification was not performed.
75 Unknown,
76}
77
78impl ToolCallErrorCategory {
79 pub const ALL: [Self; 11] = [
80 Self::SchemaValidation,
81 Self::ToolError,
82 Self::McpServerError,
83 Self::HostBridgeError,
84 Self::PermissionDenied,
85 Self::RejectedLoop,
86 Self::ParseAborted,
87 Self::Timeout,
88 Self::Network,
89 Self::Cancelled,
90 Self::Unknown,
91 ];
92
93 /// Whether a rejection in this category is RECOVERABLE by the model on its
94 /// own — i.e. the call failed because of a fixable slip (bad/missing
95 /// arguments, malformed tool name) and re-issuing it *with the correction*
96 /// is the right next move. Distinguished from a true policy/permission
97 /// denial, where the model must NOT retry and should pivot or ask. Used by
98 /// the dispatch primitive to pick a retry-positive vs. don't-retry feedback
99 /// body for the model-facing tool result.
100 pub fn is_recoverable(self) -> bool {
101 matches!(self, Self::SchemaValidation)
102 }
103
104 pub fn as_str(self) -> &'static str {
105 match self {
106 Self::SchemaValidation => "schema_validation",
107 Self::ToolError => "tool_error",
108 Self::McpServerError => "mcp_server_error",
109 Self::HostBridgeError => "host_bridge_error",
110 Self::PermissionDenied => "permission_denied",
111 Self::RejectedLoop => "rejected_loop",
112 Self::ParseAborted => "parse_aborted",
113 Self::Timeout => "timeout",
114 Self::Network => "network",
115 Self::Cancelled => "cancelled",
116 Self::Unknown => "unknown",
117 }
118 }
119
120 /// Map an internal `ErrorCategory` (used by the VM's `VmError`
121 /// classification) onto the wire enum. The internal taxonomy is
122 /// finer-grained — several transient categories collapse onto
123 /// `Network`, and the auth/quota family becomes `HostBridgeError`
124 /// because at the tool-dispatch boundary those errors come from
125 /// the bridge transport rather than the tool itself.
126 pub fn from_internal(category: &crate::value::ErrorCategory) -> Self {
127 use crate::value::ErrorCategory as Internal;
128 match category {
129 Internal::Timeout => Self::Timeout,
130 Internal::RateLimit
131 | Internal::Overloaded
132 | Internal::ServerError
133 | Internal::TransientNetwork => Self::Network,
134 Internal::SchemaValidation | Internal::SchemaStreamAborted => Self::SchemaValidation,
135 Internal::ToolError => Self::ToolError,
136 Internal::ToolRejected => Self::PermissionDenied,
137 Internal::Cancelled => Self::Cancelled,
138 Internal::Auth
139 | Internal::EgressBlocked
140 | Internal::ChannelClosed
141 | Internal::NotFound
142 | Internal::CircuitOpen
143 | Internal::BudgetExceeded
144 | Internal::Generic => Self::HostBridgeError,
145 }
146 }
147}
148
149/// Which gate refused a tool call. Pairs with [`ToolDenial`] so host
150/// harnesses can distinguish a hard capability/policy ceiling (terminal —
151/// retrying the identical call can never succeed) from a user/host
152/// approval rejection, without re-parsing the human-readable reason
153/// string (harn#2780).
154#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum DenialGate {
157 /// The tool is not in the policy's allowed-tool list.
158 ToolCeiling,
159 /// The tool requires a capability/operation the policy does not grant
160 /// (e.g. `workspace.write_text`, `process.exec`).
161 CapabilityCeiling,
162 /// The tool's side-effect level exceeds the policy ceiling
163 /// (e.g. a `process_exec` tool under a `read_only` policy).
164 SideEffectCeiling,
165 /// A `tool_arg_constraint` allow-list rejected the resolved argument
166 /// value (e.g. a `command` that does not match `cargo *`).
167 ArgConstraint,
168 /// A dynamic permission rule (`when`/`unless` predicate) denied the
169 /// call.
170 DynamicPermission,
171 /// A static approval policy decided `deny`.
172 ApprovalPolicy,
173 /// Approval was required (`ask`) but could not be requested because no
174 /// host bridge was available or the request transport failed.
175 ApprovalUnavailable,
176 /// The host/user rejected an approval request (`session/request_permission`).
177 HostRejected,
178 /// A registered pre-tool hook returned `deny`.
179 HookDeny,
180 /// Gate could not be classified.
181 #[default]
182 Unknown,
183}
184
185impl DenialGate {
186 pub const ALL: [Self; 10] = [
187 Self::ToolCeiling,
188 Self::CapabilityCeiling,
189 Self::SideEffectCeiling,
190 Self::ArgConstraint,
191 Self::DynamicPermission,
192 Self::ApprovalPolicy,
193 Self::ApprovalUnavailable,
194 Self::HostRejected,
195 Self::HookDeny,
196 Self::Unknown,
197 ];
198
199 pub fn as_str(self) -> &'static str {
200 match self {
201 Self::ToolCeiling => "tool_ceiling",
202 Self::CapabilityCeiling => "capability_ceiling",
203 Self::SideEffectCeiling => "side_effect_ceiling",
204 Self::ArgConstraint => "arg_constraint",
205 Self::DynamicPermission => "dynamic_permission",
206 Self::ApprovalPolicy => "approval_policy",
207 Self::ApprovalUnavailable => "approval_unavailable",
208 Self::HostRejected => "host_rejected",
209 Self::HookDeny => "hook_deny",
210 Self::Unknown => "unknown",
211 }
212 }
213}
214
215/// Structured record of a tool call refused at the dispatch boundary —
216/// by a capability/policy ceiling, an argument allow-list, a permission
217/// rule, an approval decision, or a pre-tool hook. Carried on the denied
218/// `tool_result` and the `PermissionDeny` transcript event so host
219/// harnesses (and the loop's own stall detector) can fail or pivot early
220/// without re-parsing human-readable command output (harn#2780). The
221/// `denied_paths` field captures any workspace paths the refused call
222/// declared, so a path-scoped denial names the offending path.
223#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct ToolDenial {
225 /// Which gate refused the call.
226 pub gate: DenialGate,
227 /// Capability/operation that was exceeded, e.g. `workspace.read_text`
228 /// or `process.exec`, when the gate identified one.
229 #[serde(skip_serializing_if = "Option::is_none", default)]
230 pub capability: Option<String>,
231 /// Workspace paths the denied call declared, when the tool annotates
232 /// path arguments. Empty for tools that declare no paths.
233 #[serde(skip_serializing_if = "Vec::is_empty", default)]
234 pub denied_paths: Vec<String>,
235 /// Whether re-issuing the identical call could ever succeed. Capability
236 /// and side-effect ceilings, argument allow-lists, and policy/approval
237 /// denials are terminal (`false`); a host harness should fail or pivot
238 /// rather than spend another model call retrying.
239 pub retryable: bool,
240 /// Human-readable explanation — the same text the model sees in the
241 /// tool result.
242 pub reason: String,
243}
244
245impl ToolDenial {
246 /// Build a terminal denial (`retryable: false`) with no declared paths
247 /// attached yet. Every gate Harn currently enforces is terminal —
248 /// re-issuing the identical call can never succeed — so the constructor
249 /// hard-codes `retryable: false`; the field exists so a future soft
250 /// denial can set it `true`. Callers at the dispatch boundary enrich
251 /// `denied_paths` from the tool's annotated path arguments.
252 pub fn terminal(
253 gate: DenialGate,
254 capability: Option<String>,
255 reason: impl Into<String>,
256 ) -> Self {
257 Self {
258 gate,
259 capability,
260 denied_paths: Vec::new(),
261 retryable: false,
262 reason: reason.into(),
263 }
264 }
265
266 /// Build a SOFT denial (`retryable: true`): the call was refused for *this*
267 /// argument, but re-issuing it with a corrected argument can succeed — so
268 /// the model should be coached to retry with the correction rather than told
269 /// to give up. Used for the argument allow-list gate (`ArgConstraint`),
270 /// where a path/command outside the allowed scope is a fixable slip, not a
271 /// hard capability ceiling. The dispatch boundary routes a retryable denial
272 /// through the recoverable (retry-positive) tool-result body.
273 pub fn retryable(
274 gate: DenialGate,
275 capability: Option<String>,
276 reason: impl Into<String>,
277 ) -> Self {
278 Self {
279 gate,
280 capability,
281 denied_paths: Vec::new(),
282 retryable: true,
283 reason: reason.into(),
284 }
285 }
286
287 pub fn to_json(&self) -> serde_json::Value {
288 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
289 }
290}
291
292/// Where a tool actually ran. Tags `ToolCallUpdate` so clients can render
293/// "via mcp:linear" / "via host bridge" badges, attribute latency by
294/// transport, and route errors to the right surface (harn#691).
295///
296/// On the wire this serializes adjacently-tagged so the `mcp_server`
297/// case carries the configured server name. The ACP adapter rewrites
298/// unit variants as bare strings (`"harn_builtin"`, `"host_bridge"`,
299/// `"provider_native"`) and the `McpServer` case as
300/// `{"kind": "mcp_server", "serverName": "..."}` to match the protocol's
301/// camelCase convention.
302#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
303#[serde(tag = "kind", rename_all = "snake_case")]
304pub enum ToolExecutor {
305 /// VM-stdlib (`read_file`, `write_file`, `exec`, `http_*`, `mcp_*`)
306 /// or any Harn-side handler closure registered in `tools_val`.
307 HarnBuiltin,
308 /// Capability provided by the host through `HostBridge.builtin_call`
309 /// (host IDE bridge and CLI host shells).
310 HostBridge,
311 /// Tool dispatched against a configured MCP server. Detected by the
312 /// `_mcp_server` tag that `mcp_list_tools` injects on every tool
313 /// dict before the agent loop sees it.
314 McpServer { server_name: String },
315 /// Provider-side server-side tool execution — currently OpenAI
316 /// Responses-API server tools (e.g. native `tool_search`). The
317 /// runtime never dispatches these locally; the model returns the
318 /// already-executed result inline.
319 ProviderNative,
320}