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/// 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            // An internal engine/wiring bug is a host-side failure, not the
145            // tool's fault; it normally propagates out of the loop, but if one
146            // is ever recorded as a tool event, `HostBridgeError` is the honest
147            // wire bucket.
148            | Internal::Internal
149            | Internal::Generic => Self::HostBridgeError,
150        }
151    }
152}
153
154/// Which gate refused a tool call. Pairs with [`ToolDenial`] so host
155/// harnesses can distinguish a hard capability/policy ceiling (terminal —
156/// retrying the identical call can never succeed) from a user/host
157/// approval rejection, without re-parsing the human-readable reason
158/// string (harn#2780).
159#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum DenialGate {
162    /// The tool is not in the policy's allowed-tool list.
163    ToolCeiling,
164    /// The tool requires a capability/operation the policy does not grant
165    /// (e.g. `workspace.write_text`, `process.exec`).
166    CapabilityCeiling,
167    /// The tool's side-effect level exceeds the policy ceiling
168    /// (e.g. a `process_exec` tool under a `read_only` policy).
169    SideEffectCeiling,
170    /// A `tool_arg_constraint` allow-list rejected the resolved argument
171    /// value (e.g. a `command` that does not match `cargo *`).
172    ArgConstraint,
173    /// A dynamic permission rule (`when`/`unless` predicate) denied the
174    /// call.
175    DynamicPermission,
176    /// A static approval policy decided `deny`.
177    ApprovalPolicy,
178    /// Approval was required (`ask`) but could not be requested because no
179    /// host bridge was available or the request transport failed.
180    ApprovalUnavailable,
181    /// The host/user rejected an approval request (`session/request_permission`).
182    HostRejected,
183    /// A registered pre-tool hook returned `deny`.
184    HookDeny,
185    /// Gate could not be classified.
186    #[default]
187    Unknown,
188}
189
190impl DenialGate {
191    pub const ALL: [Self; 10] = [
192        Self::ToolCeiling,
193        Self::CapabilityCeiling,
194        Self::SideEffectCeiling,
195        Self::ArgConstraint,
196        Self::DynamicPermission,
197        Self::ApprovalPolicy,
198        Self::ApprovalUnavailable,
199        Self::HostRejected,
200        Self::HookDeny,
201        Self::Unknown,
202    ];
203
204    pub fn as_str(self) -> &'static str {
205        match self {
206            Self::ToolCeiling => "tool_ceiling",
207            Self::CapabilityCeiling => "capability_ceiling",
208            Self::SideEffectCeiling => "side_effect_ceiling",
209            Self::ArgConstraint => "arg_constraint",
210            Self::DynamicPermission => "dynamic_permission",
211            Self::ApprovalPolicy => "approval_policy",
212            Self::ApprovalUnavailable => "approval_unavailable",
213            Self::HostRejected => "host_rejected",
214            Self::HookDeny => "hook_deny",
215            Self::Unknown => "unknown",
216        }
217    }
218}
219
220/// Structured record of a tool call refused at the dispatch boundary —
221/// by a capability/policy ceiling, an argument allow-list, a permission
222/// rule, an approval decision, or a pre-tool hook. Carried on the denied
223/// `tool_result` and the `PermissionDeny` transcript event so host
224/// harnesses (and the loop's own stall detector) can fail or pivot early
225/// without re-parsing human-readable command output (harn#2780). The
226/// `denied_paths` field captures any workspace paths the refused call
227/// declared, so a path-scoped denial names the offending path.
228#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct ToolDenial {
230    /// Which gate refused the call.
231    pub gate: DenialGate,
232    /// Capability/operation that was exceeded, e.g. `workspace.read_text`
233    /// or `process.exec`, when the gate identified one.
234    #[serde(skip_serializing_if = "Option::is_none", default)]
235    pub capability: Option<String>,
236    /// Workspace paths the denied call declared, when the tool annotates
237    /// path arguments. Empty for tools that declare no paths.
238    #[serde(skip_serializing_if = "Vec::is_empty", default)]
239    pub denied_paths: Vec<String>,
240    /// Whether re-issuing the identical call could ever succeed. Capability
241    /// and side-effect ceilings, argument allow-lists, and policy/approval
242    /// denials are terminal (`false`); a host harness should fail or pivot
243    /// rather than spend another model call retrying.
244    pub retryable: bool,
245    /// Human-readable explanation — the same text the model sees in the
246    /// tool result.
247    pub reason: String,
248}
249
250impl ToolDenial {
251    /// Build a terminal denial (`retryable: false`) with no declared paths
252    /// attached yet. Every gate Harn currently enforces is terminal —
253    /// re-issuing the identical call can never succeed — so the constructor
254    /// hard-codes `retryable: false`; the field exists so a future soft
255    /// denial can set it `true`. Callers at the dispatch boundary enrich
256    /// `denied_paths` from the tool's annotated path arguments.
257    pub fn terminal(
258        gate: DenialGate,
259        capability: Option<String>,
260        reason: impl Into<String>,
261    ) -> Self {
262        Self {
263            gate,
264            capability,
265            denied_paths: Vec::new(),
266            retryable: false,
267            reason: reason.into(),
268        }
269    }
270
271    /// Build a SOFT denial (`retryable: true`): the call was refused for *this*
272    /// argument, but re-issuing it with a corrected argument can succeed — so
273    /// the model should be coached to retry with the correction rather than told
274    /// to give up. Used for the argument allow-list gate (`ArgConstraint`),
275    /// where a path/command outside the allowed scope is a fixable slip, not a
276    /// hard capability ceiling. The dispatch boundary routes a retryable denial
277    /// through the recoverable (retry-positive) tool-result body.
278    pub fn retryable(
279        gate: DenialGate,
280        capability: Option<String>,
281        reason: impl Into<String>,
282    ) -> Self {
283        Self {
284            gate,
285            capability,
286            denied_paths: Vec::new(),
287            retryable: true,
288            reason: reason.into(),
289        }
290    }
291
292    pub fn to_json(&self) -> serde_json::Value {
293        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
294    }
295}
296
297/// Where a tool actually ran. Tags `ToolCallUpdate` so clients can render
298/// "via mcp:linear" / "via host bridge" badges, attribute latency by
299/// transport, and route errors to the right surface (harn#691).
300///
301/// On the wire this serializes adjacently-tagged so the `mcp_server`
302/// case carries the configured server name. The ACP adapter rewrites
303/// unit variants as bare strings (`"harn_builtin"`, `"host_bridge"`,
304/// `"provider_native"`) and the `McpServer` case as
305/// `{"kind": "mcp_server", "serverName": "..."}` to match the protocol's
306/// camelCase convention.
307#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
308#[serde(tag = "kind", rename_all = "snake_case")]
309pub enum ToolExecutor {
310    /// VM-stdlib (`read_file`, `write_file`, `exec`, `http_*`, `mcp_*`)
311    /// or any Harn-side handler closure registered in `tools_val`.
312    HarnBuiltin,
313    /// Capability provided by the host through `HostBridge.builtin_call`
314    /// (host IDE bridge and CLI host shells).
315    HostBridge,
316    /// Tool dispatched against a configured MCP server. Detected by the
317    /// `_mcp_server` tag that `mcp_list_tools` injects on every tool
318    /// dict before the agent loop sees it.
319    McpServer { server_name: String },
320    /// Provider-side server-side tool execution — currently OpenAI
321    /// Responses-API server tools (e.g. native `tool_search`). The
322    /// runtime never dispatches these locally; the model returns the
323    /// already-executed result inline.
324    ProviderNative,
325}