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}