Skip to main content

polyc_agent/
lib.rs

1//! The agent turn loop.
2//!
3//! Implements the standard function-calling loop: call the provider; while it
4//! asks for tools, execute them and feed the results back; repeat until the
5//! model ends its turn. Provider streaming chunks are folded into a turn via
6//! [`polyc_llm::turn::collect_turn`]; the assistant/tool messages are
7//! mapped to wire [`Message`]s for the control plane.
8
9use async_trait::async_trait;
10use buffa_types::google::protobuf::Struct;
11use polyc_llm::request::ToolCall;
12use polyc_llm::{
13    CompletionRequest, Content as LlmContent, LlmProvider, Message as LlmMessage, Role, StopReason,
14    ToolSpec, Usage,
15    turn::{collect_turn, collect_turn_observed},
16};
17use polyc_proto::proto::polychrome::agent::v1::{
18    Content, FunctionCallContent, FunctionResultContent, Message, TextContent, ToolCallContent,
19    ToolResultContent, content, function_result_content, thought_summary_content,
20    tool_call_content, tool_result_content,
21};
22
23pub mod handoff;
24pub mod llm_summarizer;
25pub mod participation;
26
27pub use handoff::{
28    DEFAULT_MAX_CARRY, HANDOFF_TOOL_NAME, HandoffRequest, handoff_tool_spec, parse_handoff_args,
29};
30pub use llm_summarizer::LlmSummarizer;
31/// Re-export so callers can build a streaming channel without depending on
32/// `polyc-llm` directly.
33pub use polyc_llm::turn::TurnStreamEvent;
34
35/// Map an `llm`-side [`StopReason`] to the wire enum value.
36///
37/// Mirrors the variants 1:1 against `harness_service.proto`; `None` (no
38/// stop chunk observed in the stream) maps to the proto
39/// `STOP_REASON_UNSPECIFIED` zero value via [`Default`] on the caller side.
40#[must_use]
41pub const fn llm_stop_to_wire_i32(stop: StopReason) -> i32 {
42    use polyc_proto::proto::polychrome::harness::v1::StopReason as Wire;
43    match stop {
44        StopReason::EndTurn => Wire::STOP_REASON_END_TURN as i32,
45        StopReason::ToolUse => Wire::STOP_REASON_TOOL_USE as i32,
46        StopReason::MaxTokens => Wire::STOP_REASON_MAX_TOKENS as i32,
47        StopReason::Refusal => Wire::STOP_REASON_REFUSAL as i32,
48        StopReason::StopSequence => Wire::STOP_REASON_STOP_SEQUENCE as i32,
49        // `StopReason` is marked `#[non_exhaustive]` upstream. Any future
50        // variant maps to UNSPECIFIED on the wire until this match catches
51        // up — losing it on the wire is preferable to a build break.
52        _ => Wire::STOP_REASON_UNSPECIFIED as i32,
53    }
54}
55
56/// Inverse of [`llm_stop_to_wire_i32`]: lift a wire enum value back.
57///
58/// Returns `None` for `STOP_REASON_UNSPECIFIED` or any unknown wire value
59/// — the caller treats that as "no stop reason observed this turn",
60/// matching the in-process [`TurnResult::stop`] semantics.
61#[must_use]
62pub const fn wire_to_llm_stop(wire: i32) -> Option<StopReason> {
63    use polyc_proto::proto::polychrome::harness::v1::StopReason as Wire;
64    match wire {
65        x if x == Wire::STOP_REASON_END_TURN as i32 => Some(StopReason::EndTurn),
66        x if x == Wire::STOP_REASON_TOOL_USE as i32 => Some(StopReason::ToolUse),
67        x if x == Wire::STOP_REASON_MAX_TOKENS as i32 => Some(StopReason::MaxTokens),
68        x if x == Wire::STOP_REASON_REFUSAL as i32 => Some(StopReason::Refusal),
69        x if x == Wire::STOP_REASON_STOP_SEQUENCE as i32 => Some(StopReason::StopSequence),
70        _ => None,
71    }
72}
73
74/// Produces a textual summary of a transcript chunk that's about to be
75/// dropped from the prompt window. Implementations can be deterministic
76/// (the [`StubSummarizer`]) or LLM-backed (a follow-up).
77///
78/// Used by the control plane's *anchored iterative summarization* pass:
79/// when the replayed history exceeds [`crate::SUMMARY_TRIGGER`], the
80/// summarizer compresses the oldest segment and the result is persisted as
81/// a `summary` event in the conversation's event log (durable, replayable).
82/// Subsequent connects find the latest summary event and skip events at-or-
83/// before its covered position, so the prompt is bounded indefinitely. The
84/// "anchored" part means new summaries *merge* into the persistent state —
85/// the next summarizer call sees the prior summary as context, keeping
86/// detail across compactions rather than re-summarizing from scratch (per
87/// Factory's evaluation across 36k engineering session messages).
88#[async_trait]
89pub trait Summarizer: Send + Sync {
90    /// Summarize `transcript` (the about-to-be-dropped chunk), in the
91    /// context of `prior_summary` (the persistent state from earlier
92    /// compactions, empty on first compaction). Returns the new summary
93    /// text that replaces `prior_summary` going forward.
94    async fn summarize(&self, prior_summary: &str, transcript: &[LlmMessage]) -> String;
95}
96
97/// Trigger threshold for summarization.
98///
99/// When the reconstructed history exceeds this many messages, the control
100/// plane summarizes the head into a `summary` event and drops the head from
101/// the prompt. Picked higher than the prompt window so summarization stays
102/// a rare, batched event rather than a per-turn cost.
103pub const SUMMARY_TRIGGER: usize = 40;
104
105/// Deterministic placeholder summarizer — formats a tiny excerpt of the
106/// transcript so the data path is exercisable without a provider. Real
107/// deployments swap in an LLM-backed summarizer (one-trait swap).
108#[derive(Clone, Copy, Default)]
109pub struct StubSummarizer;
110
111#[async_trait]
112impl Summarizer for StubSummarizer {
113    async fn summarize(&self, prior_summary: &str, transcript: &[LlmMessage]) -> String {
114        let head = transcript
115            .iter()
116            .take(2)
117            .filter_map(|m| match m.content.first() {
118                Some(LlmContent::Text(t)) => Some(format!("{:?}: {}", m.role, snippet(t, 80))),
119                _ => None,
120            })
121            .collect::<Vec<_>>()
122            .join("; ");
123        let tail = transcript
124            .iter()
125            .rev()
126            .take(2)
127            .rev()
128            .filter_map(|m| match m.content.first() {
129                Some(LlmContent::Text(t)) => Some(format!("{:?}: {}", m.role, snippet(t, 80))),
130                _ => None,
131            })
132            .collect::<Vec<_>>()
133            .join("; ");
134        let count = transcript.len();
135        if prior_summary.is_empty() {
136            format!("[summary: {count} prior messages — head: {head}; tail: {tail}]")
137        } else {
138            format!("{prior_summary}\n[+{count} messages: head: {head}; tail: {tail}]")
139        }
140    }
141}
142
143fn snippet(s: &str, max: usize) -> String {
144    if s.len() <= max {
145        return s.to_owned();
146    }
147    let mut end = max;
148    while !s.is_char_boundary(end) && end > 0 {
149        end -= 1;
150    }
151    format!("{}…", &s[..end])
152}
153
154/// Executes a tool call by name, returning a JSON result string. Also
155/// advertises the tools it can execute so the provider knows what's callable.
156#[async_trait]
157pub trait ToolExecutor: Send + Sync {
158    /// Specs for the tools this executor knows how to run. The default
159    /// returns an empty list — the model won't be told about any tools, so it
160    /// won't emit `tool_call`s. Real registries override this.
161    fn specs(&self) -> Vec<ToolSpec> {
162        Vec::new()
163    }
164
165    /// Whether this executor advertises a tool named `name`.
166    ///
167    /// Used by composite/registry executors to route a call to its owning
168    /// source without materialising every source's full [`Self::specs`] on the
169    /// hot path. The default derives the answer from [`Self::specs`]; executors
170    /// that cache or compute specs lazily should override with a cheaper check
171    /// (e.g. a name lookup that avoids cloning the spec list).
172    fn owns(&self, name: &str) -> bool {
173        self.specs().iter().any(|s| s.name == name)
174    }
175
176    /// Whether `name` requires explicit human approval before [`Self::execute`]
177    /// may run. The default is `false` — pure / read-only tools shouldn't
178    /// trigger an approval gate. Override for sensitive tools (writes, code
179    /// execution, network reach, anything with side effects).
180    ///
181    /// When this returns `true`, [`run_turn`] does NOT call [`Self::execute`].
182    /// Instead it surfaces the unexecuted tool calls via
183    /// [`TurnResult::pending_approvals`]; the caller is responsible for
184    /// persisting an `approval_request` event, waiting for a (cryptographically
185    /// signed) `approval_response`, and re-driving the loop on the next turn.
186    fn needs_approval(&self, _name: &str) -> bool {
187        false
188    }
189
190    /// Run `name` with JSON `args_json`; return a JSON result.
191    async fn execute(&self, name: &str, args_json: &str) -> String;
192}
193
194/// Placeholder executor: advertises no tools and reports any call it
195/// receives as unhandled (the model shouldn't call anything without specs,
196/// but the guard keeps the loop progressing if it does).
197#[derive(Clone, Copy, Default)]
198pub struct StubTools;
199
200#[async_trait]
201impl ToolExecutor for StubTools {
202    async fn execute(&self, name: &str, args_json: &str) -> String {
203        format!(r#"{{"unhandled_tool":"{name}","args":{args_json}}}"#)
204    }
205}
206
207/// Cap on provider↔tool round-trips, guarding against a runaway loop.
208const MAX_STEPS: usize = 8;
209
210/// Circuit-breaker bound (Anthropic-style) on how many times the model may
211/// re-emit an action the human already denied before the turn is cut short.
212///
213/// A denial is keyed to the tool *signature* (name + args) — see `denied_sigs`
214/// in [`run_turn_with`] — so a re-emitted denied call (same action, fresh
215/// provider call-id) is auto-denied without re-prompting the human. But the
216/// model can still burn the whole `MAX_STEPS` budget re-emitting it. Once this
217/// many loop iterations have resolved a *signature-matched* terminal denial
218/// (distinct from the first signed denial), the loop breaks so the turn ends
219/// cleanly instead of looping the same dead-end.
220const MAX_DENIAL_REPROMPTS: usize = 2;
221
222/// Synthetic `tool_result` payload emitted for a tool call the human approver
223/// denied. Mirrors the JSON shape a real executor would return so the model
224/// reads it as an ordinary (failed) result and the function-calling loop closes
225/// instead of re-pausing the turn forever.
226const DENIAL_RESULT_JSON: &str = r#"{"approved":false,"error":"denied by human approver"}"#;
227
228/// One tool call awaiting human-in-the-loop approval.
229///
230/// Emitted by [`run_turn`] when [`ToolExecutor::needs_approval`] returns
231/// `true` for a tool the model wants to call. The caller surfaces these to
232/// the human / approver, persists an `approval_request` event per entry, and
233/// re-drives the loop once a matching `approval_response` event lands.
234///
235/// `id` matches the provider's tool-call id (so the assistant's tool-use
236/// content block lines up with the eventual tool-result), and is also used as
237/// the `request_id` on the wire `approval_request` event payload.
238#[derive(Debug, Clone)]
239pub struct PendingApproval {
240    /// Provider-assigned tool-call id; also used as the approval `request_id`.
241    pub id: String,
242    /// Tool name, as advertised by [`ToolExecutor::specs`]. Raw machine
243    /// identifier; the field of record for trust/audit (unchanged in the
244    /// event log).
245    pub name: String,
246    /// Arguments as a JSON string (opaque at this layer).
247    pub args_json: String,
248    /// Human display label (MCP-style `title`) for the tool, carried from the
249    /// harness wire for presentation in the approval prompt. May be empty when
250    /// the harness produced no label; renderers derive one from
251    /// [`name`](Self::name) then.
252    pub title: String,
253}
254
255/// Output of one [`run_turn`] call.
256///
257/// Carries the wire messages produced (assistant text and tool results),
258/// the aggregated usage across every provider call in the loop, and the
259/// stop reason from the final step.
260///
261/// When [`Self::pending_approvals`] is non-empty the turn is *paused*:
262/// the model asked for one or more sensitive tools, [`run_turn`] short-
263/// circuited before executing them, and the caller must capture a
264/// human-in-the-loop decision (idiomatic Temporal "signal" pattern) before
265/// re-driving. The choice to surface this as a result field rather than an
266/// out-of-band callback keeps `run_turn` pure (no side-effect handle), keeps
267/// the durability boundary at the caller (the event log already gives us
268/// replay), and lets the per-conversation Mutex / Lease release while we
269/// wait — matching the durable-workflow pattern.
270#[derive(Debug, Default, Clone)]
271pub struct TurnResult {
272    /// Wire messages — assistant text + tool result messages, in order.
273    pub messages: Vec<Message>,
274    /// Sum of `input_tokens` / `output_tokens` across every provider call
275    /// this turn made (the function-calling loop may iterate multiple times).
276    pub usage: Usage,
277    /// Stop reason of the final provider step.
278    pub stop: Option<StopReason>,
279    /// Tool calls awaiting human approval. Empty in the common case; when
280    /// non-empty, the turn paused before executing any tool in this batch.
281    pub pending_approvals: Vec<PendingApproval>,
282    /// Populated when the model emitted the reserved `__handoff_to` tool
283    /// call. The loop suspends without executing any further tools and the
284    /// caller (control plane) is expected to create a child conversation,
285    /// emit a signed [`polyc_proto::proto::polychrome::handoff::v1::Handoff`]
286    /// event into the parent's eventlog, and resume the parent's turn once a
287    /// `HandoffReturn` lands.
288    ///
289    /// If multiple `__handoff_to` calls appear in the same tool batch (the
290    /// model emitted two at once), only the first is honored — fan-out is a
291    /// V2 concern and the wire shape doesn't model parallel children today.
292    pub handoff: Option<HandoffRequest>,
293}
294
295/// Options for a single [`run_turn`] invocation.
296///
297/// A small builder-style struct rather than a long parameter list — keeps the
298/// hot-path call sites readable (`RunTurnOptions::default()`) and gives the
299/// HITL-resume path a typed slot for the approved-call-ids set without adding
300/// a third positional `HashSet` argument every existing caller would have to
301/// thread through.
302#[derive(Debug, Default, Clone)]
303pub struct RunTurnOptions {
304    /// Provider-assigned tool-call ids the caller has previously gathered
305    /// signed HITL approvals for. When [`ToolExecutor::needs_approval`]
306    /// returns `true` for a tool call, the loop checks this set: if the
307    /// call's id is present, the tool executes as normal; if absent, the
308    /// loop pauses with a fresh [`PendingApproval`] as today.
309    ///
310    /// Used by the control plane → harness resume cycle: the control plane
311    /// replays the conversation's event log, collects every verified
312    /// `approval_response` that isn't yet answered by a matching `tool_result`
313    /// message in the transcript, and passes the set here so the harness
314    /// re-drives the function-calling loop with the previously-paused tools
315    /// executed.
316    ///
317    /// Each entry is the signed `(request_id, tool_name, args_json)` tuple — the
318    /// approval is bound to that exact call (#141), so a re-emitted same-id call
319    /// with different args/tool does NOT inherit the approval (it re-pauses).
320    pub approved_call_ids: std::collections::HashSet<(String, String, String)>,
321
322    /// Verified signed HITL *denials* as `(request_id, tool_name, args_json)`
323    /// tuples (a verified `approval_response` with `approved == false`).
324    ///
325    /// A denial must RESOLVE the call, not leave it pending: when
326    /// [`ToolExecutor::needs_approval`] returns `true` for a call whose
327    /// `(id, name, args)` tuple is in this set, the loop emits a synthetic denial
328    /// `tool_result` (carrying `{"approved":false,"error":"denied by human
329    /// approver"}`) WITHOUT executing the tool and WITHOUT re-pausing. As with
330    /// approvals the denial is bound to the exact call — the same id with
331    /// different args is a new request, not an inherited denial.
332    ///
333    /// A call needing approval that is in neither [`Self::approved_call_ids`]
334    /// nor this set still pends as before.
335    pub denied_call_ids: std::collections::HashSet<(String, String, String)>,
336
337    /// When set, the turn loop forwards each [`TurnStreamEvent`] (text delta,
338    /// tool start) as it arrives, so a caller can stream partial output
339    /// mid-turn (the harness forwards these over its bidi stream → control
340    /// plane → Slack `chat.appendStream`). `None` keeps the buffered path:
341    /// the full [`TurnResult`] is always returned regardless.
342    pub stream_tx: Option<futures::channel::mpsc::UnboundedSender<TurnStreamEvent>>,
343
344    /// When `true`, each request this turn sets [`CompletionRequest::web_search`]
345    /// so the provider offers the model public-web grounding (Vertex Gemini maps
346    /// it to the `googleSearch` tool). Only the answering loop sets this; the
347    /// summarizer and classifier build their own requests and never enable it.
348    pub web_search: bool,
349}
350
351tokio::task_local! {
352    /// The id of the tool call currently being executed by [`run_turn_with`].
353    /// Scoped only around each individual `tools.execute(..)` call.
354    static CURRENT_TOOL_CALL_ID: String;
355}
356
357/// Returns the provider-assigned id of the tool call currently executing, when
358/// called from within a [`run_turn_with`] tool execution; `None` outside that
359/// scope.
360///
361/// The harness's payment-proxy tool reads this to correlate its mid-turn
362/// `PaidFetchRequest` with the approved tool call (the control plane binds the
363/// request to the matching signed `approval_response` before signing). Kept as
364/// a task-local so [`ToolExecutor::execute`]'s signature stays unchanged.
365#[must_use]
366pub fn current_tool_call_id() -> Option<String> {
367    CURRENT_TOOL_CALL_ID.try_with(String::clone).ok()
368}
369
370/// Runs `fut` with [`current_tool_call_id`] set to `id` for its duration.
371///
372/// `run_turn_with` already scopes this around each tool execution; this helper
373/// is exposed for callers/tests that need to drive a tool body as if it were
374/// executing tool call `id` (e.g. the harness payment-proxy tool's tests).
375pub async fn with_tool_call_id<F>(id: String, fut: F) -> F::Output
376where
377    F: std::future::Future,
378{
379    CURRENT_TOOL_CALL_ID.scope(id, fut).await
380}
381
382/// Run one agent turn to completion with no caller-supplied options (the
383/// common path; equivalent to `run_turn_with(..., RunTurnOptions::default())`).
384///
385/// # Errors
386///
387/// Propagates the provider's error.
388pub async fn run_turn<P, T>(
389    provider: &P,
390    tools: &T,
391    model: &str,
392    messages: Vec<LlmMessage>,
393) -> Result<TurnResult, P::Error>
394where
395    P: LlmProvider + ?Sized,
396    T: ToolExecutor + ?Sized,
397{
398    run_turn_with(provider, tools, model, messages, RunTurnOptions::default()).await
399}
400
401/// Single-pass HITL classification of one tool call in a batch (see the
402/// classification step in [`run_turn_with`]). Computed once per call so the
403/// pause decision and the resolve decision can't drift apart.
404enum CallDisposition {
405    /// Needs approval, but neither approved nor denied — must pause the batch.
406    Pending,
407    /// Needs approval and carries a signed/sticky denial — auto-denied (no
408    /// pause). `sig_match` is true when the denial came from the sticky
409    /// `denied_sigs` set (a re-emitted already-denied action) rather than the
410    /// first call-id denial; only `sig_match` denials drive the circuit breaker.
411    Denied { sig_match: bool },
412    /// Approved, or never gated — execute it.
413    Execute,
414}
415
416/// Run one agent turn to completion with caller-supplied [`RunTurnOptions`].
417///
418/// Used by the harness when resuming a previously-paused turn: the
419/// `approved_call_ids` set lets the function-calling loop execute the
420/// specific tool calls a human has signed off on while still pausing on any
421/// other `needs_approval=true` calls that haven't been approved.
422///
423/// # Errors
424///
425/// Propagates the provider's error.
426#[allow(clippy::too_many_lines)] // cohesive function-calling loop
427#[tracing::instrument(skip_all, fields(model = model, input_messages = messages.len(), approved = options.approved_call_ids.len(), denied = options.denied_call_ids.len()))]
428pub async fn run_turn_with<P, T>(
429    provider: &P,
430    tools: &T,
431    model: &str,
432    mut messages: Vec<LlmMessage>,
433    options: RunTurnOptions,
434) -> Result<TurnResult, P::Error>
435where
436    P: LlmProvider + ?Sized,
437    T: ToolExecutor + ?Sized,
438{
439    let mut outputs = Vec::new();
440    let mut total_usage = Usage::default();
441    let mut last_stop: Option<StopReason> = None;
442    let mut pending_handoff: Option<HandoffRequest> = None;
443    // STICKY/TERMINAL DENIAL set, keyed to the tool *signature* (name +
444    // args_json) rather than the provider call-id. Once a human denies an
445    // action, the model can re-emit the SAME logical call with a fresh
446    // call-id; that new id isn't in `options.denied_call_ids`, so a
447    // call-id-only check would re-pause and re-prompt the human for something
448    // they already rejected. Recording the signature here makes the denial
449    // stick across re-emits: a matching call is auto-denied (synthetic result)
450    // without ever pausing again.
451    let mut denied_sigs: std::collections::HashSet<(String, String)> =
452        std::collections::HashSet::new();
453    // Circuit-breaker counter: how many loop iterations have resolved a
454    // signature-matched terminal denial (the model retrying an already-denied
455    // action). The first signed denial — by call-id, before any signature is
456    // recorded — does NOT count; only re-emits of an already-denied signature
457    // do. When this reaches `MAX_DENIAL_REPROMPTS` the loop breaks.
458    let mut denial_reprompts: usize = 0;
459    for _ in 0..MAX_STEPS {
460        // Advertise the tool specs FRESH each iteration. Re-reading `specs()` per
461        // step lets an executor that DEFERS tools (progressive disclosure behind a
462        // `search_tools` meta-tool) grow the advertised set within a turn: after
463        // the model searches and the executor unlocks the matched tools, they
464        // appear on the next request so the model can call them. A static registry
465        // returns the same set each step, so its behavior is unchanged (just a
466        // re-clone). The reserved `__handoff_to` primitive is always advertised;
467        // a real registry that declares that name is short-circuited below.
468        let mut tool_specs = tools.specs();
469        if !tool_specs.iter().any(|s| s.name == HANDOFF_TOOL_NAME) {
470            tool_specs.push(handoff_tool_spec());
471        }
472        let mut req = CompletionRequest::new(model);
473        req.messages.clone_from(&messages);
474        req.tools = tool_specs.clone();
475        req.web_search = options.web_search;
476        let stream = provider.complete(req).await?;
477        let turn = if let Some(tx) = options.stream_tx.clone() {
478            // Forward deltas live; an unbounded send never blocks the fold.
479            collect_turn_observed(stream, move |ev| {
480                let _ = tx.unbounded_send(ev);
481            })
482            .await?
483        } else {
484            collect_turn(stream).await?
485        };
486        total_usage.input_tokens += turn.usage.input_tokens;
487        total_usage.output_tokens += turn.usage.output_tokens;
488        last_stop = turn.stop;
489
490        if !turn.text.is_empty() {
491            outputs.push(text_message("model", &turn.text));
492        }
493        // Persist the assistant's tool calls *structurally* (not as text), so
494        // eventlog replay reconstructs a real tool_use/tool_result pair —
495        // carrying the provider signature — instead of a lossy `[tool_call:id]`
496        // marker. These render as `ToolStarted` (ignored) downstream, never as
497        // user-visible reply text.
498        for tc in &turn.tool_calls {
499            outputs.push(tool_call_message(tc));
500        }
501
502        // Reflect the assistant turn back onto the transcript.
503        let mut assistant = LlmMessage::assistant(turn.text.clone());
504        for tc in &turn.tool_calls {
505            // Preserve the provider signature (e.g. a thinking model's thought
506            // signature) so the next request — which carries this call in the
507            // history — echoes it back; some providers reject the follow-up
508            // otherwise.
509            assistant.content.push(LlmContent::tool_use_signed(
510                tc.id.clone(),
511                tc.name.clone(),
512                tc.args_json.clone(),
513                tc.signature.clone(),
514            ));
515        }
516        messages.push(assistant);
517
518        // Execute tool calls whenever the model emitted any — don't gate on
519        // `stop == ToolUse`. Providers can report a normal terminal stop
520        // alongside tool calls (some stream the tool call and the end-of-turn
521        // marker as separate events), and skipping execution there would
522        // strand the turn with no output. BUT a hard stop (MaxTokens/Refusal)
523        // means the output was truncated or refused — the tool call may be
524        // incomplete (e.g. partial args JSON), so do NOT execute it.
525        let wants_tools = !turn.tool_calls.is_empty()
526            && !matches!(turn.stop, Some(StopReason::MaxTokens | StopReason::Refusal));
527        if !wants_tools {
528            break;
529        }
530
531        // Short-circuit (handoff): if any of the tool calls is the reserved
532        // handoff name, suspend the turn immediately — do NOT execute the
533        // companion tools in the batch, and do NOT feed any tool_results back
534        // to the provider. The control plane sees `handoff = Some(..)` on the
535        // returned `TurnResult` and takes over: it creates the child
536        // conversation and writes the signed `Handoff` event. On the parent's
537        // *next* turn the resumed transcript will include the `__handoff_to`
538        // call + its `HandoffReturn`-derived result, so the function-calling
539        // loop closes cleanly.
540        if let Some(tc) = turn.tool_calls.iter().find(|t| t.name == HANDOFF_TOOL_NAME)
541            && let Some(req) = handoff::parse_handoff_args(&tc.id, &tc.args_json, &messages)
542        {
543            pending_handoff = Some(req);
544            break;
545        }
546
547        // HITL approval gate: if ANY tool in this batch needs human approval
548        // *and* the caller hasn't already supplied a signed approval for it,
549        // pause the entire batch — execute nothing, surface every still-
550        // unapproved call so the caller can route them through approval
551        // together. Atomicity matters: the model's prompt sees either all
552        // results (after every approval lands) or no results (paused). Mixed
553        // batches with some pre-executed read-only tools would force the
554        // rest into a different batch on resume and confuse the model's
555        // tool_use accounting.
556        //
557        // On a resumed turn the caller passes the set of previously-approved
558        // call ids via `options.approved_call_ids` and the set of denied ids
559        // via `options.denied_call_ids`. Tools whose id is approved execute as
560        // normal; tools whose id is denied resolve to a synthetic denial
561        // result (below) without executing; only tools that still need approval
562        // but have neither a signed approval nor a signed denial cause the
563        // pause.
564        // SINGLE-PASS CLASSIFICATION. Classify every tool call in the batch
565        // exactly once into one of three dispositions, then act on the batch
566        // as a whole. This replaces the old `still_needs_approval` closure +
567        // the inline `denied = ...` recomputation, which evaluated the same
568        // predicates twice and drifted apart easily.
569        //
570        // A call is DENIED if its id carries a signed denial
571        // (`options.denied_call_ids`) OR its signature is already in the
572        // sticky `denied_sigs` set (the model re-emitted an already-denied
573        // action with a fresh call-id). A denied call NEVER pauses — it
574        // resolves to a synthetic denial result directly.
575        let dispositions = turn
576            .tool_calls
577            .iter()
578            .map(|tc| {
579                let needs_approval = tools.needs_approval(&tc.name);
580                let sig = (tc.name.clone(), tc.args_json.clone());
581                let sig_denied = denied_sigs.contains(&sig);
582                // The approval/denial is bound to the EXACT (id, name, args) tuple
583                // the human signed (#141): a re-emitted same-id call with changed
584                // args/tool matches neither set, so it re-pauses rather than
585                // inheriting the prior verdict.
586                let approval_key = (tc.id.clone(), tc.name.clone(), tc.args_json.clone());
587                let is_denied = options.denied_call_ids.contains(&approval_key) || sig_denied;
588                let is_approved = options.approved_call_ids.contains(&approval_key);
589                if needs_approval && is_denied {
590                    // A signature match means the model re-emitted an
591                    // already-denied action; a call-id-only denial is the
592                    // first signed denial (does not count toward the breaker).
593                    CallDisposition::Denied {
594                        sig_match: sig_denied,
595                    }
596                } else if needs_approval && !is_approved {
597                    CallDisposition::Pending
598                } else {
599                    CallDisposition::Execute
600                }
601            })
602            .collect::<Vec<_>>();
603
604        // PAUSE the whole batch iff ANY call is Pending — preserving the
605        // atomic-batch semantics (the model's prompt sees either all results
606        // or none) and the existing `PendingApproval` surface. Denied calls
607        // do NOT trigger a pause; they resolve below.
608        let batch_needs_approval = dispositions
609            .iter()
610            .any(|d| matches!(d, CallDisposition::Pending));
611        if batch_needs_approval {
612            let pending = turn
613                .tool_calls
614                .iter()
615                .zip(&dispositions)
616                .filter(|(_, d)| matches!(d, CallDisposition::Pending))
617                .map(|(tc, _)| {
618                    // Carry the tool's curated display title (the MCP-style
619                    // annotation) when its spec advertised one; empty otherwise
620                    // (downstream derives a label from `name`). The raw `name`
621                    // remains the audit identifier.
622                    let title = tool_specs
623                        .iter()
624                        .find(|s| s.name == tc.name)
625                        .and_then(|s| s.title.clone())
626                        .unwrap_or_default();
627                    PendingApproval {
628                        id: tc.id.clone(),
629                        name: tc.name.clone(),
630                        args_json: tc.args_json.clone(),
631                        title,
632                    }
633                })
634                .collect::<Vec<_>>();
635            return Ok(TurnResult {
636                messages: outputs,
637                usage: total_usage,
638                stop: last_stop,
639                pending_approvals: pending,
640                handoff: None,
641            });
642        }
643
644        // Resolve each tool call per its disposition. Denied calls get a
645        // synthetic denial result (NOT executed) and record their signature in
646        // `denied_sigs` so any later re-emit is auto-denied; every Execute call
647        // runs concurrently via join_all (denials are instant). Results are
648        // gathered in `turn.tool_calls` order so the next provider call sees
649        // the same shape as a sequential loop.
650        //
651        // The denial result payload (`DENIAL_RESULT_JSON`) mirrors the JSON
652        // shape an executor would return, so the model reads it as an ordinary
653        // (failed) tool_result and the function-calling loop closes cleanly
654        // instead of re-pausing.
655        let mut saw_sig_match_denial = false;
656        let tool_futures = turn
657            .tool_calls
658            .iter()
659            .zip(&dispositions)
660            .map(|(tc, disposition)| {
661                let denied = matches!(disposition, CallDisposition::Denied { .. });
662                if let CallDisposition::Denied { sig_match } = disposition {
663                    // Make the denial sticky for this turn: future re-emits of
664                    // the same action are auto-denied without re-prompting.
665                    denied_sigs.insert((tc.name.clone(), tc.args_json.clone()));
666                    if *sig_match {
667                        saw_sig_match_denial = true;
668                    }
669                }
670                let name = tc.name.clone();
671                let args = tc.args_json.clone();
672                let call_id = tc.id.clone();
673                async move {
674                    if denied {
675                        DENIAL_RESULT_JSON.to_owned()
676                    } else {
677                        // Scope the call id as a task-local for the duration of
678                        // this one execution, so a tool (e.g. the harness payment
679                        // proxy) can correlate without an `execute` signature
680                        // change. Same task as the tool body — task-local is
681                        // visible inside `execute`.
682                        CURRENT_TOOL_CALL_ID
683                            .scope(call_id, tools.execute(&name, &args))
684                            .await
685                    }
686                }
687            })
688            .collect::<Vec<_>>();
689        let results = futures::future::join_all(tool_futures).await;
690        for (tc, result) in turn.tool_calls.iter().zip(results) {
691            // Structured tool result (not text) so replay reconstructs a real
692            // tool_result keyed to its call id (pairs with the tool_call above).
693            outputs.push(tool_result_message(&tc.id, &result));
694            messages.push(LlmMessage {
695                role: Role::Tool,
696                content: vec![LlmContent::tool_result(tc.id.clone(), result, false)],
697            });
698        }
699
700        // CIRCUIT BREAKER: if this step resolved a re-emitted denied
701        // signature (the model retried an already-denied action), count it.
702        // Once the model has done this `MAX_DENIAL_REPROMPTS` times, stop
703        // giving it another chance — break so the turn ends cleanly with the
704        // last stop reason instead of burning the rest of `MAX_STEPS` looping
705        // the same dead-end. tool_results for this step are already appended
706        // above, so the transcript stays well-formed.
707        if saw_sig_match_denial {
708            denial_reprompts += 1;
709            if denial_reprompts >= MAX_DENIAL_REPROMPTS {
710                tracing::warn!(
711                    denial_reprompts,
712                    max = MAX_DENIAL_REPROMPTS,
713                    "HITL circuit breaker: model re-emitted a denied action repeatedly; \
714                     ending turn instead of re-prompting"
715                );
716                break;
717            }
718        }
719    }
720    Ok(TurnResult {
721        messages: outputs,
722        usage: total_usage,
723        stop: last_stop,
724        pending_approvals: Vec::new(),
725        handoff: pending_handoff,
726    })
727}
728
729/// Convert an llm [`LlmMessage`] back to a wire [`Message`] for transmission
730/// over `HarnessService`.
731///
732/// Lossy on multi-content messages (concatenates text content blocks);
733/// non-text variants are skipped — the assumption is that the caller has
734/// already normalized via [`wire_to_llm`] and windowing, so each llm
735/// message carries a single text content block by construction.
736#[must_use]
737pub fn llm_to_wire(msg: &LlmMessage) -> Message {
738    let role = match msg.role {
739        Role::Assistant => "model",
740        Role::Tool => "tool",
741        Role::System => "system",
742        // User and any future non-exhaustive variant map to wire "user".
743        _ => "user",
744    };
745    let text = msg
746        .content
747        .iter()
748        .filter_map(|c| match c {
749            LlmContent::Text(s) => Some(s.as_str()),
750            _ => None,
751        })
752        .collect::<Vec<_>>()
753        .join("");
754    text_message(role, &text)
755}
756
757/// Convert an owned wire [`Message`] into an llm [`LlmMessage`].
758///
759/// Preserves the role and reconstructs faithful content so a replayed
760/// transcript carries the same tool and reasoning state the model emitted
761/// originally — not lossy placeholders. Concretely:
762/// - text survives verbatim;
763/// - a tool call surfaces as [`LlmContent::tool_use`] carrying the real
764///   function name and JSON-encoded arguments;
765/// - a tool result surfaces as [`LlmContent::tool_result`] carrying the
766///   JSON-encoded result payload keyed by its originating call id;
767/// - model reasoning surfaces as text built from the thought summary parts.
768///
769/// Image / audio / document / video / confirmation variants surface as no
770/// content (no fabrication). The inverse of [`text_message`]; both bridges
771/// live here so the wire ↔ llm conversion has one canonical owner used by the
772/// control plane (eventlog replay) and the harness (`HarnessService` input).
773#[must_use]
774pub fn wire_to_llm(msg: &Message) -> LlmMessage {
775    let role = match msg.role.as_str() {
776        "model" | "assistant" => Role::Assistant,
777        "tool" | "function" => Role::Tool,
778        "system" => Role::System,
779        _ => Role::User,
780    };
781    let content = match msg.content.as_option().and_then(|c| c.r#type.as_ref()) {
782        Some(content::Type::Text(t)) => vec![LlmContent::Text(t.text.clone())],
783        Some(content::Type::ToolCall(tc)) => {
784            // The function name and arguments live on the inner FunctionCall
785            // oneof. Arguments are a structured `Struct` on the wire; serialize
786            // it to the JSON-string `args_json` the llm layer expects. Fall
787            // back to an empty name / `{}` args when either is absent so a
788            // partial call still replays as a well-formed tool_use.
789            let (name, args_json) = match tc.r#type.as_ref() {
790                Some(tool_call_content::Type::FunctionCall(fc)) => {
791                    let args_json = fc
792                        .arguments
793                        .as_option()
794                        .and_then(|s| serde_json::to_string(s).ok())
795                        .unwrap_or_else(|| "{}".to_owned());
796                    (fc.name.clone(), args_json)
797                }
798                None => (String::new(), "{}".to_owned()),
799            };
800            // Recover the provider signature (stored as bytes on the wire) so
801            // a replayed tool call still echoes it back on the next request.
802            let signature = (!tc.signature.is_empty())
803                .then(|| String::from_utf8_lossy(&tc.signature).into_owned());
804            vec![LlmContent::tool_use_signed(
805                tc.id.clone(),
806                name,
807                args_json,
808                signature,
809            )]
810        }
811        Some(content::Type::ToolResult(tr)) => {
812            // The result payload is a structured `Struct` on the inner
813            // FunctionResult oneof; serialize it to the JSON-string the llm
814            // layer expects. Replayed results are observed history, never
815            // errors, so `is_error` is false.
816            let result_json = match tr.r#type.as_ref() {
817                Some(tool_result_content::Type::FunctionResult(fr)) => match fr.result.as_ref() {
818                    Some(function_result_content::Result::Response(resp)) => {
819                        serde_json::to_string(resp).unwrap_or_else(|_| "{}".to_owned())
820                    }
821                    None => "{}".to_owned(),
822                },
823                None => "{}".to_owned(),
824            };
825            vec![LlmContent::tool_result(
826                tr.call_id.clone(),
827                result_json,
828                false,
829            )]
830        }
831        Some(content::Type::Thought(t)) => {
832            // Reasoning has no top-level text; its `summary` repeated field
833            // carries the text parts. Concatenate them so the reasoning
834            // survives the round trip; skip entirely when empty rather than
835            // emit a blank text block.
836            let mut buf = String::new();
837            for s in &t.summary {
838                if let Some(thought_summary_content::Type::Text(text)) = s.r#type.as_ref()
839                    && !text.text.is_empty()
840                {
841                    if !buf.is_empty() {
842                        buf.push(' ');
843                    }
844                    buf.push_str(&text.text);
845                }
846            }
847            if buf.is_empty() {
848                Vec::new()
849            } else {
850                vec![LlmContent::Text(buf)]
851            }
852        }
853        // Image / audio / document / video / confirmation: skip rather than
854        // fabricate a misleading text representation.
855        _ => Vec::new(),
856    };
857    LlmMessage { role, content }
858}
859
860/// Build a wire [`Message`] carrying a structured tool call.
861///
862/// Preserves the provider signature (e.g. a thinking model's thought
863/// signature) as the `ToolCallContent.signature` bytes. Persisted to the event
864/// log so replay reconstructs a real `tool_use` (paired with
865/// [`tool_result_message`]) instead of a lossy text marker, and the signature
866/// survives to be echoed back on the next request. Rendered as an (ignored)
867/// tool-start downstream — never as user-visible reply text.
868#[must_use]
869pub fn tool_call_message(tc: &ToolCall) -> Message {
870    let arguments = serde_json::from_str::<Struct>(&tc.args_json)
871        .map(buffa::MessageField::some)
872        .unwrap_or_default();
873    Message {
874        role: "model".to_owned(),
875        content: buffa::MessageField::some(Content {
876            r#type: Some(content::Type::ToolCall(Box::new(ToolCallContent {
877                id: tc.id.clone(),
878                signature: tc
879                    .signature
880                    .clone()
881                    .map(String::into_bytes)
882                    .unwrap_or_default(),
883                r#type: Some(tool_call_content::Type::FunctionCall(Box::new(
884                    FunctionCallContent {
885                        name: tc.name.clone(),
886                        arguments,
887                        ..Default::default()
888                    },
889                ))),
890                ..Default::default()
891            }))),
892            ..Default::default()
893        }),
894        internal_only: false,
895        ..Default::default()
896    }
897}
898
899/// Build a wire [`Message`] carrying a structured tool result keyed to its
900/// originating `call_id`.
901///
902/// The inverse of the tool-result arm of [`wire_to_llm`]; persisted so replay
903/// reconstructs a real `tool_result`.
904#[must_use]
905pub fn tool_result_message(call_id: &str, result_json: &str) -> Message {
906    let response = serde_json::from_str::<Struct>(result_json)
907        .ok()
908        .map(|s| function_result_content::Result::Response(Box::new(s)));
909    Message {
910        role: "tool".to_owned(),
911        content: buffa::MessageField::some(Content {
912            r#type: Some(content::Type::ToolResult(Box::new(ToolResultContent {
913                call_id: call_id.to_owned(),
914                r#type: Some(tool_result_content::Type::FunctionResult(Box::new(
915                    FunctionResultContent {
916                        result: response,
917                        ..Default::default()
918                    },
919                ))),
920                ..Default::default()
921            }))),
922            ..Default::default()
923        }),
924        internal_only: false,
925        ..Default::default()
926    }
927}
928
929/// Build a wire [`Message`] carrying a single text content block.
930///
931/// Shared by the turn loop and by the control plane's eventlog write path; one
932/// owner of the wire-message construction prevents the two from drifting.
933#[must_use]
934pub fn text_message(role: &str, text: &str) -> Message {
935    Message {
936        role: role.to_owned(),
937        content: buffa::MessageField::some(Content {
938            r#type: Some(content::Type::Text(Box::new(TextContent {
939                text: text.to_owned(),
940                ..Default::default()
941            }))),
942            ..Default::default()
943        }),
944        internal_only: false,
945        ..Default::default()
946    }
947}
948
949#[cfg(test)]
950mod tests {
951    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
952
953    use futures::{StreamExt, stream};
954    use polyc_llm::{Chunk, CompletionRequest, LlmProvider, error::DummyError, turn::StubProvider};
955    use std::sync::atomic::{AtomicUsize, Ordering};
956
957    use super::*;
958
959    #[tokio::test]
960    async fn stub_turn_yields_one_assistant_message() {
961        let out = run_turn(
962            &StubProvider,
963            &StubTools,
964            "stub",
965            vec![LlmMessage::user("hi")],
966        )
967        .await
968        .expect("turn");
969        assert_eq!(out.messages.len(), 1);
970        assert_eq!(out.messages[0].role, "model");
971        assert!(out.pending_approvals.is_empty());
972    }
973
974    /// Provider that emits a single tool_call on the first complete() and
975    /// EndTurn on subsequent calls. Lets us drive a deterministic two-step
976    /// function-calling loop in tests.
977    struct ScriptedToolCallProvider {
978        calls: AtomicUsize,
979    }
980
981    #[async_trait]
982    impl LlmProvider for ScriptedToolCallProvider {
983        type Error = DummyError;
984
985        async fn complete(
986            &self,
987            _req: CompletionRequest,
988        ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
989        {
990            let n = self.calls.fetch_add(1, Ordering::SeqCst);
991            let chunks = if n == 0 {
992                vec![
993                    Ok(Chunk::tool_call_start("call-1", "dangerous_tool")),
994                    Ok(Chunk::tool_call_args_delta("call-1", r#"{"rm":"-rf"}"#)),
995                    Ok(Chunk::tool_call_end("call-1")),
996                    Ok(Chunk::Stop(polyc_llm::StopReason::ToolUse)),
997                ]
998            } else {
999                vec![
1000                    Ok(Chunk::text_delta("done")),
1001                    Ok(Chunk::Stop(polyc_llm::StopReason::EndTurn)),
1002                ]
1003            };
1004            Ok(stream::iter(chunks).boxed())
1005        }
1006    }
1007
1008    /// Tracking executor: records every execute() call and declares
1009    /// `dangerous_tool` as needing approval. Used to prove that a needs-
1010    /// approval batch is NEVER executed by `run_turn`.
1011    #[derive(Default)]
1012    struct ApprovalGatedTools {
1013        executed: std::sync::Mutex<Vec<String>>,
1014    }
1015
1016    #[async_trait]
1017    impl ToolExecutor for ApprovalGatedTools {
1018        fn needs_approval(&self, name: &str) -> bool {
1019            name == "dangerous_tool"
1020        }
1021        async fn execute(&self, name: &str, args_json: &str) -> String {
1022            self.executed.lock().unwrap().push(name.to_owned());
1023            format!(r#"{{"ran":"{name}","args":{args_json}}}"#)
1024        }
1025    }
1026
1027    #[tokio::test]
1028    async fn needs_approval_tool_pauses_with_pending_approval() {
1029        let provider = ScriptedToolCallProvider {
1030            calls: AtomicUsize::new(0),
1031        };
1032        let tools = ApprovalGatedTools::default();
1033        let out = run_turn(&provider, &tools, "scripted", vec![LlmMessage::user("hi")])
1034            .await
1035            .expect("turn");
1036        assert_eq!(
1037            out.pending_approvals.len(),
1038            1,
1039            "needs_approval tool short-circuits the loop"
1040        );
1041        let pa = &out.pending_approvals[0];
1042        assert_eq!(pa.id, "call-1");
1043        assert_eq!(pa.name, "dangerous_tool");
1044        assert_eq!(pa.args_json, r#"{"rm":"-rf"}"#);
1045        assert!(
1046            tools.executed.lock().unwrap().is_empty(),
1047            "execute() must not be called when needs_approval=true"
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn pending_approval_default_is_empty() {
1053        // The common path: a tool-less turn returns an empty pending list so
1054        // callers can use the field unconditionally.
1055        let out = run_turn(
1056            &StubProvider,
1057            &StubTools,
1058            "stub",
1059            vec![LlmMessage::user("hi")],
1060        )
1061        .await
1062        .expect("turn");
1063        assert!(out.pending_approvals.is_empty());
1064    }
1065
1066    /// Read-only tool that does NOT need approval. Used to prove a non-
1067    /// sensitive batch still executes through the normal path.
1068    #[derive(Default)]
1069    struct ReadOnlyTools;
1070
1071    #[async_trait]
1072    impl ToolExecutor for ReadOnlyTools {
1073        async fn execute(&self, _name: &str, _args_json: &str) -> String {
1074            r#"{"result":"ok"}"#.to_owned()
1075        }
1076    }
1077
1078    /// Scripted provider that emits a single benign tool_call then ends.
1079    struct ScriptedBenignProvider {
1080        calls: AtomicUsize,
1081    }
1082
1083    #[async_trait]
1084    impl LlmProvider for ScriptedBenignProvider {
1085        type Error = DummyError;
1086
1087        async fn complete(
1088            &self,
1089            _req: CompletionRequest,
1090        ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
1091        {
1092            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1093            let chunks = if n == 0 {
1094                vec![
1095                    Ok(Chunk::tool_call_start("call-1", "read_only")),
1096                    Ok(Chunk::tool_call_args_delta("call-1", "{}")),
1097                    Ok(Chunk::tool_call_end("call-1")),
1098                    Ok(Chunk::Stop(polyc_llm::StopReason::ToolUse)),
1099                ]
1100            } else {
1101                vec![
1102                    Ok(Chunk::text_delta("done")),
1103                    Ok(Chunk::Stop(polyc_llm::StopReason::EndTurn)),
1104                ]
1105            };
1106            Ok(stream::iter(chunks).boxed())
1107        }
1108    }
1109
1110    #[tokio::test]
1111    async fn previously_approved_tool_executes_on_resume() {
1112        // Drive `run_turn_with` with the same scripted provider + gated tool
1113        // executor as the pause test, but populate `approved_call_ids` with
1114        // the call id the harness would carry on a resumed turn. The tool
1115        // must execute (executor.executed records the call) and no
1116        // pending_approvals must be surfaced.
1117        let provider = ScriptedToolCallProvider {
1118            calls: AtomicUsize::new(0),
1119        };
1120        let tools = ApprovalGatedTools::default();
1121        let mut approved = std::collections::HashSet::new();
1122        approved.insert((
1123            "call-1".to_owned(),
1124            "dangerous_tool".to_owned(),
1125            r#"{"rm":"-rf"}"#.to_owned(),
1126        ));
1127        let out = run_turn_with(
1128            &provider,
1129            &tools,
1130            "scripted",
1131            vec![LlmMessage::user("hi")],
1132            RunTurnOptions {
1133                approved_call_ids: approved,
1134                ..Default::default()
1135            },
1136        )
1137        .await
1138        .expect("turn");
1139        assert!(
1140            out.pending_approvals.is_empty(),
1141            "approved call must NOT re-pause the loop"
1142        );
1143        let executed = tools.executed.lock().unwrap().clone();
1144        assert_eq!(
1145            executed,
1146            vec!["dangerous_tool".to_owned()],
1147            "tool executes after approval lands"
1148        );
1149    }
1150
1151    /// #141 regression: an approval bound to one (id, name, args) tuple must NOT
1152    /// authorize a same-id call with DIFFERENT args. The gate re-pauses instead
1153    /// of inheriting the approval.
1154    #[tokio::test]
1155    async fn approval_does_not_inherit_across_changed_args() {
1156        let provider = ScriptedToolCallProvider {
1157            calls: AtomicUsize::new(0),
1158        };
1159        let tools = ApprovalGatedTools::default();
1160        // Approve the SAME call-id + tool but DIFFERENT args than the scripted
1161        // call actually emits (`{"rm":"-rf"}`).
1162        let mut approved = std::collections::HashSet::new();
1163        approved.insert((
1164            "call-1".to_owned(),
1165            "dangerous_tool".to_owned(),
1166            r#"{"rm":"/tmp/safe"}"#.to_owned(),
1167        ));
1168        let out = run_turn_with(
1169            &provider,
1170            &tools,
1171            "scripted",
1172            vec![LlmMessage::user("hi")],
1173            RunTurnOptions {
1174                approved_call_ids: approved,
1175                ..Default::default()
1176            },
1177        )
1178        .await
1179        .expect("turn");
1180        assert_eq!(
1181            out.pending_approvals.len(),
1182            1,
1183            "an approval for different args must NOT authorize this call — it re-pauses"
1184        );
1185        assert!(
1186            tools.executed.lock().unwrap().is_empty(),
1187            "the tool must NOT execute under a mismatched-args approval"
1188        );
1189    }
1190
1191    #[tokio::test]
1192    async fn denied_tool_resolves_without_executing_or_repausing() {
1193        // The denial path: the same scripted provider + gated tool executor as
1194        // the pause test, but the call id lands in `denied_call_ids` (a verified
1195        // approval_response with approved=false). The loop must NOT re-pause and
1196        // must NOT execute the tool; instead it emits a synthetic denial
1197        // tool_result so the model sees a result and the turn closes.
1198        let provider = ScriptedToolCallProvider {
1199            calls: AtomicUsize::new(0),
1200        };
1201        let tools = ApprovalGatedTools::default();
1202        let mut denied = std::collections::HashSet::new();
1203        denied.insert((
1204            "call-1".to_owned(),
1205            "dangerous_tool".to_owned(),
1206            r#"{"rm":"-rf"}"#.to_owned(),
1207        ));
1208        let out = run_turn_with(
1209            &provider,
1210            &tools,
1211            "scripted",
1212            vec![LlmMessage::user("hi")],
1213            RunTurnOptions {
1214                denied_call_ids: denied,
1215                ..Default::default()
1216            },
1217        )
1218        .await
1219        .expect("turn");
1220        assert!(
1221            out.pending_approvals.is_empty(),
1222            "denied call must NOT re-pause the loop"
1223        );
1224        // The FIRST signed denial (by call-id) must NOT trip the circuit
1225        // breaker: it records the signature, resolves the call, and lets the
1226        // model continue. Here the scripted provider ends the turn naturally on
1227        // its second call — so it was driven exactly twice (the breaker did not
1228        // cut it short on step 0).
1229        assert_eq!(
1230            provider.calls.load(Ordering::SeqCst),
1231            2,
1232            "first signed denial must not trip the breaker; model ends the turn itself"
1233        );
1234        assert!(
1235            tools.executed.lock().unwrap().is_empty(),
1236            "execute() must not be called for a denied call"
1237        );
1238        // A tool-result message must exist for the denied call, carrying the
1239        // denial payload (so the model gets a result, not a hang).
1240        let denial = out
1241            .messages
1242            .iter()
1243            .find(|m| {
1244                m.role == "tool"
1245                    && matches!(
1246                        m.content.as_option().and_then(|c| c.r#type.as_ref()),
1247                        Some(content::Type::ToolResult(tr)) if tr.call_id == "call-1"
1248                    )
1249            })
1250            .expect("denied call must produce a tool_result message");
1251        // Round-trip the wire message back to llm form and assert the payload
1252        // is the denial JSON (not an executed result).
1253        let llm = wire_to_llm(denial);
1254        match &llm.content[0] {
1255            LlmContent::ToolResult(tr) => {
1256                let parsed: serde_json::Value =
1257                    serde_json::from_str(&tr.result_json).expect("denial result is valid json");
1258                assert_eq!(
1259                    parsed.get("approved"),
1260                    Some(&serde_json::Value::Bool(false)),
1261                    "denial result must carry approved=false"
1262                );
1263                assert!(
1264                    parsed.get("error").is_some(),
1265                    "denial result must carry an error explanation"
1266                );
1267            }
1268            other => panic!("expected ToolResult, got {other:?}"),
1269        }
1270    }
1271
1272    /// Scripted provider that re-emits the SAME logical tool call
1273    /// (`dangerous_tool` with identical args) on every step, each time under a
1274    /// fresh provider call-id (`call-1`, `call-2`, ...). Models the deny →
1275    /// re-emit loop: a denial keyed only to the call-id would never stick, so
1276    /// the signature-based sticky denial + circuit breaker must catch it.
1277    /// Records how many times the provider was driven so a test can assert the
1278    /// breaker bounded the loop well below `MAX_STEPS`.
1279    struct ReEmittingDeniedProvider {
1280        calls: AtomicUsize,
1281    }
1282
1283    #[async_trait]
1284    impl LlmProvider for ReEmittingDeniedProvider {
1285        type Error = DummyError;
1286
1287        async fn complete(
1288            &self,
1289            _req: CompletionRequest,
1290        ) -> Result<futures::stream::BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error>
1291        {
1292            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1293            // Fresh call-id each step; identical name + args (the signature).
1294            let id = format!("call-{}", n + 1);
1295            let chunks = vec![
1296                Ok(Chunk::tool_call_start(&id, "dangerous_tool")),
1297                Ok(Chunk::tool_call_args_delta(&id, r#"{"rm":"-rf"}"#)),
1298                Ok(Chunk::tool_call_end(&id)),
1299                Ok(Chunk::Stop(polyc_llm::StopReason::ToolUse)),
1300            ];
1301            Ok(stream::iter(chunks).boxed())
1302        }
1303    }
1304
1305    #[tokio::test]
1306    async fn reemitted_denied_signature_is_auto_denied_and_circuit_breaker_bounds_loop() {
1307        // The deny → re-emit → re-prompt loop the fix targets. Step 0's call
1308        // (`call-1`) carries a signed denial via `denied_call_ids`, recording
1309        // its (name, args) signature. The model then re-emits the SAME action
1310        // with fresh call-ids on each later step. Those re-emits must be
1311        // AUTO-DENIED by signature — never surfaced as a PendingApproval and
1312        // never executed — and the circuit breaker must end the turn well
1313        // before MAX_STEPS.
1314        let provider = ReEmittingDeniedProvider {
1315            calls: AtomicUsize::new(0),
1316        };
1317        let tools = ApprovalGatedTools::default();
1318        let mut denied = std::collections::HashSet::new();
1319        denied.insert((
1320            "call-1".to_owned(),
1321            "dangerous_tool".to_owned(),
1322            r#"{"rm":"-rf"}"#.to_owned(),
1323        ));
1324        let out = run_turn_with(
1325            &provider,
1326            &tools,
1327            "scripted",
1328            vec![LlmMessage::user("hi")],
1329            RunTurnOptions {
1330                denied_call_ids: denied,
1331                ..Default::default()
1332            },
1333        )
1334        .await
1335        .expect("turn");
1336
1337        // No PendingApproval: the re-emitted denied signature must NOT
1338        // re-prompt the human for an already-denied action.
1339        assert!(
1340            out.pending_approvals.is_empty(),
1341            "re-emitted denied signature must auto-deny, not re-prompt"
1342        );
1343        // Never executed — every step resolved to a synthetic denial.
1344        assert!(
1345            tools.executed.lock().unwrap().is_empty(),
1346            "auto-denied calls must never execute"
1347        );
1348        // Every step produced a denial tool_result for its (fresh) call-id.
1349        let denial_results = out
1350            .messages
1351            .iter()
1352            .filter(|m| {
1353                m.role == "tool"
1354                    && matches!(
1355                        m.content.as_option().and_then(|c| c.r#type.as_ref()),
1356                        Some(content::Type::ToolResult(_))
1357                    )
1358            })
1359            .count();
1360        assert!(
1361            denial_results >= 1,
1362            "each auto-denied call must still produce a tool_result"
1363        );
1364        // Circuit breaker bounded the loop: the provider was driven at most
1365        // `MAX_DENIAL_REPROMPTS + 1` times (step 0's first signed denial does
1366        // not count toward the breaker; the next two signature re-emits trip
1367        // it) — strictly fewer than MAX_STEPS.
1368        let driven = provider.calls.load(Ordering::SeqCst);
1369        assert!(
1370            driven <= MAX_DENIAL_REPROMPTS + 1,
1371            "circuit breaker must bound re-prompts: driven={driven} > {}",
1372            MAX_DENIAL_REPROMPTS + 1
1373        );
1374        assert!(
1375            driven < MAX_STEPS,
1376            "circuit breaker must end the turn before burning MAX_STEPS"
1377        );
1378    }
1379
1380    #[tokio::test]
1381    async fn read_only_batch_runs_through_without_approval_pause() {
1382        let provider = ScriptedBenignProvider {
1383            calls: AtomicUsize::new(0),
1384        };
1385        let tools = ReadOnlyTools;
1386        let out = run_turn(&provider, &tools, "scripted", vec![LlmMessage::user("hi")])
1387            .await
1388            .expect("turn");
1389        assert!(
1390            out.pending_approvals.is_empty(),
1391            "no approval needed for read-only tools"
1392        );
1393        // One assistant text + one tool-result + final assistant text.
1394        // The exact count depends on whether the model emitted text on step 0
1395        // — here it did not, so we expect [tool-result, final-text].
1396        assert!(out.messages.iter().any(|m| m.role == "tool"));
1397    }
1398
1399    #[test]
1400    fn wire_to_llm_preserves_tool_call_and_result() {
1401        use buffa::MessageField;
1402        use buffa_types::google::protobuf::Struct;
1403        use polyc_proto::proto::polychrome::agent::v1::{
1404            FunctionCallContent, FunctionResultContent, ToolCallContent, ToolResultContent,
1405        };
1406
1407        fn wire(role: &str, ty: content::Type) -> Message {
1408            Message {
1409                role: role.to_owned(),
1410                content: MessageField::some(Content {
1411                    r#type: Some(ty),
1412                    ..Default::default()
1413                }),
1414                internal_only: false,
1415                ..Default::default()
1416            }
1417        }
1418
1419        // Assistant tool call carrying a real function name + structured args.
1420        let args: Struct = serde_json::from_str(r#"{"query":"rust"}"#).expect("args struct");
1421        let call = wire(
1422            "model",
1423            content::Type::ToolCall(Box::new(ToolCallContent {
1424                id: "call_1".to_owned(),
1425                r#type: Some(tool_call_content::Type::FunctionCall(Box::new(
1426                    FunctionCallContent {
1427                        name: "search".to_owned(),
1428                        arguments: MessageField::some(args),
1429                        ..Default::default()
1430                    },
1431                ))),
1432                ..Default::default()
1433            })),
1434        );
1435
1436        let llm_call = wire_to_llm(&call);
1437        assert_eq!(llm_call.role, Role::Assistant);
1438        assert_eq!(llm_call.content.len(), 1);
1439        match &llm_call.content[0] {
1440            LlmContent::ToolUse(tc) => {
1441                assert_eq!(tc.id, "call_1");
1442                assert_eq!(tc.name, "search", "function name must survive");
1443                let parsed: serde_json::Value =
1444                    serde_json::from_str(&tc.args_json).expect("args_json is valid json");
1445                assert_eq!(
1446                    parsed,
1447                    serde_json::json!({ "query": "rust" }),
1448                    "args must survive, not a placeholder"
1449                );
1450            }
1451            other => panic!("expected ToolUse, got {other:?}"),
1452        }
1453
1454        // Tool result carrying a real structured payload.
1455        let resp: Struct = serde_json::from_str(r#"{"answer":42}"#).expect("resp struct");
1456        let result = wire(
1457            "tool",
1458            content::Type::ToolResult(Box::new(ToolResultContent {
1459                call_id: "call_1".to_owned(),
1460                r#type: Some(tool_result_content::Type::FunctionResult(Box::new(
1461                    FunctionResultContent {
1462                        name: "search".to_owned(),
1463                        result: Some(function_result_content::Result::Response(Box::new(resp))),
1464                        ..Default::default()
1465                    },
1466                ))),
1467                ..Default::default()
1468            })),
1469        );
1470
1471        let llm_result = wire_to_llm(&result);
1472        assert_eq!(llm_result.role, Role::Tool);
1473        assert_eq!(llm_result.content.len(), 1);
1474        match &llm_result.content[0] {
1475            LlmContent::ToolResult(tr) => {
1476                assert_eq!(tr.tool_call_id, "call_1", "call id must correlate");
1477                assert!(!tr.is_error);
1478                let parsed: serde_json::Value =
1479                    serde_json::from_str(&tr.result_json).expect("result_json is valid json");
1480                // `google.protobuf.Struct` numbers are doubles, so `42`
1481                // round-trips as `42.0`; the payload itself is preserved.
1482                assert_eq!(
1483                    parsed,
1484                    serde_json::json!({ "answer": 42.0 }),
1485                    "result payload must survive, not a placeholder"
1486                );
1487            }
1488            other => panic!("expected ToolResult, got {other:?}"),
1489        }
1490    }
1491
1492    #[test]
1493    fn tool_call_message_round_trips_through_wire_to_llm_with_signature() {
1494        // The persist→replay round-trip: build the structured wire message we
1495        // persist, decode it back, and assert the call (name, args, id) AND the
1496        // provider signature all survive.
1497        let tc = ToolCall {
1498            id: "call-7".to_owned(),
1499            name: "search".to_owned(),
1500            args_json: r#"{"query":"rust"}"#.to_owned(),
1501            signature: Some("sig-abc123".to_owned()),
1502        };
1503        let wire = tool_call_message(&tc);
1504        assert_eq!(wire.role, "model");
1505        let back = wire_to_llm(&wire);
1506        match &back.content[0] {
1507            LlmContent::ToolUse(rt) => {
1508                assert_eq!(rt.id, "call-7");
1509                assert_eq!(rt.name, "search");
1510                let parsed: serde_json::Value = serde_json::from_str(&rt.args_json).unwrap();
1511                assert_eq!(parsed, serde_json::json!({ "query": "rust" }));
1512                assert_eq!(
1513                    rt.signature.as_deref(),
1514                    Some("sig-abc123"),
1515                    "thought signature must survive the wire round-trip"
1516                );
1517            }
1518            other => panic!("expected ToolUse, got {other:?}"),
1519        }
1520    }
1521
1522    #[test]
1523    fn tool_result_message_round_trips_through_wire_to_llm() {
1524        let wire = tool_result_message("call-7", r#"{"answer":42}"#);
1525        assert_eq!(wire.role, "tool");
1526        let back = wire_to_llm(&wire);
1527        match &back.content[0] {
1528            LlmContent::ToolResult(tr) => {
1529                assert_eq!(tr.tool_call_id, "call-7");
1530                let parsed: serde_json::Value = serde_json::from_str(&tr.result_json).unwrap();
1531                assert_eq!(parsed, serde_json::json!({ "answer": 42.0 }));
1532            }
1533            other => panic!("expected ToolResult, got {other:?}"),
1534        }
1535    }
1536}