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