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::SchemaValidation | Internal::SchemaStreamAborted => Self::SchemaValidation,
192            Internal::ToolError => Self::ToolError,
193            Internal::ToolRejected => Self::PermissionDenied,
194            Internal::Cancelled => Self::Cancelled,
195            // A machine-provisioning gap. It reaches the wire under its own
196            // name because "widen the sandbox / install the toolchain" is a
197            // different instruction from every other bucket here, and folding
198            // it into `HostBridgeError` left hosts unable to give it (#5537).
199            Internal::Environment => Self::Environment,
200            // Blocked outbound egress is host CONFIGURATION the operator chose,
201            // so it belongs with the environment family for the same reason:
202            // the fix is to widen the policy, not to change the agent's work.
203            Internal::EgressBlocked => Self::Environment,
204            Internal::Auth
205            | Internal::ChannelClosed
206            | Internal::NotFound
207            | Internal::CircuitOpen
208            | Internal::BudgetExceeded
209            // An internal engine/wiring bug is a host-side failure, not the
210            // tool's fault; it normally propagates out of the loop, but if one
211            // is ever recorded as a tool event, `HostBridgeError` is the honest
212            // wire bucket.
213            | Internal::Internal
214            | Internal::Generic => Self::HostBridgeError,
215        }
216    }
217}
218
219/// Which gate refused a tool call. Pairs with [`ToolDenial`] so host
220/// harnesses can distinguish a hard capability/policy ceiling (terminal —
221/// retrying the identical call can never succeed) from a user/host
222/// approval rejection, without re-parsing the human-readable reason
223/// string (harn#2780).
224#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum DenialGate {
227    /// The tool is not in the policy's allowed-tool list.
228    ToolCeiling,
229    /// The model emitted Harn text-tool wrapper syntax as a native/provider
230    /// tool name or arguments payload, so dispatch refused the wrapper while
231    /// coaching a direct re-issue of the embedded tool call.
232    MalformedToolWrapper,
233    /// The tool requires a capability/operation the policy does not grant
234    /// (e.g. `workspace.write_text`, `process.exec`).
235    CapabilityCeiling,
236    /// The tool's side-effect level exceeds the policy ceiling
237    /// (e.g. a `process_exec` tool under a `read_only` policy).
238    SideEffectCeiling,
239    /// A `tool_arg_constraint` allow-list rejected the resolved argument
240    /// value (e.g. a `command` that does not match `cargo *`).
241    ArgConstraint,
242    /// A dynamic permission rule (`when`/`unless` predicate) denied the
243    /// call.
244    DynamicPermission,
245    /// A static approval policy decided `deny`.
246    ApprovalPolicy,
247    /// Approval was required (`ask`) but could not be requested because no
248    /// host bridge was available or the request transport failed.
249    ApprovalUnavailable,
250    /// The host/user rejected an approval request (`session/request_permission`).
251    HostRejected,
252    /// A registered pre-tool hook returned `deny`.
253    HookDeny,
254    /// An embedder-registered deterministic precheck refused the call before
255    /// any approval prompt was emitted, so a predetermined-denied call never
256    /// asks the human (harn pre-approval deny seam).
257    DeterministicPrecheck,
258    /// Gate could not be classified.
259    #[default]
260    Unknown,
261}
262
263impl DenialGate {
264    pub const ALL: [Self; 12] = [
265        Self::ToolCeiling,
266        Self::MalformedToolWrapper,
267        Self::CapabilityCeiling,
268        Self::SideEffectCeiling,
269        Self::ArgConstraint,
270        Self::DynamicPermission,
271        Self::ApprovalPolicy,
272        Self::ApprovalUnavailable,
273        Self::HostRejected,
274        Self::HookDeny,
275        Self::DeterministicPrecheck,
276        Self::Unknown,
277    ];
278
279    pub fn as_str(self) -> &'static str {
280        match self {
281            Self::ToolCeiling => "tool_ceiling",
282            Self::MalformedToolWrapper => "malformed_tool_wrapper",
283            Self::CapabilityCeiling => "capability_ceiling",
284            Self::SideEffectCeiling => "side_effect_ceiling",
285            Self::ArgConstraint => "arg_constraint",
286            Self::DynamicPermission => "dynamic_permission",
287            Self::ApprovalPolicy => "approval_policy",
288            Self::ApprovalUnavailable => "approval_unavailable",
289            Self::HostRejected => "host_rejected",
290            Self::HookDeny => "hook_deny",
291            Self::DeterministicPrecheck => "deterministic_precheck",
292            Self::Unknown => "unknown",
293        }
294    }
295
296    /// Stable model-facing signature for this refusal class. The signature is
297    /// deliberately owned beside the typed gate so a denial's structured and
298    /// rendered projections cannot name different gates.
299    fn reason_prefix(self) -> &'static str {
300        match self {
301            Self::ToolCeiling => "Tool ceiling denial",
302            Self::MalformedToolWrapper => "Malformed tool wrapper denial",
303            Self::CapabilityCeiling => "Capability ceiling denial",
304            Self::SideEffectCeiling => "Side-effect ceiling denial",
305            Self::ArgConstraint => "Tool argument constraint denial",
306            Self::DynamicPermission => "Dynamic permission denial",
307            Self::ApprovalPolicy => "Approval policy denial",
308            Self::ApprovalUnavailable => "Approval unavailable denial",
309            Self::HostRejected => "Host rejection denial",
310            Self::HookDeny => "Pre-tool hook denial",
311            Self::DeterministicPrecheck => "Deterministic precheck denial",
312            Self::Unknown => "Unclassified tool denial",
313        }
314    }
315
316    /// Render gate-specific particulars under this gate's stable signature.
317    ///
318    /// Already-rendered text from the same gate is accepted so an owning
319    /// boundary can pass a denial through without double-prefixing it. Text
320    /// carrying any other gate's signature is a producer bug.
321    pub fn render_reason(self, particulars: impl Into<String>) -> String {
322        let particulars = particulars.into();
323        if self.owns_reason(&particulars) {
324            return particulars;
325        }
326        debug_assert!(
327            !Self::ALL
328                .iter()
329                .copied()
330                .any(|gate| gate.has_signature(&particulars)),
331            "{} denial particulars carry another gate signature: {particulars}",
332            self.as_str(),
333        );
334        let particulars = particulars.trim();
335        if particulars.is_empty() {
336            format!("{}: no further details were provided", self.reason_prefix())
337        } else {
338            format!("{}: {particulars}", self.reason_prefix())
339        }
340    }
341
342    fn has_signature(self, reason: &str) -> bool {
343        reason.contains(&format!("{}:", self.reason_prefix()))
344    }
345
346    pub(crate) fn owns_reason(self, reason: &str) -> bool {
347        reason.starts_with(&format!("{}:", self.reason_prefix()))
348            && !Self::ALL
349                .iter()
350                .copied()
351                .any(|gate| gate != self && gate.has_signature(reason))
352    }
353}
354
355/// The next action a host or operator can take after a side-effect ceiling
356/// blocked a tool call. This is deliberately one-shot: durable policy or
357/// credential grants have their own session-grant contract.
358#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
359#[serde(rename_all = "snake_case")]
360pub enum SideEffectCeilingRemedy {
361    /// An interactive host can ask the user to allow this exact call once.
362    RequestPermission,
363    /// No interactive approver is available; an operator must raise the
364    /// session's declared ceiling before the call can run.
365    RaiseSideEffectCeiling,
366}
367
368/// Typed facts for a [`DenialGate::SideEffectCeiling`] refusal. Keeping this
369/// beside the denied tool result makes the cause actionable without parsing
370/// the human-readable error string.
371#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub struct SideEffectCeilingDetails {
374    /// The active policy ceiling that blocked the call.
375    pub ceiling: SideEffectLevel,
376    /// The side-effect level declared by the requested tool.
377    pub required_level: SideEffectLevel,
378    /// The tool whose declared effect exceeded the ceiling.
379    pub tool: String,
380    /// The only supported path forward for this denial.
381    pub remedy: SideEffectCeilingRemedy,
382}
383
384/// Structured record of a tool call refused at the dispatch boundary —
385/// by a capability/policy ceiling, an argument allow-list, a permission
386/// rule, an approval decision, or a pre-tool hook. Carried on the denied
387/// `tool_result` and the `PermissionDeny` transcript event so host
388/// harnesses (and the loop's own stall detector) can fail or pivot early
389/// without re-parsing human-readable command output (harn#2780). The
390/// `denied_paths` field captures any workspace paths the refused call
391/// declared, so a path-scoped denial names the offending path.
392#[derive(Clone, Debug, PartialEq, Eq)]
393pub struct ToolDenial {
394    /// Which gate refused the call.
395    pub gate: DenialGate,
396    /// Capability/operation that was exceeded, e.g. `workspace.read_text`
397    /// or `process.exec`, when the gate identified one.
398    pub capability: Option<String>,
399    /// Workspace paths the denied call declared, when the tool annotates
400    /// path arguments. Empty for tools that declare no paths.
401    pub denied_paths: Vec<String>,
402    /// Whether re-issuing the identical call could ever succeed. Capability
403    /// and side-effect ceilings, argument allow-lists, and policy/approval
404    /// denials are terminal (`false`); a host harness should fail or pivot
405    /// rather than spend another model call retrying.
406    pub retryable: bool,
407    /// Human-readable explanation — the same text the model sees in the
408    /// tool result.
409    pub reason: String,
410    /// Stable terminal-denial class for gates that should suppress argument
411    /// churn across equivalent call variants in one run.
412    pub denial_class: Option<String>,
413    /// One-based count for this terminal-denial class within the session.
414    pub class_repeat_count: Option<u64>,
415    /// Typed side-effect facts when the denied call exceeded the active
416    /// ceiling. Other denial gates omit this field.
417    pub side_effect_ceiling: Option<SideEffectCeilingDetails>,
418    /// Machine-facing refusal fact — a stable, secret-free reason (e.g. the
419    /// matched policy pattern) for audit records and structured logs. Distinct
420    /// from `reason`, which is the model-facing text. Set by gates that split
421    /// their refusal by audience (the deterministic pre-approval precheck);
422    /// omitted otherwise.
423    pub machine_reason: Option<String>,
424    /// One plain sentence for a human reading an approval/denial surface, with
425    /// no model-teaching prose. Set by audience-splitting gates; omitted
426    /// otherwise, in which case an embedder falls back to `reason`.
427    pub human_summary: Option<String>,
428}
429
430impl ToolDenial {
431    /// Build a terminal denial (`retryable: false`) with no declared paths
432    /// attached yet. Every gate Harn currently enforces is terminal —
433    /// re-issuing the identical call can never succeed — so the constructor
434    /// hard-codes `retryable: false`; the field exists so a future soft
435    /// denial can set it `true`. Callers at the dispatch boundary enrich
436    /// `denied_paths` from the tool's annotated path arguments.
437    pub fn terminal(
438        gate: DenialGate,
439        capability: Option<String>,
440        particulars: impl Into<String>,
441    ) -> Self {
442        Self {
443            gate,
444            capability,
445            denied_paths: Vec::new(),
446            retryable: false,
447            reason: gate.render_reason(particulars),
448            denial_class: None,
449            class_repeat_count: None,
450            side_effect_ceiling: None,
451            machine_reason: None,
452            human_summary: None,
453        }
454    }
455
456    /// Build a SOFT denial (`retryable: true`): the call was refused for *this*
457    /// argument, but re-issuing it with a corrected argument can succeed — so
458    /// the model should be coached to retry with the correction rather than told
459    /// to give up. Used for the argument allow-list gate (`ArgConstraint`),
460    /// where a path/command outside the allowed scope is a fixable slip, not a
461    /// hard capability ceiling. The dispatch boundary routes a retryable denial
462    /// through the recoverable (retry-positive) tool-result body.
463    pub fn retryable(
464        gate: DenialGate,
465        capability: Option<String>,
466        particulars: impl Into<String>,
467    ) -> Self {
468        Self {
469            gate,
470            capability,
471            denied_paths: Vec::new(),
472            retryable: true,
473            reason: gate.render_reason(particulars),
474            denial_class: None,
475            class_repeat_count: None,
476            side_effect_ceiling: None,
477            machine_reason: None,
478            human_summary: None,
479        }
480    }
481
482    /// Replace every gate-owned projection when a dispatch denial is
483    /// reclassified. Context that is independent of the refusal class
484    /// (`denied_paths`) remains attached.
485    pub(crate) fn reclassify(
486        mut self,
487        gate: DenialGate,
488        capability: Option<String>,
489        retryable: bool,
490        particulars: impl Into<String>,
491    ) -> Self {
492        self.gate = gate;
493        self.capability = capability;
494        self.retryable = retryable;
495        self.reason = gate.render_reason(particulars);
496        self.denial_class = None;
497        self.class_repeat_count = None;
498        self.side_effect_ceiling = None;
499        self.machine_reason = None;
500        self.human_summary = None;
501        self
502    }
503
504    pub fn with_denial_class(mut self, denial_class: impl Into<String>, repeat_count: u64) -> Self {
505        self.denial_class = Some(denial_class.into());
506        self.class_repeat_count = Some(repeat_count);
507        self
508    }
509
510    /// Attach the typed cause of a side-effect ceiling denial. The dispatch
511    /// boundary chooses the remedy because only it knows whether an
512    /// interactive ACP host is available.
513    pub fn with_side_effect_ceiling(mut self, details: SideEffectCeilingDetails) -> Self {
514        self.side_effect_ceiling = Some(details);
515        self
516    }
517
518    /// Attach machine- and human-facing renderings of this refusal so an
519    /// embedder can surface the right text per audience without re-parsing the
520    /// model-facing `reason`. Either field may be `None`, in which case the
521    /// embedder falls back to `reason`.
522    pub fn with_audiences(
523        mut self,
524        machine_reason: Option<String>,
525        human_summary: Option<String>,
526    ) -> Self {
527        self.machine_reason = machine_reason;
528        self.human_summary = human_summary;
529        self
530    }
531
532    pub fn to_json(&self) -> serde_json::Value {
533        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
534    }
535}
536
537impl Default for ToolDenial {
538    fn default() -> Self {
539        Self::terminal(DenialGate::Unknown, None, "")
540    }
541}
542
543impl Serialize for ToolDenial {
544    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
545    where
546        S: serde::Serializer,
547    {
548        use serde::ser::{Error, SerializeStruct};
549
550        if !self.gate.owns_reason(&self.reason) {
551            return Err(S::Error::custom(format!(
552                "{} denial carries unattributable reason text: {}",
553                self.gate.as_str(),
554                self.reason,
555            )));
556        }
557        let mut field_count = 3;
558        field_count += usize::from(self.capability.is_some());
559        field_count += usize::from(!self.denied_paths.is_empty());
560        field_count += usize::from(self.denial_class.is_some());
561        field_count += usize::from(self.class_repeat_count.is_some());
562        field_count += usize::from(self.side_effect_ceiling.is_some());
563        field_count += usize::from(self.machine_reason.is_some());
564        field_count += usize::from(self.human_summary.is_some());
565        let mut record = serializer.serialize_struct("ToolDenial", field_count)?;
566        record.serialize_field("gate", &self.gate)?;
567        if let Some(capability) = &self.capability {
568            record.serialize_field("capability", capability)?;
569        }
570        if !self.denied_paths.is_empty() {
571            record.serialize_field("denied_paths", &self.denied_paths)?;
572        }
573        record.serialize_field("retryable", &self.retryable)?;
574        record.serialize_field("reason", &self.reason)?;
575        if let Some(denial_class) = &self.denial_class {
576            record.serialize_field("denial_class", denial_class)?;
577        }
578        if let Some(class_repeat_count) = self.class_repeat_count {
579            record.serialize_field("class_repeat_count", &class_repeat_count)?;
580        }
581        if let Some(details) = &self.side_effect_ceiling {
582            record.serialize_field("side_effect_ceiling", details)?;
583        }
584        if let Some(machine_reason) = &self.machine_reason {
585            record.serialize_field("machine_reason", machine_reason)?;
586        }
587        if let Some(human_summary) = &self.human_summary {
588            record.serialize_field("human_summary", human_summary)?;
589        }
590        record.end()
591    }
592}
593
594#[derive(Deserialize)]
595struct ToolDenialRecord {
596    gate: DenialGate,
597    #[serde(default)]
598    capability: Option<String>,
599    #[serde(default)]
600    denied_paths: Vec<String>,
601    retryable: bool,
602    reason: String,
603    #[serde(default)]
604    denial_class: Option<String>,
605    #[serde(default)]
606    class_repeat_count: Option<u64>,
607    #[serde(default)]
608    side_effect_ceiling: Option<SideEffectCeilingDetails>,
609    #[serde(default)]
610    machine_reason: Option<String>,
611    #[serde(default)]
612    human_summary: Option<String>,
613}
614
615impl<'de> Deserialize<'de> for ToolDenial {
616    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
617    where
618        D: serde::Deserializer<'de>,
619    {
620        use serde::de::Error;
621
622        let record = ToolDenialRecord::deserialize(deserializer)?;
623        let reason = if record.gate.owns_reason(&record.reason) {
624            record.reason
625        } else {
626            if DenialGate::ALL
627                .iter()
628                .copied()
629                .any(|gate| gate.has_signature(&record.reason))
630            {
631                return Err(D::Error::custom(format!(
632                    "{} denial carries another gate's reason signature",
633                    record.gate.as_str(),
634                )));
635            }
636            // Pre-signature persisted denials remain readable and acquire the
637            // typed gate's current rendering when replayed.
638            record.gate.render_reason(record.reason)
639        };
640        Ok(Self {
641            gate: record.gate,
642            capability: record.capability,
643            denied_paths: record.denied_paths,
644            retryable: record.retryable,
645            reason,
646            denial_class: record.denial_class,
647            class_repeat_count: record.class_repeat_count,
648            side_effect_ceiling: record.side_effect_ceiling,
649            machine_reason: record.machine_reason,
650            human_summary: record.human_summary,
651        })
652    }
653}
654
655/// Where a tool actually ran. Tags `ToolCallUpdate` so clients can render
656/// "via mcp:linear" / "via host bridge" badges, attribute latency by
657/// transport, and route errors to the right surface (harn#691).
658///
659/// On the wire this serializes adjacently-tagged so the `mcp_server`
660/// case carries the configured server name. The ACP adapter rewrites
661/// unit variants as bare strings (`"harn_builtin"`, `"host_bridge"`,
662/// `"provider_native"`) and the `McpServer` case as
663/// `{"kind": "mcp_server", "serverName": "..."}` to match the protocol's
664/// camelCase convention.
665#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
666#[serde(tag = "kind", rename_all = "snake_case")]
667pub enum ToolExecutor {
668    /// VM-stdlib (`read_file`, `write_file`, `exec`, `http_*`, `mcp_*`)
669    /// or any Harn-side handler closure registered in `tools_val`.
670    HarnBuiltin,
671    /// Capability provided by the host through `HostBridge.builtin_call`
672    /// (host IDE bridge and CLI host shells).
673    HostBridge,
674    /// Tool dispatched against a configured MCP server. Detected by the
675    /// `_mcp_server` tag that `mcp_list_tools` injects on every tool
676    /// dict before the agent loop sees it.
677    McpServer { server_name: String },
678    /// Provider-side server-side tool execution — currently OpenAI
679    /// Responses-API server tools (e.g. native `tool_search`). The
680    /// runtime never dispatches these locally; the model returns the
681    /// already-executed result inline.
682    ProviderNative,
683}