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}