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 /// Default when classification was not performed.
102 Unknown,
103}
104
105impl ToolCallErrorCategory {
106 pub const ALL: [Self; 11] = [
107 Self::SchemaValidation,
108 Self::ToolError,
109 Self::McpServerError,
110 Self::HostBridgeError,
111 Self::PermissionDenied,
112 Self::RejectedLoop,
113 Self::ParseAborted,
114 Self::Timeout,
115 Self::Network,
116 Self::Cancelled,
117 Self::Unknown,
118 ];
119
120 /// Whether a rejection in this category is RECOVERABLE by the model on its
121 /// own — i.e. the call failed because of a fixable slip (bad/missing
122 /// arguments, malformed tool name) and re-issuing it *with the correction*
123 /// is the right next move. Distinguished from a true policy/permission
124 /// denial, where the model must NOT retry and should pivot or ask. Used by
125 /// the dispatch primitive to pick a retry-positive vs. don't-retry feedback
126 /// body for the model-facing tool result.
127 pub fn is_recoverable(self) -> bool {
128 matches!(self, Self::SchemaValidation)
129 }
130
131 pub fn as_str(self) -> &'static str {
132 match self {
133 Self::SchemaValidation => "schema_validation",
134 Self::ToolError => "tool_error",
135 Self::McpServerError => "mcp_server_error",
136 Self::HostBridgeError => "host_bridge_error",
137 Self::PermissionDenied => "permission_denied",
138 Self::RejectedLoop => "rejected_loop",
139 Self::ParseAborted => "parse_aborted",
140 Self::Timeout => "timeout",
141 Self::Network => "network",
142 Self::Cancelled => "cancelled",
143 Self::Unknown => "unknown",
144 }
145 }
146
147 /// Map an internal `ErrorCategory` (used by the VM's `VmError`
148 /// classification) onto the wire enum. The internal taxonomy is
149 /// finer-grained — several transient categories collapse onto
150 /// `Network`, and the auth/quota family becomes `HostBridgeError`
151 /// because at the tool-dispatch boundary those errors come from
152 /// the bridge transport rather than the tool itself.
153 pub fn from_internal(category: &crate::value::ErrorCategory) -> Self {
154 use crate::value::ErrorCategory as Internal;
155 match category {
156 Internal::Timeout => Self::Timeout,
157 Internal::RateLimit
158 | Internal::Overloaded
159 | Internal::ServerError
160 | Internal::TransientNetwork => Self::Network,
161 Internal::SchemaValidation | Internal::SchemaStreamAborted => Self::SchemaValidation,
162 Internal::ToolError => Self::ToolError,
163 Internal::ToolRejected => Self::PermissionDenied,
164 Internal::Cancelled => Self::Cancelled,
165 Internal::Auth
166 | Internal::EgressBlocked
167 | Internal::ChannelClosed
168 | Internal::NotFound
169 | Internal::CircuitOpen
170 | Internal::BudgetExceeded
171 // An internal engine/wiring bug is a host-side failure, not the
172 // tool's fault; it normally propagates out of the loop, but if one
173 // is ever recorded as a tool event, `HostBridgeError` is the honest
174 // wire bucket.
175 | Internal::Internal
176 | Internal::Generic => Self::HostBridgeError,
177 }
178 }
179}
180
181/// Which gate refused a tool call. Pairs with [`ToolDenial`] so host
182/// harnesses can distinguish a hard capability/policy ceiling (terminal —
183/// retrying the identical call can never succeed) from a user/host
184/// approval rejection, without re-parsing the human-readable reason
185/// string (harn#2780).
186#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum DenialGate {
189 /// The tool is not in the policy's allowed-tool list.
190 ToolCeiling,
191 /// The model emitted Harn text-tool wrapper syntax as a native/provider
192 /// tool name or arguments payload, so dispatch refused the wrapper while
193 /// coaching a direct re-issue of the embedded tool call.
194 MalformedToolWrapper,
195 /// The tool requires a capability/operation the policy does not grant
196 /// (e.g. `workspace.write_text`, `process.exec`).
197 CapabilityCeiling,
198 /// The tool's side-effect level exceeds the policy ceiling
199 /// (e.g. a `process_exec` tool under a `read_only` policy).
200 SideEffectCeiling,
201 /// A `tool_arg_constraint` allow-list rejected the resolved argument
202 /// value (e.g. a `command` that does not match `cargo *`).
203 ArgConstraint,
204 /// A dynamic permission rule (`when`/`unless` predicate) denied the
205 /// call.
206 DynamicPermission,
207 /// A static approval policy decided `deny`.
208 ApprovalPolicy,
209 /// Approval was required (`ask`) but could not be requested because no
210 /// host bridge was available or the request transport failed.
211 ApprovalUnavailable,
212 /// The host/user rejected an approval request (`session/request_permission`).
213 HostRejected,
214 /// A registered pre-tool hook returned `deny`.
215 HookDeny,
216 /// Gate could not be classified.
217 #[default]
218 Unknown,
219}
220
221impl DenialGate {
222 pub const ALL: [Self; 11] = [
223 Self::ToolCeiling,
224 Self::MalformedToolWrapper,
225 Self::CapabilityCeiling,
226 Self::SideEffectCeiling,
227 Self::ArgConstraint,
228 Self::DynamicPermission,
229 Self::ApprovalPolicy,
230 Self::ApprovalUnavailable,
231 Self::HostRejected,
232 Self::HookDeny,
233 Self::Unknown,
234 ];
235
236 pub fn as_str(self) -> &'static str {
237 match self {
238 Self::ToolCeiling => "tool_ceiling",
239 Self::MalformedToolWrapper => "malformed_tool_wrapper",
240 Self::CapabilityCeiling => "capability_ceiling",
241 Self::SideEffectCeiling => "side_effect_ceiling",
242 Self::ArgConstraint => "arg_constraint",
243 Self::DynamicPermission => "dynamic_permission",
244 Self::ApprovalPolicy => "approval_policy",
245 Self::ApprovalUnavailable => "approval_unavailable",
246 Self::HostRejected => "host_rejected",
247 Self::HookDeny => "hook_deny",
248 Self::Unknown => "unknown",
249 }
250 }
251}
252
253/// Structured record of a tool call refused at the dispatch boundary —
254/// by a capability/policy ceiling, an argument allow-list, a permission
255/// rule, an approval decision, or a pre-tool hook. Carried on the denied
256/// `tool_result` and the `PermissionDeny` transcript event so host
257/// harnesses (and the loop's own stall detector) can fail or pivot early
258/// without re-parsing human-readable command output (harn#2780). The
259/// `denied_paths` field captures any workspace paths the refused call
260/// declared, so a path-scoped denial names the offending path.
261#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ToolDenial {
263 /// Which gate refused the call.
264 pub gate: DenialGate,
265 /// Capability/operation that was exceeded, e.g. `workspace.read_text`
266 /// or `process.exec`, when the gate identified one.
267 #[serde(skip_serializing_if = "Option::is_none", default)]
268 pub capability: Option<String>,
269 /// Workspace paths the denied call declared, when the tool annotates
270 /// path arguments. Empty for tools that declare no paths.
271 #[serde(skip_serializing_if = "Vec::is_empty", default)]
272 pub denied_paths: Vec<String>,
273 /// Whether re-issuing the identical call could ever succeed. Capability
274 /// and side-effect ceilings, argument allow-lists, and policy/approval
275 /// denials are terminal (`false`); a host harness should fail or pivot
276 /// rather than spend another model call retrying.
277 pub retryable: bool,
278 /// Human-readable explanation — the same text the model sees in the
279 /// tool result.
280 pub reason: String,
281}
282
283impl ToolDenial {
284 /// Build a terminal denial (`retryable: false`) with no declared paths
285 /// attached yet. Every gate Harn currently enforces is terminal —
286 /// re-issuing the identical call can never succeed — so the constructor
287 /// hard-codes `retryable: false`; the field exists so a future soft
288 /// denial can set it `true`. Callers at the dispatch boundary enrich
289 /// `denied_paths` from the tool's annotated path arguments.
290 pub fn terminal(
291 gate: DenialGate,
292 capability: Option<String>,
293 reason: impl Into<String>,
294 ) -> Self {
295 Self {
296 gate,
297 capability,
298 denied_paths: Vec::new(),
299 retryable: false,
300 reason: reason.into(),
301 }
302 }
303
304 /// Build a SOFT denial (`retryable: true`): the call was refused for *this*
305 /// argument, but re-issuing it with a corrected argument can succeed — so
306 /// the model should be coached to retry with the correction rather than told
307 /// to give up. Used for the argument allow-list gate (`ArgConstraint`),
308 /// where a path/command outside the allowed scope is a fixable slip, not a
309 /// hard capability ceiling. The dispatch boundary routes a retryable denial
310 /// through the recoverable (retry-positive) tool-result body.
311 pub fn retryable(
312 gate: DenialGate,
313 capability: Option<String>,
314 reason: impl Into<String>,
315 ) -> Self {
316 Self {
317 gate,
318 capability,
319 denied_paths: Vec::new(),
320 retryable: true,
321 reason: reason.into(),
322 }
323 }
324
325 pub fn to_json(&self) -> serde_json::Value {
326 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
327 }
328}
329
330/// Where a tool actually ran. Tags `ToolCallUpdate` so clients can render
331/// "via mcp:linear" / "via host bridge" badges, attribute latency by
332/// transport, and route errors to the right surface (harn#691).
333///
334/// On the wire this serializes adjacently-tagged so the `mcp_server`
335/// case carries the configured server name. The ACP adapter rewrites
336/// unit variants as bare strings (`"harn_builtin"`, `"host_bridge"`,
337/// `"provider_native"`) and the `McpServer` case as
338/// `{"kind": "mcp_server", "serverName": "..."}` to match the protocol's
339/// camelCase convention.
340#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
341#[serde(tag = "kind", rename_all = "snake_case")]
342pub enum ToolExecutor {
343 /// VM-stdlib (`read_file`, `write_file`, `exec`, `http_*`, `mcp_*`)
344 /// or any Harn-side handler closure registered in `tools_val`.
345 HarnBuiltin,
346 /// Capability provided by the host through `HostBridge.builtin_call`
347 /// (host IDE bridge and CLI host shells).
348 HostBridge,
349 /// Tool dispatched against a configured MCP server. Detected by the
350 /// `_mcp_server` tag that `mcp_list_tools` injects on every tool
351 /// dict before the agent loop sees it.
352 McpServer { server_name: String },
353 /// Provider-side server-side tool execution — currently OpenAI
354 /// Responses-API server tools (e.g. native `tool_search`). The
355 /// runtime never dispatches these locally; the model returns the
356 /// already-executed result inline.
357 ProviderNative,
358}