Skip to main content

harn_vm/agent_events/
tool.rs

1use serde::{Deserialize, Serialize};
2
3use crate::tool_annotations::SideEffectLevel;
4
5/// Status of a tool call. Mirrors ACP's `toolCallStatus`.
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ToolCallStatus {
9    /// Dispatched by the model but not yet started.
10    Pending,
11    /// Dispatch is actively running.
12    InProgress,
13    /// Finished successfully.
14    Completed,
15    /// Finished with an error.
16    Failed,
17}
18
19impl ToolCallStatus {
20    pub const ALL: [Self; 4] = [
21        Self::Pending,
22        Self::InProgress,
23        Self::Completed,
24        Self::Failed,
25    ];
26
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Self::Pending => "pending",
30            Self::InProgress => "in_progress",
31            Self::Completed => "completed",
32            Self::Failed => "failed",
33        }
34    }
35}
36
37/// Whether a terminal tool result changed workspace state. This is
38/// intentionally orthogonal to [`ToolCallStatus`]: a successfully completed
39/// tool can be a no-op, while a failed tool may have applied a mutation before
40/// reporting a post-apply error.
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ToolMutationStatus {
44    /// The tool changed workspace state.
45    Applied,
46    /// The tool did not change workspace state.
47    NotApplied,
48    /// The execution boundary did not provide a definitive outcome.
49    Unknown,
50}
51
52impl ToolMutationStatus {
53    pub const ALL: [Self; 3] = [Self::Applied, Self::NotApplied, Self::Unknown];
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::Applied => "applied",
58            Self::NotApplied => "not_applied",
59            Self::Unknown => "unknown",
60        }
61    }
62}
63
64/// Wire-level classification of a `ToolCallUpdate` failure. Pairs with the
65/// human-readable `error` string so clients can render each failure type
66/// distinctly (e.g. surface a "permission denied" badge, or a different
67/// retry affordance for `network` vs `tool_error`). The enum is
68/// deliberately extensible — `unknown` is the default when the runtime
69/// could not classify a failure.
70#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ToolCallErrorCategory {
73    /// Host-side validation rejected the args (missing required field,
74    /// invalid type, malformed JSON).
75    SchemaValidation,
76    /// The tool ran and returned an error result (e.g. `read_file` on a
77    /// missing path) — distinguished from a transport failure.
78    ToolError,
79    /// MCP transport / server-protocol error.
80    McpServerError,
81    /// The host bridge returned an error during dispatch.
82    HostBridgeError,
83    /// `session/request_permission` denied by the client, or a policy
84    /// rule (static or dynamic) refused the call.
85    PermissionDenied,
86    /// The harn loop detector skipped this call because the same
87    /// (tool, args) pair repeated past the configured threshold.
88    RejectedLoop,
89    /// Streaming text candidate was detected (bare `name(` or
90    /// `<tool_call>` opener) but never resolved into a parseable call:
91    /// args parsed as malformed, the heredoc body broke, the tag closed
92    /// without a balanced expression, or the stream ended mid-call.
93    /// Used by the streaming candidate detector (harn#692) to retract a
94    /// `tool_call` candidate that turned out to be prose or syntactically
95    /// broken so clients can dismiss the in-flight chip.
96    ParseAborted,
97    /// The tool exceeded its time budget.
98    Timeout,
99    /// Transient network / rate-limited / 5xx provider failure.
100    Network,
101    /// A shared local resource is temporarily unavailable, such as a
102    /// contended database write lock.
103    ResourceBusy,
104    /// The tool was cancelled (e.g. session aborted).
105    Cancelled,
106    /// The agent loop reached a terminal condition (completion judge `done`,
107    /// max iterations, budget exhausted, stuck) while this call was still in
108    /// flight — a `ToolCall` start was observed but the call never dispatched
109    /// to a `Completed`/`Failed` result. The loop synthesizes a terminal
110    /// update in this category at session finalize so the transcript never
111    /// ends with a dangling `pending` call. Distinct from [`Self::Cancelled`]
112    /// (an explicit `cancel_in_flight_tool_call` / user preemption) so an
113    /// auditor can tell loop-lifecycle abandonment from a user-initiated stop.
114    AbandonedAtLoopExit,
115    /// A host environment / infrastructure gap: a required toolchain root or
116    /// cache lies outside the sandbox profile, a needed system binary is
117    /// missing, or the machine is otherwise not provisioned for the work. The
118    /// fix is to widen the sandbox/config or provision the host, never to
119    /// change what the agent did. Distinct from [`Self::HostBridgeError`] (the
120    /// bridge itself failed) and [`Self::PermissionDenied`] (the host
121    /// deliberately refused) so a host can tell a user to fix their machine
122    /// instead of blaming the model.
123    Environment,
124    /// Default when classification was not performed.
125    Unknown,
126}
127
128impl ToolCallErrorCategory {
129    pub const ALL: [Self; 14] = [
130        Self::SchemaValidation,
131        Self::ToolError,
132        Self::McpServerError,
133        Self::HostBridgeError,
134        Self::PermissionDenied,
135        Self::RejectedLoop,
136        Self::ParseAborted,
137        Self::Timeout,
138        Self::Network,
139        Self::ResourceBusy,
140        Self::Cancelled,
141        Self::AbandonedAtLoopExit,
142        Self::Environment,
143        Self::Unknown,
144    ];
145
146    /// Whether a rejection in this category is RECOVERABLE by the model on its
147    /// own — i.e. the call failed because of a fixable slip (bad/missing
148    /// arguments, malformed tool name) and re-issuing it *with the correction*
149    /// is the right next move. Distinguished from a true policy/permission
150    /// denial, where the model must NOT retry and should pivot or ask. Used by
151    /// the dispatch primitive to pick a retry-positive vs. don't-retry feedback
152    /// body for the model-facing tool result.
153    pub fn is_recoverable(self) -> bool {
154        matches!(self, Self::SchemaValidation)
155    }
156
157    pub fn as_str(self) -> &'static str {
158        match self {
159            Self::SchemaValidation => "schema_validation",
160            Self::ToolError => "tool_error",
161            Self::McpServerError => "mcp_server_error",
162            Self::HostBridgeError => "host_bridge_error",
163            Self::PermissionDenied => "permission_denied",
164            Self::RejectedLoop => "rejected_loop",
165            Self::ParseAborted => "parse_aborted",
166            Self::Timeout => "timeout",
167            Self::Network => "network",
168            Self::ResourceBusy => "resource_busy",
169            Self::Cancelled => "cancelled",
170            Self::AbandonedAtLoopExit => "abandoned_at_loop_exit",
171            Self::Environment => "environment",
172            Self::Unknown => "unknown",
173        }
174    }
175
176    /// Map an internal `ErrorCategory` (used by the VM's `VmError`
177    /// classification) onto the wire enum. The internal taxonomy is
178    /// finer-grained — several transient categories collapse onto
179    /// `Network`, and the auth/quota family becomes `HostBridgeError`
180    /// because at the tool-dispatch boundary those errors come from
181    /// the bridge transport rather than the tool itself.
182    pub fn from_internal(category: &crate::value::ErrorCategory) -> Self {
183        use crate::value::ErrorCategory as Internal;
184        match category {
185            Internal::Timeout => Self::Timeout,
186            Internal::RateLimit
187            | Internal::Overloaded
188            | Internal::ServerError
189            | Internal::TransientNetwork => Self::Network,
190            Internal::ResourceBusy => Self::ResourceBusy,
191            Internal::SchemaIncompatible => Self::HostBridgeError,
192            Internal::SchemaValidation | Internal::SchemaStreamAborted => Self::SchemaValidation,
193            Internal::ToolError => Self::ToolError,
194            Internal::ToolRejected => Self::PermissionDenied,
195            Internal::Cancelled => Self::Cancelled,
196            // A machine-provisioning gap. It reaches the wire under its own
197            // name because "widen the sandbox / install the toolchain" is a
198            // different instruction from every other bucket here, and folding
199            // it into `HostBridgeError` left hosts unable to give it (#5537).
200            Internal::Environment => Self::Environment,
201            // Blocked outbound egress is host CONFIGURATION the operator chose,
202            // so it belongs with the environment family for the same reason:
203            // the fix is to widen the policy, not to change the agent's work.
204            Internal::EgressBlocked => Self::Environment,
205            Internal::Auth
206            | Internal::ChannelClosed
207            | Internal::NotFound
208            | Internal::CircuitOpen
209            | Internal::BudgetExceeded
210            // An internal engine/wiring bug is a host-side failure, not the
211            // tool's fault; it normally propagates out of the loop, but if one
212            // is ever recorded as a tool event, `HostBridgeError` is the honest
213            // wire bucket.
214            | Internal::Internal
215            | Internal::Generic => Self::HostBridgeError,
216        }
217    }
218}
219
220/// Which gate refused a tool call. Pairs with [`ToolDenial`] so host
221/// harnesses can distinguish a hard capability/policy ceiling (terminal —
222/// retrying the identical call can never succeed) from a user/host
223/// approval rejection, without re-parsing the human-readable reason
224/// string (harn#2780).
225#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum DenialGate {
228    /// The tool is not in the policy's allowed-tool list.
229    ToolCeiling,
230    /// The model emitted Harn text-tool wrapper syntax as a native/provider
231    /// tool name or arguments payload, so dispatch refused the wrapper while
232    /// coaching a direct re-issue of the embedded tool call.
233    MalformedToolWrapper,
234    /// The tool requires a capability/operation the policy does not grant
235    /// (e.g. `workspace.write_text`, `process.exec`).
236    CapabilityCeiling,
237    /// The tool's side-effect level exceeds the policy ceiling
238    /// (e.g. a `process_exec` tool under a `read_only` policy).
239    SideEffectCeiling,
240    /// A `tool_arg_constraint` allow-list rejected the resolved argument
241    /// value (e.g. a `command` that does not match `cargo *`).
242    ArgConstraint,
243    /// A dynamic permission rule (`when`/`unless` predicate) denied the
244    /// call.
245    DynamicPermission,
246    /// A static approval policy decided `deny`.
247    ApprovalPolicy,
248    /// Approval was required (`ask`) but could not be requested because no
249    /// host bridge was available or the request transport failed.
250    ApprovalUnavailable,
251    /// The host/user rejected an approval request (`session/request_permission`).
252    HostRejected,
253    /// A registered pre-tool hook returned `deny`.
254    HookDeny,
255    /// An embedder-registered deterministic precheck refused the call before
256    /// any approval prompt was emitted, so a predetermined-denied call never
257    /// asks the human (harn pre-approval deny seam).
258    DeterministicPrecheck,
259    /// Gate could not be classified.
260    #[default]
261    Unknown,
262}
263
264impl DenialGate {
265    pub const ALL: [Self; 12] = [
266        Self::ToolCeiling,
267        Self::MalformedToolWrapper,
268        Self::CapabilityCeiling,
269        Self::SideEffectCeiling,
270        Self::ArgConstraint,
271        Self::DynamicPermission,
272        Self::ApprovalPolicy,
273        Self::ApprovalUnavailable,
274        Self::HostRejected,
275        Self::HookDeny,
276        Self::DeterministicPrecheck,
277        Self::Unknown,
278    ];
279
280    pub fn as_str(self) -> &'static str {
281        match self {
282            Self::ToolCeiling => "tool_ceiling",
283            Self::MalformedToolWrapper => "malformed_tool_wrapper",
284            Self::CapabilityCeiling => "capability_ceiling",
285            Self::SideEffectCeiling => "side_effect_ceiling",
286            Self::ArgConstraint => "arg_constraint",
287            Self::DynamicPermission => "dynamic_permission",
288            Self::ApprovalPolicy => "approval_policy",
289            Self::ApprovalUnavailable => "approval_unavailable",
290            Self::HostRejected => "host_rejected",
291            Self::HookDeny => "hook_deny",
292            Self::DeterministicPrecheck => "deterministic_precheck",
293            Self::Unknown => "unknown",
294        }
295    }
296
297    /// Stable model-facing signature for this refusal class. The signature is
298    /// deliberately owned beside the typed gate so a denial's structured and
299    /// rendered projections cannot name different gates.
300    fn reason_prefix(self) -> &'static str {
301        match self {
302            Self::ToolCeiling => "Tool ceiling denial",
303            Self::MalformedToolWrapper => "Malformed tool wrapper denial",
304            Self::CapabilityCeiling => "Capability ceiling denial",
305            Self::SideEffectCeiling => "Side-effect ceiling denial",
306            Self::ArgConstraint => "Tool argument constraint denial",
307            Self::DynamicPermission => "Dynamic permission denial",
308            Self::ApprovalPolicy => "Approval policy denial",
309            Self::ApprovalUnavailable => "Approval unavailable denial",
310            Self::HostRejected => "Host rejection denial",
311            Self::HookDeny => "Pre-tool hook denial",
312            Self::DeterministicPrecheck => "Deterministic precheck denial",
313            Self::Unknown => "Unclassified tool denial",
314        }
315    }
316
317    /// Render gate-specific particulars under this gate's stable signature.
318    ///
319    /// Already-rendered text from the same gate is accepted so an owning
320    /// boundary can pass a denial through without double-prefixing it. Text
321    /// carrying any other gate's signature is a producer bug.
322    pub fn render_reason(self, particulars: impl Into<String>) -> String {
323        let particulars = particulars.into();
324        if self.owns_reason(&particulars) {
325            return particulars;
326        }
327        debug_assert!(
328            !Self::ALL
329                .iter()
330                .copied()
331                .any(|gate| gate.has_signature(&particulars)),
332            "{} denial particulars carry another gate signature: {particulars}",
333            self.as_str(),
334        );
335        let particulars = particulars.trim();
336        if particulars.is_empty() {
337            format!("{}: no further details were provided", self.reason_prefix())
338        } else {
339            format!("{}: {particulars}", self.reason_prefix())
340        }
341    }
342
343    fn has_signature(self, reason: &str) -> bool {
344        reason.contains(&format!("{}:", self.reason_prefix()))
345    }
346
347    pub(crate) fn owns_reason(self, reason: &str) -> bool {
348        reason.starts_with(&format!("{}:", self.reason_prefix()))
349            && !Self::ALL
350                .iter()
351                .copied()
352                .any(|gate| gate != self && gate.has_signature(reason))
353    }
354}
355
356/// The next action a host or operator can take after a side-effect ceiling
357/// blocked a tool call. This is deliberately one-shot: durable policy or
358/// credential grants have their own session-grant contract.
359#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub enum SideEffectCeilingRemedy {
362    /// An interactive host can ask the user to allow this exact call once.
363    RequestPermission,
364    /// No interactive approver is available; an operator must raise the
365    /// session's declared ceiling before the call can run.
366    RaiseSideEffectCeiling,
367}
368
369/// Typed facts for a [`DenialGate::SideEffectCeiling`] refusal. Keeping this
370/// beside the denied tool result makes the cause actionable without parsing
371/// the human-readable error string.
372#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub struct SideEffectCeilingDetails {
375    /// The active policy ceiling that blocked the call.
376    pub ceiling: SideEffectLevel,
377    /// The side-effect level declared by the requested tool.
378    pub required_level: SideEffectLevel,
379    /// The tool whose declared effect exceeded the ceiling.
380    pub tool: String,
381    /// The only supported path forward for this denial.
382    pub remedy: SideEffectCeilingRemedy,
383}
384
385/// Structured record of a tool call refused at the dispatch boundary —
386/// by a capability/policy ceiling, an argument allow-list, a permission
387/// rule, an approval decision, or a pre-tool hook. Carried on the denied
388/// `tool_result` and the `PermissionDeny` transcript event so host
389/// harnesses (and the loop's own stall detector) can fail or pivot early
390/// without re-parsing human-readable command output (harn#2780). The
391/// `denied_paths` field captures any workspace paths the refused call
392/// declared, so a path-scoped denial names the offending path.
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub struct ToolDenial {
395    /// Which gate refused the call.
396    pub gate: DenialGate,
397    /// Capability/operation that was exceeded, e.g. `workspace.read_text`
398    /// or `process.exec`, when the gate identified one.
399    pub capability: Option<String>,
400    /// Workspace paths the denied call declared, when the tool annotates
401    /// path arguments. Empty for tools that declare no paths.
402    pub denied_paths: Vec<String>,
403    /// Whether re-issuing the identical call could ever succeed. Capability
404    /// and side-effect ceilings, argument allow-lists, and policy/approval
405    /// denials are terminal (`false`); a host harness should fail or pivot
406    /// rather than spend another model call retrying.
407    pub retryable: bool,
408    /// Human-readable explanation — the same text the model sees in the
409    /// tool result.
410    pub reason: String,
411    /// Stable terminal-denial class for gates that should suppress argument
412    /// churn across equivalent call variants in one run.
413    pub denial_class: Option<String>,
414    /// One-based count for this terminal-denial class within the session.
415    pub class_repeat_count: Option<u64>,
416    /// Typed side-effect facts when the denied call exceeded the active
417    /// ceiling. Other denial gates omit this field.
418    pub side_effect_ceiling: Option<SideEffectCeilingDetails>,
419    /// Machine-facing refusal fact — a stable, secret-free reason (e.g. the
420    /// matched policy pattern) for audit records and structured logs. Distinct
421    /// from `reason`, which is the model-facing text. Set by gates that split
422    /// their refusal by audience (the deterministic pre-approval precheck);
423    /// omitted otherwise.
424    pub machine_reason: Option<String>,
425    /// One plain sentence for a human reading an approval/denial surface, with
426    /// no model-teaching prose. Set by audience-splitting gates; omitted
427    /// otherwise, in which case an embedder falls back to `reason`.
428    pub human_summary: Option<String>,
429}
430
431impl ToolDenial {
432    /// Build a terminal denial (`retryable: false`) with no declared paths
433    /// attached yet. Every gate Harn currently enforces is terminal —
434    /// re-issuing the identical call can never succeed — so the constructor
435    /// hard-codes `retryable: false`; the field exists so a future soft
436    /// denial can set it `true`. Callers at the dispatch boundary enrich
437    /// `denied_paths` from the tool's annotated path arguments.
438    pub fn terminal(
439        gate: DenialGate,
440        capability: Option<String>,
441        particulars: impl Into<String>,
442    ) -> Self {
443        Self {
444            gate,
445            capability,
446            denied_paths: Vec::new(),
447            retryable: false,
448            reason: gate.render_reason(particulars),
449            denial_class: None,
450            class_repeat_count: None,
451            side_effect_ceiling: None,
452            machine_reason: None,
453            human_summary: None,
454        }
455    }
456
457    /// Build a SOFT denial (`retryable: true`): the call was refused for *this*
458    /// argument, but re-issuing it with a corrected argument can succeed — so
459    /// the model should be coached to retry with the correction rather than told
460    /// to give up. Used for the argument allow-list gate (`ArgConstraint`),
461    /// where a path/command outside the allowed scope is a fixable slip, not a
462    /// hard capability ceiling. The dispatch boundary routes a retryable denial
463    /// through the recoverable (retry-positive) tool-result body.
464    pub fn retryable(
465        gate: DenialGate,
466        capability: Option<String>,
467        particulars: impl Into<String>,
468    ) -> Self {
469        Self {
470            gate,
471            capability,
472            denied_paths: Vec::new(),
473            retryable: true,
474            reason: gate.render_reason(particulars),
475            denial_class: None,
476            class_repeat_count: None,
477            side_effect_ceiling: None,
478            machine_reason: None,
479            human_summary: None,
480        }
481    }
482
483    /// Replace every gate-owned projection when a dispatch denial is
484    /// reclassified. Context that is independent of the refusal class
485    /// (`denied_paths`) remains attached.
486    pub(crate) fn reclassify(
487        mut self,
488        gate: DenialGate,
489        capability: Option<String>,
490        retryable: bool,
491        particulars: impl Into<String>,
492    ) -> Self {
493        self.gate = gate;
494        self.capability = capability;
495        self.retryable = retryable;
496        self.reason = gate.render_reason(particulars);
497        self.denial_class = None;
498        self.class_repeat_count = None;
499        self.side_effect_ceiling = None;
500        self.machine_reason = None;
501        self.human_summary = None;
502        self
503    }
504
505    pub fn with_denial_class(mut self, denial_class: impl Into<String>, repeat_count: u64) -> Self {
506        self.denial_class = Some(denial_class.into());
507        self.class_repeat_count = Some(repeat_count);
508        self
509    }
510
511    /// Attach the typed cause of a side-effect ceiling denial. The dispatch
512    /// boundary chooses the remedy because only it knows whether an
513    /// interactive ACP host is available.
514    pub fn with_side_effect_ceiling(mut self, details: SideEffectCeilingDetails) -> Self {
515        self.side_effect_ceiling = Some(details);
516        self
517    }
518
519    /// Attach machine- and human-facing renderings of this refusal so an
520    /// embedder can surface the right text per audience without re-parsing the
521    /// model-facing `reason`. Either field may be `None`, in which case the
522    /// embedder falls back to `reason`.
523    pub fn with_audiences(
524        mut self,
525        machine_reason: Option<String>,
526        human_summary: Option<String>,
527    ) -> Self {
528        self.machine_reason = machine_reason;
529        self.human_summary = human_summary;
530        self
531    }
532
533    pub fn to_json(&self) -> serde_json::Value {
534        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
535    }
536}
537
538impl Default for ToolDenial {
539    fn default() -> Self {
540        Self::terminal(DenialGate::Unknown, None, "")
541    }
542}
543
544impl Serialize for ToolDenial {
545    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
546    where
547        S: serde::Serializer,
548    {
549        use serde::ser::{Error, SerializeStruct};
550
551        if !self.gate.owns_reason(&self.reason) {
552            return Err(S::Error::custom(format!(
553                "{} denial carries unattributable reason text: {}",
554                self.gate.as_str(),
555                self.reason,
556            )));
557        }
558        let mut field_count = 3;
559        field_count += usize::from(self.capability.is_some());
560        field_count += usize::from(!self.denied_paths.is_empty());
561        field_count += usize::from(self.denial_class.is_some());
562        field_count += usize::from(self.class_repeat_count.is_some());
563        field_count += usize::from(self.side_effect_ceiling.is_some());
564        field_count += usize::from(self.machine_reason.is_some());
565        field_count += usize::from(self.human_summary.is_some());
566        let mut record = serializer.serialize_struct("ToolDenial", field_count)?;
567        record.serialize_field("gate", &self.gate)?;
568        if let Some(capability) = &self.capability {
569            record.serialize_field("capability", capability)?;
570        }
571        if !self.denied_paths.is_empty() {
572            record.serialize_field("denied_paths", &self.denied_paths)?;
573        }
574        record.serialize_field("retryable", &self.retryable)?;
575        record.serialize_field("reason", &self.reason)?;
576        if let Some(denial_class) = &self.denial_class {
577            record.serialize_field("denial_class", denial_class)?;
578        }
579        if let Some(class_repeat_count) = self.class_repeat_count {
580            record.serialize_field("class_repeat_count", &class_repeat_count)?;
581        }
582        if let Some(details) = &self.side_effect_ceiling {
583            record.serialize_field("side_effect_ceiling", details)?;
584        }
585        if let Some(machine_reason) = &self.machine_reason {
586            record.serialize_field("machine_reason", machine_reason)?;
587        }
588        if let Some(human_summary) = &self.human_summary {
589            record.serialize_field("human_summary", human_summary)?;
590        }
591        record.end()
592    }
593}
594
595#[derive(Deserialize)]
596struct ToolDenialRecord {
597    gate: DenialGate,
598    #[serde(default)]
599    capability: Option<String>,
600    #[serde(default)]
601    denied_paths: Vec<String>,
602    retryable: bool,
603    reason: String,
604    #[serde(default)]
605    denial_class: Option<String>,
606    #[serde(default)]
607    class_repeat_count: Option<u64>,
608    #[serde(default)]
609    side_effect_ceiling: Option<SideEffectCeilingDetails>,
610    #[serde(default)]
611    machine_reason: Option<String>,
612    #[serde(default)]
613    human_summary: Option<String>,
614}
615
616impl<'de> Deserialize<'de> for ToolDenial {
617    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
618    where
619        D: serde::Deserializer<'de>,
620    {
621        use serde::de::Error;
622
623        let record = ToolDenialRecord::deserialize(deserializer)?;
624        let reason = if record.gate.owns_reason(&record.reason) {
625            record.reason
626        } else {
627            if DenialGate::ALL
628                .iter()
629                .copied()
630                .any(|gate| gate.has_signature(&record.reason))
631            {
632                return Err(D::Error::custom(format!(
633                    "{} denial carries another gate's reason signature",
634                    record.gate.as_str(),
635                )));
636            }
637            // Pre-signature persisted denials remain readable and acquire the
638            // typed gate's current rendering when replayed.
639            record.gate.render_reason(record.reason)
640        };
641        Ok(Self {
642            gate: record.gate,
643            capability: record.capability,
644            denied_paths: record.denied_paths,
645            retryable: record.retryable,
646            reason,
647            denial_class: record.denial_class,
648            class_repeat_count: record.class_repeat_count,
649            side_effect_ceiling: record.side_effect_ceiling,
650            machine_reason: record.machine_reason,
651            human_summary: record.human_summary,
652        })
653    }
654}
655
656/// Where a tool actually ran. Tags `ToolCallUpdate` so clients can render
657/// "via mcp:linear" / "via host bridge" badges, attribute latency by
658/// transport, and route errors to the right surface (harn#691).
659///
660/// On the wire this serializes adjacently-tagged so the `mcp_server`
661/// case carries the configured server name. The ACP adapter rewrites
662/// unit variants as bare strings (`"harn_builtin"`, `"host_bridge"`,
663/// `"provider_native"`) and the `McpServer` case as
664/// `{"kind": "mcp_server", "serverName": "..."}` to match the protocol's
665/// camelCase convention.
666#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
667#[serde(tag = "kind", rename_all = "snake_case")]
668pub enum ToolExecutor {
669    /// VM-stdlib (`read_file`, `write_file`, `exec`, `http_*`, `mcp_*`)
670    /// or any Harn-side handler closure registered in `tools_val`.
671    HarnBuiltin,
672    /// Capability provided by the host through `HostBridge.builtin_call`
673    /// (host IDE bridge and CLI host shells).
674    HostBridge,
675    /// Tool dispatched against a configured MCP server. Detected by the
676    /// `_mcp_server` tag that `mcp_list_tools` injects on every tool
677    /// dict before the agent loop sees it.
678    McpServer { server_name: String },
679    /// Provider-side server-side tool execution — currently OpenAI
680    /// Responses-API server tools (e.g. native `tool_search`). The
681    /// runtime never dispatches these locally; the model returns the
682    /// already-executed result inline.
683    ProviderNative,
684}