polyc_agent/step.rs
1//! The `TurnStep` seam: a small, ordered set of composable steps the turn loop
2//! drives, each owning one cohesive slice of turn behavior.
3//!
4//! Slice 2 of #649 introduces the seam and proves it by migrating exactly one
5//! behavior out of the turn-loop monolith — the forced closing completion. The
6//! turn function's post-loop tail is now a driver over a list of
7//! [`TurnStep`]s; later slices migrate the remaining stanzas behind the same
8//! interface.
9//!
10//! A step reads and mutates the turn's working state through a [`TurnCtx`] and
11//! reports back with a [`StepOutcome`] (keep going, pause for a human, or end
12//! the turn early).
13
14use std::collections::{HashMap, HashSet};
15
16use async_trait::async_trait;
17use futures::SinkExt as _;
18use polyc_crypto::canon::canon_args;
19use polyc_llm::{
20 CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role, StopReason,
21 ToolSpec, Usage,
22 request::ToolCall,
23 turn::{collect_turn, collect_turn_observed},
24};
25use polyc_proto::proto::polychrome::agent::v1::Message;
26
27use crate::{
28 ApprovalOverride, CallContext, CallDisposition, HandoffRequest, PendingApproval, ResolvedCall,
29 RunTurnOptions, ToolExecutor, append_injected_notes, cap_tool_result, forced_result,
30 gate_decision, gate_missing, push_internal_note, push_reasoning, resolve_approved_call,
31 run_and_redact, session_approves, splice_results_after, text_message, tool_result_message,
32 untrusted_content_in_context,
33};
34
35/// The runtime-injected ground-truth note (`#743` change 1b) pushed after a
36/// resume executes at least one previously-approved call: model-visible
37/// (a System message in [`TurnCtx::messages`]) but never user-visible
38/// (`internal_only` in [`TurnCtx::outputs`], via [`push_internal_note`]).
39///
40/// The resume's continuation text MUST still post — the codeless invite ack,
41/// the demote confirmation, etc. are genuine narration, not a status guess —
42/// so this does not suppress anything. It structurally corrects what the
43/// model would otherwise have to infer from a bare tool result: that a person
44/// already approved the call and it already ran, so the model's job now is to
45/// report the outcome, not to describe (or re-describe) approval status. Same
46/// mechanism as the `#67` approver-injected context: runtime-supplied ground
47/// truth, not a prompt-level instruction the model could ignore as mere text.
48pub(crate) const RESUME_EXECUTED_GROUND_TRUTH_NOTE: &str = "A person approved this request and the tool has \
49 already run — the results above are final. Tell the user what happened; do not describe \
50 approval status, the system already showed it.";
51
52/// The turn's working state, threaded through each [`TurnStep`].
53///
54/// Owns what were locals in the turn function — the working transcript, the
55/// accumulated wire outputs, the folded usage, the last stop reason, and the
56/// loop-control flags — and borrows the turn's immutable inputs (the provider,
57/// tool executor, model, and options) for the lifetime `'a` so a step can dial
58/// the provider without re-plumbing them.
59// Independent working flags a step reads/sets separately; folding them into an
60// enum would force artificial combinations (a turn that executed tools also
61// produced text, and either can coexist with a fired escape hatch).
62#[allow(clippy::struct_excessive_bools)]
63pub struct TurnCtx<'a, P, T>
64where
65 P: LlmProvider + ?Sized,
66 T: ToolExecutor + ?Sized,
67{
68 /// The LLM provider the turn dials.
69 pub provider: &'a P,
70 /// The executor that advertises and runs this turn's tools.
71 pub tools: &'a T,
72 /// The model identifier for provider requests.
73 pub model: &'a str,
74 /// The options the turn was invoked with (streaming channel, decisions).
75 pub options: &'a RunTurnOptions,
76 /// The working transcript driven through the loop and any post-steps.
77 pub messages: Vec<LlmMessage>,
78 /// The wire messages produced so far — assistant text and tool results.
79 pub outputs: Vec<Message>,
80 /// Usage folded across every provider call this turn has made.
81 pub total_usage: Usage,
82 /// Stop reason of the most recent provider step.
83 pub last_stop: Option<StopReason>,
84 /// Whether any tool ran this turn (resume pre-pass or the loop).
85 pub executed_tools: bool,
86 /// Whether the model ever emitted user-visible text this turn.
87 pub produced_text: bool,
88 /// The pending handoff request, if the model asked to hand off.
89 pub pending_handoff: Option<HandoffRequest>,
90 /// STICKY/TERMINAL denials keyed to the tool *signature* (name + canonical
91 /// args) rather than the provider call-id. Once a human denies an action,
92 /// the model can re-emit the SAME logical call with a fresh call-id; a
93 /// call-id-only check would re-pause and re-prompt for something already
94 /// rejected. The resume pre-pass seeds this and the in-loop batch records
95 /// into it, so a matching re-emit is auto-denied (synthetic result) without
96 /// ever pausing again.
97 pub denied_sigs: HashSet<(String, String)>,
98 /// Approvals still awaiting execution, keyed by the canonicalized
99 /// `(id, name, args)` identity (#141). Seeded from
100 /// [`RunTurnOptions::approved_call_ids`]; the resume pre-pass removes each
101 /// entry it spends so neither the pre-pass nor the loop re-executes an
102 /// approval the model re-emits. `args` is canonicalized through `canon_args`
103 /// so a re-emit with reordered keys still matches by value.
104 pub approved_remaining: HashSet<(String, String, String)>,
105 /// How many loop iterations have resolved a signature-matched terminal
106 /// denial — the model retrying an action a human already denied. The first
107 /// signed denial (by call-id, before any signature is recorded) does not
108 /// count; only re-emits of an already-denied signature do. Persists across
109 /// iterations so the stateless [`CircuitBreaker`] step can increment it and
110 /// end the turn once it reaches `MAX_DENIAL_REPROMPTS`.
111 pub denial_reprompts: usize,
112 /// Whether the step that just resolved handled a signature-matched terminal
113 /// denial. The loop republishes it onto the ctx each iteration before the
114 /// [`CircuitBreaker`] step reads it; the step never touches the working
115 /// state.
116 pub saw_sig_match_denial: bool,
117 /// Gate clears a remembered passkey grant was solely responsible for
118 /// (`#594`), accumulated across the turn's loop iterations. Each entry is an
119 /// executed tool call that ran only because a grant kept a capability
120 /// untrusted content in context would have revoked; the final
121 /// [`TurnResult::grant_replays`](crate::TurnResult::grant_replays) carries
122 /// them out for the control plane to audit.
123 pub grant_replays: Vec<crate::GrantReplayClear>,
124 /// Gated calls an unattended turn denied fail-closed (`#623`), accumulated
125 /// across the loop iterations. Each entry is a call the capability gate would
126 /// have escalated on a turn with [`RunTurnOptions::unattended`](crate::RunTurnOptions::unattended)
127 /// set, where no live grant covered the shape; the model saw a legible denial
128 /// result and the call neither ran nor paused. The final
129 /// [`TurnResult::unattended_denials`](crate::TurnResult::unattended_denials)
130 /// carries them out for the control plane to audit. Always empty on an
131 /// attended turn.
132 pub unattended_denials: Vec<crate::UnattendedDenial>,
133 /// Whether the fuzzy-match escape hatch (`#582`, invariant 9) has fired
134 /// this turn. The hatch widens the advertised tool set at most ONCE per
135 /// turn; once set, a later call naming an unadvertised tool resolves to
136 /// the ordinary unknown-tool result again.
137 pub escape_hatch_fired: bool,
138 /// One entry per `__delegate_to` call dispatched this turn (`#872`),
139 /// accumulated across the loop iterations. The final
140 /// [`TurnResult::delegate_records`](crate::TurnResult::delegate_records)
141 /// carries them out for the control plane to append as signed forensic
142 /// events. Empty for every turn that never called `__delegate_to`.
143 pub delegate_records: Vec<crate::DelegateRecord>,
144}
145
146impl<P, T> TurnCtx<'_, P, T>
147where
148 P: LlmProvider + ?Sized,
149 T: ToolExecutor + ?Sized,
150{
151 /// Consumes the turn's working state into a [`crate::TurnResult`], moving
152 /// every accumulated audit surface out in one place.
153 ///
154 /// The turn loop's return sites differ only in the approvals they surface
155 /// and whether a handoff rides along; the transcript, folded usage, last
156 /// stop reason, and the audit surfaces (`#594` grant replays, `#623`
157 /// unattended denials) are always whatever the context accumulated. Owning
158 /// that move here makes forgetting an audit surface at a return site
159 /// impossible by construction.
160 #[must_use]
161 pub fn finish(
162 self,
163 pending_approvals: Vec<PendingApproval>,
164 handoff: Option<HandoffRequest>,
165 ) -> crate::TurnResult {
166 crate::TurnResult {
167 messages: self.outputs,
168 usage: self.total_usage,
169 stop: self.last_stop,
170 pending_approvals,
171 handoff,
172 grant_replays: self.grant_replays,
173 unattended_denials: self.unattended_denials,
174 mid_stream_failure: None,
175 delegate_records: self.delegate_records,
176 }
177 }
178
179 /// Consumes the turn's working state into a [`crate::TurnResult`] that
180 /// reports a mid-turn provider stream failure (`#798`), exactly like
181 /// [`Self::finish`] but with [`crate::TurnResult::mid_stream_failure`] set
182 /// and no pending approvals (a failed stream never paused for HITL).
183 ///
184 /// Whatever the loop already accumulated — executed tool results, produced
185 /// text, folded usage — rides along on the returned [`crate::TurnResult`]
186 /// instead of being discarded, which is the whole point: the caller can
187 /// persist iterations `1..N-1`'s work AND fail the turn with a typed error,
188 /// rather than losing both to a bare `Err` propagated via `?`.
189 #[must_use]
190 pub fn finish_failed(mut self, failure: crate::MidStreamFailure) -> crate::TurnResult {
191 let handoff = self.pending_handoff.take();
192 let mut result = self.finish(Vec::new(), handoff);
193 result.mid_stream_failure = Some(failure);
194 result
195 }
196}
197
198/// What a [`TurnStep`] reports after running.
199pub enum StepOutcome {
200 /// Continue to the next step in the list.
201 Continue,
202 /// Pause the turn for human approval, surfacing the given calls.
203 Pause(Vec<PendingApproval>),
204 /// End the step-driving phase early; skip any remaining steps.
205 Done,
206}
207
208/// One cohesive slice of turn behavior the turn loop drives.
209///
210/// Each step reads and mutates the turn's working state through [`TurnCtx`] and
211/// reports a [`StepOutcome`]. Generic over the provider `P` and tool executor
212/// `T` so a step can dial the provider and use the turn's error type directly.
213#[async_trait]
214pub trait TurnStep<P, T>: Send + Sync
215where
216 P: LlmProvider + ?Sized,
217 T: ToolExecutor + ?Sized,
218{
219 /// Run this step against the turn context.
220 ///
221 /// # Errors
222 ///
223 /// Returns the provider's error type when the step fails in a way that
224 /// should abort the turn. A step that is best-effort swallows its own
225 /// provider failures and returns [`StepOutcome::Continue`] instead.
226 async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error>;
227}
228
229/// The forced closing completion: when a turn executed tools but the model
230/// never produced any user-visible text, force one final text answer so the
231/// turn always yields a reply.
232pub struct ForcedCompletion;
233
234#[async_trait]
235impl<P, T> TurnStep<P, T> for ForcedCompletion
236where
237 P: LlmProvider + ?Sized,
238 T: ToolExecutor + ?Sized,
239{
240 async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
241 // FALLBACK: the turn executed tools but the model never produced any
242 // user-visible text, so `outputs` carries only tool calls/results — the
243 // edge would post nothing (the "agent produced no text" dead-end). This
244 // covers two shapes: the loop exhausting MAX_STEPS while still calling
245 // tools, AND a resume whose pre-pass executed an approved call in one step
246 // and then got an empty continuation (which breaks the loop far short of
247 // MAX_STEPS, so the old `steps_used >= MAX_STEPS` guard let it fall through
248 // silent — the approved action ran but the human saw no reply). Force ONE
249 // final completion with tools disabled so the model must answer in text,
250 // summarizing what it did or explaining it couldn't proceed. Skipped for an
251 // intentional handoff (the parent resumes with the child's result).
252 // Best-effort: a failure here leaves the turn as-is rather than erroring.
253 if !(ctx.executed_tools && !ctx.produced_text && ctx.pending_handoff.is_none()) {
254 return Ok(StepOutcome::Continue);
255 }
256 let mut req = CompletionRequest::new(ctx.model);
257 req.messages.clone_from(&ctx.messages);
258 // Removing tools is not enough: a model deep in a tool-calling groove
259 // will keep emitting a functionCall (stop == ToolUse) and no text even
260 // with no tools declared. Also disable web-search grounding (another
261 // tool surface) and append an explicit instruction so the model writes a
262 // plain-text final answer from what it already has.
263 // A System instruction (folded into systemInstruction by the provider,
264 // not the visible transcript) so the model follows it without echoing it
265 // into the reply; a User message gets paraphrased back by thinking models.
266 // Kept non-meta for the same reason.
267 req.messages.push(LlmMessage {
268 role: Role::System,
269 content: vec![LlmContent::Text(
270 "No tools are available for the remainder of this turn. Give the \
271 user a direct, plain-text answer using the information already \
272 gathered."
273 .to_owned(),
274 )],
275 });
276 req.tools = Vec::new();
277 req.web_search = false;
278 // Best-effort closing completion: its output is discarded on any error,
279 // so don't spend the retry budget's backoff here — a single attempt
280 // keeps a wedged turn from also paying tens of seconds of backoff.
281 if let Ok(stream) = ctx.provider.complete(req).await {
282 let turn = if let Some(tx) = ctx.options.stream_tx.clone() {
283 // Bounded (`#251`): see the matching forwarding site in
284 // `lib.rs` — `tx` is cloned once here (not per event) and the
285 // `.await`ed send applies real backpressure.
286 let mut tx = tx;
287 collect_turn_observed(stream, async move |ev| {
288 let _ = tx.send(ev).await;
289 })
290 .await
291 } else {
292 collect_turn(stream).await
293 };
294 if let Ok(turn) = turn {
295 ctx.total_usage.input_tokens += turn.usage.input_tokens;
296 ctx.total_usage.output_tokens += turn.usage.output_tokens;
297 push_reasoning(&mut ctx.outputs, &turn.reasoning);
298 if !turn.text.is_empty() {
299 ctx.outputs.push(text_message("model", &turn.text));
300 }
301 ctx.last_stop = turn.stop;
302 tracing::info!(
303 "forced closing completion (tool loop produced no text); turn now yields a reply"
304 );
305 }
306 }
307 Ok(StepOutcome::Continue)
308 }
309}
310
311/// The approval resume pre-pass: resolve the tool calls a human already decided.
312///
313/// Before the turn drives the model, execute the calls the human approved that
314/// are dangling in the resumed transcript, resolve signed or denied calls to
315/// synthetic results, splice those results in after the paused batch, and append
316/// any approver-injected context notes.
317///
318/// On an approval resume the control plane replays the paused turn's assistant
319/// `tool_use` (which has NO paired `tool_result` — the call was paused, never
320/// executed) and forwards the signed decisions via
321/// [`RunTurnOptions::approved_call_ids`] / [`RunTurnOptions::denied_call_ids`].
322/// The function-calling loop only executes tool calls the *model emits this
323/// turn*, so without this step an approval takes effect only if the model
324/// happens to RE-EMIT the same call. Resolving the dangling calls
325/// deterministically here makes an approval ALWAYS take effect, independent of
326/// whether the model re-emits.
327///
328/// Classification and the #141 approval binding mirror the in-loop batch so the
329/// two can't drift. It runs only on a resume: a fresh turn carries an empty
330/// decision set, so this step is a no-op and the hot path is unchanged.
331///
332/// A forwarded signed decision must never resolve silently to nothing: whenever
333/// `approved_call_ids` is non-empty, [`Self::run`] logs a resolution summary and
334/// `tracing::warn!`s individually for every approved tuple that matches no
335/// unanswered call in the resumed transcript — distinguishing an already-
336/// answered (harmless) re-forward from a call that is missing outright (a
337/// projection loss upstream, e.g. one folded into a compaction summary). This
338/// is purely observational: an approval that resolves to nothing still resolves
339/// to nothing (re-pausing here would loop), but the loss is now loud instead of
340/// surfacing only as an unexplained model refusal downstream.
341///
342/// Borrows the turn's read-only resume inputs — the pinned tool-spec set (for
343/// pause-card titles), the approver edits, and the signed denials — while the
344/// mutable working state (transcript, outputs, the sticky denial set, and the
345/// remaining approvals) rides the [`TurnCtx`].
346pub struct ResumePrePass<'a> {
347 /// The turn's pinned tool-spec set, read once for pause-card titles.
348 pub tool_specs: &'a [ToolSpec],
349 /// Approver edits (#67), keyed by the canonicalized approval identity.
350 pub approved_overrides: &'a HashMap<(String, String, String), ApprovalOverride>,
351 /// Verified signed denials as canonicalized `(id, name, args)` tuples.
352 pub denied_call_ids: &'a HashSet<(String, String, String)>,
353}
354
355#[async_trait]
356impl<P, T> TurnStep<P, T> for ResumePrePass<'_>
357where
358 P: LlmProvider + ?Sized,
359 T: ToolExecutor + ?Sized,
360{
361 #[allow(clippy::too_many_lines)] // cohesive resume-resolution pass
362 async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
363 // RESUME: execute approved-but-unanswered tool calls ALREADY PRESENT in
364 // the input transcript, before driving the model. When the model instead
365 // reads its own dangling `tool_use` as already-done and narrates
366 // completion (e.g. "OK, I've torn it down"), the approved action silently
367 // never executes and the human's decision is lost — so resolve the
368 // dangling calls deterministically here.
369 //
370 // #1154: this step used to short-circuit here whenever BOTH decision
371 // sets were empty, on the assumption that "no approvals and no
372 // denials" implies "a genuinely fresh turn, no dangling tool_use." A
373 // resume whose only signed decision failed harness-side verification
374 // (e.g. a dropped signed field) breaks that assumption: the wire
375 // carried a real decision, it just verified to nothing, so this step
376 // was skipped and the model was left narrating a dangling call it
377 // never ran. There is no cheaper-but-safe proxy for "this is a fresh
378 // turn" than actually checking for a dangling `tool_use` below, so the
379 // scan always runs; a genuinely fresh turn still exits immediately at
380 // the `unanswered.is_empty()` check just past it.
381
382 // Every tool_call id that already has a tool_result somewhere in the
383 // transcript is "answered" and must not be re-executed.
384 let answered: HashSet<&str> = ctx
385 .messages
386 .iter()
387 .flat_map(|m| m.content.iter())
388 .filter_map(|c| match c {
389 LlmContent::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
390 _ => None,
391 })
392 .collect();
393 // Unanswered assistant tool_use blocks, paired with the index of the
394 // message they live in so each synthesized result can be inserted
395 // directly after its `tool_use` (preserving provider ordering).
396 let mut unanswered: Vec<(usize, ToolCall)> = Vec::new();
397 for (idx, m) in ctx.messages.iter().enumerate() {
398 for c in &m.content {
399 if let LlmContent::ToolUse(tc) = c
400 && !answered.contains(tc.id.as_str())
401 {
402 unanswered.push((idx, tc.clone()));
403 }
404 }
405 }
406
407 // Observability (hardening after the #699/#700 admin-invite silent-no-op:
408 // an approved resume that resolved to nothing with no trace beyond a
409 // model-generated refusal). A forwarded signed decision must never
410 // resolve silently — WARN individually for every approved tuple that
411 // matches no unanswered call in THIS resumed transcript, distinguishing
412 // "already answered" (its id already carries a live `tool_result` — a
413 // harmless re-forward of a spent decision) from "not found" (no call
414 // anywhere in the transcript carries this id — the call itself is
415 // missing, e.g. folded into a compaction summary or otherwise dropped
416 // between pause and resume) so a silent loss is loud at the exact site
417 // that would otherwise have swallowed it.
418 if !ctx.options.approved_call_ids.is_empty() {
419 let unanswered_ids: HashSet<&str> =
420 unanswered.iter().map(|(_, tc)| tc.id.as_str()).collect();
421 for (id, name, _args) in &ctx.options.approved_call_ids {
422 if unanswered_ids.contains(id.as_str()) {
423 continue;
424 }
425 if answered.contains(id.as_str()) {
426 tracing::info!(
427 request_id = %id,
428 tool = %name,
429 "approved call already answered on this resume; decision is a no-op re-forward"
430 );
431 } else {
432 tracing::warn!(
433 request_id = %id,
434 tool = %name,
435 "approved call id matches no tool call in the resumed \
436 transcript; the signed decision cannot resolve to anything"
437 );
438 }
439 }
440 }
441 tracing::info!(
442 approved = ctx.options.approved_call_ids.len(),
443 denied = ctx.options.denied_call_ids.len(),
444 unanswered = unanswered.len(),
445 "resume pre-pass: resolving forwarded decisions against the resumed transcript"
446 );
447
448 if unanswered.is_empty() {
449 return Ok(StepOutcome::Continue);
450 }
451
452 // Taint state, evaluated against the resumed transcript: any untrusted
453 // tool-result (a prior fetch) already in context, OR the durable seed the
454 // control plane computed over the full event log (untrusted content that
455 // compaction folded out of the projection, or a non-principal
456 // participant's input — neither of which survives as a live `ToolResult`).
457 let untrusted_in_context =
458 untrusted_content_in_context(&ctx.messages) || ctx.options.untrusted_context_seed;
459 // Classify exactly as the in-loop batch does (same #141 binding:
460 // approval/denial bound to the exact (id, name, args) tuple).
461 let dispositions: Vec<CallDisposition> = unanswered
462 .iter()
463 .map(|(_, tc)| {
464 let gate = gate_decision(
465 ctx.tools,
466 ctx.options,
467 untrusted_in_context,
468 &tc.name,
469 &tc.args_json,
470 );
471 let key = (tc.id.clone(), tc.name.clone(), canon_args(&tc.args_json));
472 let is_denied = self.denied_call_ids.contains(&key);
473 // A remembered session approval ("don't ask again") satisfies the
474 // gate only when its signed covered set includes everything this
475 // call is currently missing (#595; see the in-loop site for the
476 // rationale). An explicit `approved_remaining` entry still runs.
477 let is_approved = ctx.approved_remaining.contains(&key)
478 || session_approves(ctx.options, ctx.tools, &tc.name, gate_missing(&gate));
479 // No sticky-signature denial at pre-pass time (denied_sigs is
480 // empty until the loop runs), so sig_match is always false. A
481 // resume is by definition attended (a human answered an approval),
482 // so `unattended` is false here — the #623 fail-closed denial only
483 // arises on a fresh trigger-originated firing, never on resume.
484 CallDisposition::classify(
485 gate,
486 CallContext {
487 approved: is_approved,
488 denied: is_denied,
489 ..CallContext::default()
490 },
491 )
492 })
493 .collect();
494 // Tally the four disposition classes in a SINGLE traversal rather than
495 // one filter/count pass per class.
496 let mut execute = 0usize;
497 let mut denied = 0usize;
498 let mut policy_denied = 0usize;
499 let mut pending = 0usize;
500 for disposition in &dispositions {
501 match disposition {
502 CallDisposition::Execute => execute += 1,
503 CallDisposition::Denied { .. } => denied += 1,
504 // #623: an unattended fail-closed denial is a non-HITL denial,
505 // tallied with the policy/sandbox class (this count only feeds a
506 // tracing line; an unattended turn never resumes, ADR 0003).
507 CallDisposition::PolicyDenied { .. } | CallDisposition::UnattendedDenied { .. } => {
508 policy_denied += 1;
509 }
510 // #582 invariant 9: constructed only by the in-loop escape
511 // hatch, never by `classify` — unreachable on a resume, but the
512 // tally stays total so a future refactor can't miscount.
513 CallDisposition::Recovered { .. } => {}
514 CallDisposition::Pending { .. } => pending += 1,
515 }
516 }
517 tracing::info!(
518 execute,
519 denied,
520 policy_denied,
521 pending,
522 "resume pre-pass: classified every unanswered dangling call"
523 );
524
525 // A dangling call that still needs approval (neither approved nor denied)
526 // must NOT be executed — re-pause the turn so the human is re-prompted,
527 // exactly as a fresh gated call would.
528 if dispositions
529 .iter()
530 .any(|d| matches!(d, CallDisposition::Pending { .. }))
531 {
532 let pending = unanswered
533 .iter()
534 .zip(&dispositions)
535 .filter_map(|((_, tc), d)| {
536 let CallDisposition::Pending { reason, missing } = d else {
537 return None;
538 };
539 let title = self
540 .tool_specs
541 .iter()
542 .find(|s| s.name == tc.name)
543 .and_then(|s| s.title.clone())
544 .unwrap_or_default();
545 Some(PendingApproval {
546 id: tc.id.clone(),
547 name: tc.name.clone(),
548 args_json: tc.args_json.clone(),
549 title,
550 // Sandbox-unaware here; the harness stamps the mode onto
551 // the wire payload.
552 sandbox_mode: String::new(),
553 // The gate's reason carried on the disposition (empty for
554 // an ordinary intrinsic/sandbox gate).
555 reason: reason.clone(),
556 missing_capabilities: missing
557 .names()
558 .iter()
559 .map(|n| (*n).to_owned())
560 .collect(),
561 })
562 })
563 .collect::<Vec<_>>();
564 return Ok(StepOutcome::Pause(pending));
565 }
566
567 // Execute approved calls concurrently; denied calls resolve to the
568 // synthetic denial payload (mirrors the in-loop resolution). Resolve each
569 // paused call's approver edit (#67) once: the edited args to execute + any
570 // context to inject. Aligned with `unanswered`.
571 let pre_resolutions: Vec<ResolvedCall> = unanswered
572 .iter()
573 .map(|(_, tc)| {
574 let key = (tc.id.clone(), tc.name.clone(), canon_args(&tc.args_json));
575 resolve_approved_call(&tc.args_json, self.approved_overrides.get(&key))
576 })
577 .collect();
578 // Resumed calls execute the args the human already approved; the dispatch
579 // policy's INPUT mutations (#539) belong to a fresh dispatch, but
580 // `post_dispatch` result redaction (#540) still applies to their output.
581 let tools = ctx.tools;
582 let recorder = ctx.options.dispatch_recorder.clone();
583 let futures = unanswered
584 .iter()
585 .zip(&dispositions)
586 .zip(&pre_resolutions)
587 .map(|(((_, tc), disposition), resolved)| {
588 if matches!(disposition, CallDisposition::Denied { .. }) {
589 // Sticky for the loop below: any re-emit of the same action is
590 // auto-denied without re-prompting.
591 ctx.denied_sigs
592 .insert((tc.name.clone(), canon_args(&tc.args_json)));
593 }
594 // A human denial OR a policy veto (#67) resolves to a synthetic
595 // result instead of executing.
596 let forced = forced_result(disposition);
597 let name = tc.name.clone();
598 let args = resolved.args_json.clone();
599 let call_id = tc.id.clone();
600 let recorder = recorder.clone();
601 async move {
602 if let Some(result) = forced {
603 result
604 } else {
605 run_and_redact(tools, recorder.as_ref(), call_id, name, args).await
606 }
607 }
608 })
609 .collect::<Vec<_>>();
610 let results = futures::future::join_all(futures).await;
611 // The pre-pass resolved dangling calls (executed approvals and/or
612 // synthesized denial results); either way the turn produced tool_results
613 // that need narrating, so guarantee a closing reply.
614 ctx.executed_tools = true;
615
616 // Mark each EXECUTED approval as spent so the loop below cannot re-execute
617 // it if the model re-emits the same call. Only Execute consumes an
618 // approval — a denial or a policy veto (#67) ran no tool.
619 for ((_, tc), disposition) in unanswered.iter().zip(&dispositions) {
620 if matches!(disposition, CallDisposition::Execute) {
621 ctx.approved_remaining.remove(&(
622 tc.id.clone(),
623 tc.name.clone(),
624 canon_args(&tc.args_json),
625 ));
626 }
627 }
628
629 // Append each result to the persisted `outputs` (so a LATER resume sees
630 // the call as answered) and into the transcript GROUPED after the paused
631 // batch's last tool_use — never interleaved between two calls. A paused
632 // batch can be parallel tool calls, and the function-calling contract
633 // requires a turn's `functionCall`s to be followed by ALL their
634 // `functionResponse`s together: a response spliced between two parallel
635 // calls is rejected (the provider 400s, which would fail the re-drive and
636 // strand the calls unanswered — poisoning the conversation). The in-loop
637 // path groups the same way.
638 let mut result_msgs = Vec::with_capacity(unanswered.len());
639 for ((_, tc), result) in unanswered.iter().zip(results) {
640 let result = cap_tool_result(&result);
641 // Stamp ingestion-time provenance so the durable trifecta tag mirrors
642 // the live-scan predicate: a first-party tool's result does not taint
643 // context (see `output_msg_trust`).
644 let first_party = !ctx.tools.ingests_untrusted_content(&tc.name);
645 ctx.outputs
646 .push(tool_result_message(&tc.id, &result, first_party));
647 // #874 (headline fix): stamp the same verdict onto the in-memory
648 // transcript, mirroring the main dispatch loop's fix. The static
649 // per-tool-name check is sufficient HERE specifically: a
650 // `__delegate_to` call is never gated (`gate_decision` returns
651 // `Allow` unconditionally for it, the same seam this pre-pass and
652 // the in-loop batch share), so it can never be paused and
653 // therefore never appears in `unanswered` — this resume path
654 // structurally never dispatches a delegate call, only ordinary
655 // gated tools whose provenance IS the static per-name check.
656 result_msgs.push(LlmMessage {
657 role: Role::Tool,
658 content: vec![LlmContent::tool_result(
659 tc.id.clone(),
660 result,
661 false,
662 first_party,
663 )],
664 });
665 }
666 // The paused batch is the tail of the transcript, so its results go after
667 // its last call. `unanswered` is non-empty in this branch.
668 let after = unanswered
669 .iter()
670 .map(|(idx, _)| *idx)
671 .max()
672 .unwrap_or(ctx.messages.len());
673 ctx.messages = splice_results_after(std::mem::take(&mut ctx.messages), after, result_msgs);
674 // #67: approver-injected context lands as internal-only system notes after
675 // the spliced results (the paused batch is the transcript tail),
676 // preserving the function-call ⇒ all-responses grouping.
677 append_injected_notes(&mut ctx.outputs, &mut ctx.messages, &pre_resolutions);
678
679 // `#743` change 1b: when at least one dangling call actually EXECUTED
680 // this resume (as opposed to only denials/policy vetoes resolving),
681 // tell the model — as runtime-injected ground truth, not a
682 // suppressible instruction — that the results above are the final,
683 // already-approved outcome. This is what makes the resume's
684 // continuation narrate the real result instead of re-guessing
685 // approval status from a bare tool result.
686 if dispositions
687 .iter()
688 .any(|d| matches!(d, CallDisposition::Execute))
689 {
690 push_internal_note(
691 &mut ctx.outputs,
692 &mut ctx.messages,
693 RESUME_EXECUTED_GROUND_TRUTH_NOTE,
694 );
695 }
696
697 Ok(StepOutcome::Continue)
698 }
699}
700
701/// The denied-action circuit breaker.
702///
703/// When the model re-emits an action a human already denied, the turn loop
704/// auto-denies it (a synthetic result, never executed) and republishes the "saw
705/// a signature-matching denial" signal onto the ctx. This step counts each such
706/// re-emit and, once the model has done it `MAX_DENIAL_REPROMPTS` times, reports
707/// [`StepOutcome::Done`] so the turn ends cleanly with the last stop reason
708/// instead of burning the rest of the step budget looping the same dead-end.
709pub struct CircuitBreaker;
710
711#[async_trait]
712impl<P, T> TurnStep<P, T> for CircuitBreaker
713where
714 P: LlmProvider + ?Sized,
715 T: ToolExecutor + ?Sized,
716{
717 async fn run(&self, ctx: &mut TurnCtx<'_, P, T>) -> Result<StepOutcome, P::Error> {
718 // CIRCUIT BREAKER: if the step that just resolved handled a re-emitted
719 // denied signature (the model retried an already-denied action), count
720 // it. Once the model has done this `MAX_DENIAL_REPROMPTS` times, stop
721 // giving it another chance — end the turn so it closes cleanly with the
722 // last stop reason instead of burning the rest of the step budget
723 // looping the same dead-end. The tool_results for the step are already
724 // appended by the loop, so the transcript stays well-formed.
725 if ctx.saw_sig_match_denial {
726 ctx.denial_reprompts += 1;
727 if ctx.denial_reprompts >= crate::MAX_DENIAL_REPROMPTS {
728 tracing::warn!(
729 denial_reprompts = ctx.denial_reprompts,
730 max = crate::MAX_DENIAL_REPROMPTS,
731 "HITL circuit breaker: model re-emitted a denied action repeatedly; \
732 ending turn instead of re-prompting"
733 );
734 return Ok(StepOutcome::Done);
735 }
736 }
737 Ok(StepOutcome::Continue)
738 }
739}