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}