Skip to main content

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}