pub struct RunTurnOptions {Show 21 fields
pub approved_call_ids: HashSet<(String, String, String)>,
pub approved_overrides: HashMap<(String, String, String), ApprovalOverride>,
pub denied_call_ids: HashSet<(String, String, String)>,
pub max_steps: Option<usize>,
pub stream_tx: Option<Sender<TurnStreamEvent>>,
pub native_search_allowed: bool,
pub session_approved_tools: HashMap<String, CapabilitySet>,
pub remembered_grants: HashMap<String, RememberedGrant>,
pub unattended: bool,
pub is_delegated_worker: bool,
pub escape_hatch: bool,
pub escalate_sandbox_denials: bool,
pub untrusted_context_seed: bool,
pub dispatch_recorder: Option<Arc<dyn DispatchRecorder>>,
pub clock: Option<Arc<dyn Clock + Send + Sync>>,
pub cache_hint: CacheHint,
pub delegate_descriptors: Vec<DelegateDescriptor>,
pub delegate_max_fanout: Option<u32>,
pub delegate_turn_budget: Option<u32>,
pub question_answers: Vec<VerifiedAnswer>,
pub turn_start_unix_ms: Option<u64>,
}Expand description
Options for a single run_turn invocation.
A small builder-style struct rather than a long parameter list — keeps the
hot-path call sites readable (RunTurnOptions::default()) and gives the
HITL-resume path a typed slot for the approved-call-ids set without adding
a third positional HashSet argument every existing caller would have to
thread through.
Fields§
§approved_call_ids: HashSet<(String, String, String)>Provider-assigned tool-call ids the caller has previously gathered
signed HITL approvals for. When ToolExecutor::needs_approval
returns true for a tool call, the loop checks this set: if the
call’s id is present, the tool executes as normal; if absent, the
loop pauses with a fresh PendingApproval as today.
Used by the control plane → harness resume cycle: the control plane
replays the conversation’s event log, collects every verified
approval_response that isn’t yet answered by a matching tool_result
message in the transcript, and passes the set here so the harness
re-drives the function-calling loop with the previously-paused tools
executed.
Each entry is the signed (request_id, tool_name, args_json) tuple — the
approval is bound to that exact call (#141), so a re-emitted same-id call
with different args/tool does NOT inherit the approval (it re-pauses).
approved_overrides: HashMap<(String, String, String), ApprovalOverride>Per approved call, the approver’s in-flight EDIT to apply on execution
(#67): the arguments to run in place of the model’s proposal. Keyed by
the same signed (request_id, tool_name, args_json) identity as
Self::approved_call_ids, where the tuple’s args_json is the model’s
PROPOSED args (the identity), and the ApprovalOverride carries the
approver’s replacement. A call approved without an edit has no entry here
— resolve_approved_call then runs the proposed args unchanged, so the
common approve path is untouched.
denied_call_ids: HashSet<(String, String, String)>Verified signed HITL denials as (request_id, tool_name, args_json)
tuples (a verified approval_response with approved == false).
A denial must RESOLVE the call, not leave it pending: when
ToolExecutor::needs_approval returns true for a call whose
(id, name, args) tuple is in this set, the loop emits a synthetic denial
tool_result (carrying {"approved":false,"error":"denied by human approver"}) WITHOUT executing the tool and WITHOUT re-pausing. As with
approvals the denial is bound to the exact call — the same id with
different args is a new request, not an inherited denial.
A call needing approval that is in neither Self::approved_call_ids
nor this set still pends as before.
max_steps: Option<usize>Per-agent override of the provider↔tool round-trip cap (#801). None
falls through to POLYCHROME_AGENT_MAX_STEPS (per-deployment), then the
crate’s fixed default of 8 — which is tight for the shipped coding-tool
family; a caller that knows this turn’s agent needs a larger (or
smaller) budget sets it here rather than every deployment being stuck
on one global default.
stream_tx: Option<Sender<TurnStreamEvent>>When set, the turn loop forwards each TurnStreamEvent (text delta,
tool start) as it arrives, so a caller can stream partial output
mid-turn (the harness forwards these over its bidi stream → control
plane → Slack chat.appendStream). None keeps the buffered path:
the full TurnResult is always returned regardless.
Bounded (#251): forwarding is an awaited Sender::send, so a slow
consumer on the other end (an idle Slack client, a stalled control
plane) applies real backpressure all the way back through
polyc_llm::turn::collect_turn_observed to the provider stream poll
loop, instead of letting turn-stream events accumulate in memory
without limit.
native_search_allowed: boolWhether this turn’s resolved agent is SCOPED to the provider’s native
web-search-grounding primitive (issue #1226) — i.e. its
builtinTools names polyc_capability::NATIVE_SEARCH_GROUNDING
(re-exported as polyc_tools::web::NATIVE_SEARCH_GROUNDING for that
crate’s callers).
true does not mean grounding is on for every step: the per-step gate
(see the answering loop, which is the only caller that ever sets
CompletionRequest::web_search) additionally requires
polyc_capability::Capability::ArbitraryEgress to survive this
step’s taint state before actually turning the request flag on — the
same required ⊆ granted comparison every other tool call goes
through, applied once per step since there is no per-call tool_use
for this provider-native primitive to intercept. The summarizer and
classifier build their own requests and never consult this at all.
session_approved_tools: HashMap<String, CapabilitySet>Session-scoped approvals (“approve & don’t ask again”), already
filtered to THIS turn’s caller by the control plane (the per-user
scope): tool name → the capability set the signed grant covered at
approval time (#595). A gated call to one of these tools
auto-executes WITHOUT pausing — REGARDLESS of its arguments — but only
when the grant’s covered set includes every capability the call is
currently missing AND ToolExecutor::cacheable_approval returns
true for the tool (the authoritative idempotency gate: a
non-idempotent tool can never be session-approved even if a stale
entry is present).
Scoped per-tool (not per-exact-args) because “don’t ask again” means “stop prompting me for this tool”; a model rarely repeats an identical call, so binding to exact args would make the grant near-useless. The covered-capability key keeps one convenience approval from silently widening: if the tool’s required set later grows, the old grant does not cover the new capability and the gate asks again.
Unlike Self::approved_call_ids these are NOT drained on execution.
remembered_grants: HashMap<String, RememberedGrant>Passkey-signed remembered grants for this turn’s caller, keyed by
tool name → the capability set a verified grant covers (#594). Unlike
Self::session_approved_tools (the interactive “don’t ask again” path,
which satisfies the disposition AFTER the gate escalates), a remembered
grant feeds the capability decision itself: it becomes the per-call
policy’s polyc_capability::GrantPolicy::taint_resilient set for its
covered tool, so polyc_capability::decide allows a tainted
egress/mutation the grant covers WITHOUT ever escalating — the human
authorized the exact tainted shape at the enrollment ceremony. One
decision path, no override, no leg-clearing flag.
Populated by the harness from the control-plane-verified grants on the
turn input (each grant’s principal matched this turn’s caller, its signed
coverage matched the current template coverage, and it survived
revocation/suspension). Default empty ⇒ byte-for-byte identical to a turn
with no grants: the per-call gate then builds
polyc_capability::GrantPolicy::default and every path is unchanged.
Each value is a RememberedGrant carrying both the covered set and the
opaque audit identity (grant_ref, coverage hash) the harness verified,
so a GrantReplayClear the loop records stamps its identity from birth.
unattended: boolWhether this turn runs unattended — a trigger-originated firing of an enrollment conversation with no human present (#623). The control plane sets it ONLY for that path (an explicit wire flag, never inferred from the conversation-id shape here).
When true, a gated call the capability decision would ESCALATE (no live
grant covers it, a coverage break, or an off-shape call) does NOT pause
with a PendingApproval — there is no one to answer it and ADR 0003
forbids park-and-resume on this path. It resolves fail-closed to a
denial-with-reason: the model receives a legible tool-result error (so it
can finish the turn without the tool), the call surfaces on
TurnResult::unattended_denials for the control plane to record as a
durable audit event, and the turn runs to a normal end. The next scheduled
firing is the retry.
Default false ⇒ every attended turn is byte-for-byte unchanged: an
escalation still pauses with a PendingApproval exactly as today.
is_delegated_worker: boolWhether this turn IS a delegated worker’s own nested turn
(run_delegate_call), as opposed to a top-level or orchestrator
turn. __delegate_to already caps delegation depth at one by never
resolving delegate_descriptors for a nested call, but __handoff_to
has no equivalent depth cap of its own: it’s advertised
unconditionally by run_turn/run_turn_with and matched by tool
NAME regardless of advertisement. Without this flag a worker that
calls (or hallucinates calling) __handoff_to would suspend its own
nested turn with a pending_handoff the delegate machinery has no way
to surface — the orphaned request silently degrades into
run_delegate_call’s "worker produced no answer" (ForcedCompletion
also skips a turn with a pending handoff). When true, the reserved
spec is never advertised AND a matching tool call is never treated as
a handoff — it resolves through the ordinary unknown-tool path
instead, exactly like any other unadvertised name.
Default false ⇒ every non-delegated turn is byte-for-byte unchanged.
escape_hatch: boolEnables the fuzzy-match escape hatch (#582, invariant 9): when the
model calls a tool name that was NOT advertised this turn, the loop
builds a retrieval query from the call itself (the name split into
words plus the argument text — the model’s own expression of the
capability it needs), asks ToolExecutor::recover_unadvertised for
the closest not-yet-advertised tools, and — at most ONCE per turn —
appends the matches to the advertised set so the model can re-issue
the call against a real tool. The failed call resolves to a synthetic
result naming the newly available tools; every firing is logged as a
false-negative retrieval miss. A second unadvertised call in the same
turn (same or different name) gets the ordinary unknown-tool result.
Default false ⇒ byte-for-byte today’s behavior: an unadvertised call
resolves however the executor answers it (typically an unknown-tool
error result). The harness sets this from the wire retrieval config’s
escape_hatch knob, resolved control-plane-side.
escalate_sandbox_denials: boolEnable the graduated-approval sandbox-denial ESCALATION (#301): when
true, a call ToolExecutor::sandbox_would_deny flags is routed
through the approval gate (pauses with a PendingApproval) instead of
being executed and returning the sandbox’s flat denial to the model. The
control plane sets this from the resolved per-persona approval policy.
Default false, so existing callers are unaffected: a sandbox-denied
call runs and surfaces its own error exactly as before.
untrusted_context_seed: boolDurable seed for the untrusted-content-in-context taint state,
computed by the control plane over the conversation’s FULL durable event
log (any quarantined_content-tagged event) and OR-ed into the agent’s
structural in-memory check (untrusted_content_in_context). Taint is
the provenance input to grant derivation: while it holds, the granted
set loses arbitrary egress and external mutation.
The structural check only sees untrusted content that is still a live
LlmContent::ToolResult in the projected transcript. History compaction
folds older tool results into a single System summary message — erasing
the ToolResult the check keys on — and a non-principal participant’s
chat text is never a ToolResult at all. In both cases the durable log
still carries the quarantined provenance, so the control plane reads it
there and passes the verdict in here. true keeps the taint state live
even when the transcript looks clean; the containment escalation then
still fires.
Default false: a conversation with no durable untrusted provenance (and
no multi-party input) is unaffected, so a first egress on a genuinely
clean context still runs unattended.
dispatch_recorder: Option<Arc<dyn DispatchRecorder>>Signs + records dispatch mutations (#67, #539/#540) before they apply.
When None (the default), pre_dispatch Modify/InjectContext and
post_dispatch redactions are NOT applied — the proposed call runs and
the raw result stands — so a policy mutation is inert unless a signer is
wired. When present, each mutation is recorded first and applied only on
success (fail-closed).
clock: Option<Arc<dyn Clock + Send + Sync>>The turn’s clock and jitter source (#656). When None (the default) the
turn wires retry::RealClock — real wall time for jitter entropy and a
real timer for the retry backoff — so production behaves exactly as
before. A test supplies a virtual clock with a fixed jitter seed so the
retry backoff (the turn loop’s only non-determinism) replays identically
and can be stepped without a wall-clock wait.
cache_hint: CacheHintProvider prompt-caching hint for this turn (#629).
When CacheHint::StablePrefix, each step’s CompletionRequest marks
the stable prefix — the system text plus the tool-spec set built once per
turn (#628) — as cacheable, so a provider that supports prompt caching
skips re-processing it on every step (the biggest latency lever on a
multi-step turn). A provider without caching ignores it. Default
CacheHint::None ⇒ no caching, so auxiliary calls that build their own
options are unaffected. The control plane sets it from its turn-boundary
config snapshot, so the knob lands at a turn boundary, never as a compiled
constant.
delegate_descriptors: Vec<DelegateDescriptor>This turn’s resolved __delegate_to targets (#870), one entry per
live can_delegate_to entry the bound Agent declares — each a
complete, self-contained worker configuration the control plane
resolved at dispatch. run_turn_with advertises the reserved
delegate::DELEGATE_TOOL_NAME tool ONLY when this is non-empty; a
call to it is resolved by find_delegate_descriptor and dispatched
as a nested, context-isolated run_turn_with call that joins the SAME
batch’s ordinary tool futures (contrast HandoffRequest, which
short-circuits the batch). Default empty ⇒ byte-for-byte identical to
a turn with no delegation targets: no tool is advertised, so a model
that never sees the name can’t emit it.
delegate_max_fanout: Option<u32>Fan-out width cap for this turn (#874): the maximum number of
__delegate_to calls allowed in a SINGLE batch/step — resolved
control-plane-side from the bound agent’s Agent.delegateMaxFanout
(see polyc_control_plane::delegate::resolve_delegate_max_fanout).
None ⇒ this crate’s own DEFAULT_DELEGATE_MAX_FANOUT, clamped
to DELEGATE_MAX_FANOUT_CEILING regardless of source — a caller
that resolves a wire value ALREADY clamps it, but this crate clamps
again defensively so a directly-constructed RunTurnOptions (a
test, or a future caller) can’t accidentally exceed the ceiling
either. A __delegate_to call beyond the cap, counted within the
SAME batch in source order, resolves to a structured error result —
it is never queued, never silently dropped, and never counts as an
executed delegation for forensic/usage purposes (no
DelegateRecord is produced for it).
delegate_turn_budget: Option<u32>Turn-scoped total delegate-call budget (#874): the maximum number
of __delegate_to calls this turn may dispatch ACROSS ALL its
batches/steps — not just one batch. Bounds a pathological
re-decompose-every-step loop from spawning unbounded workers over a
long-running turn, complementing Self::delegate_max_fanout’s
per-batch bound. None ⇒ DEFAULT_DELEGATE_TURN_BUDGET, clamped
to DELEGATE_TURN_BUDGET_CEILING. A call beyond the turn budget
resolves to a structured error exactly like an over-fan-out call.
question_answers: Vec<VerifiedAnswer>Verified, signed answers to ask_question questions this conversation
gathered since the turn paused (#1660) — the question-pause SIBLING
of Self::approved_call_ids, not a reuse of it. Populated by the
harness from control-plane-verified question_response events on the
turn input; each entry is bound to its exact (call_id, index, question_args_json) identity, so a re-emitted ask_question call
with different questions does not inherit an unrelated answer.
Consumed by step::QuestionResumePrePass: a dangling ask_question
tool_use in the resumed transcript resolves once every question in
its call has a matching entry here; any question still missing
re-pauses the turn exactly as a fresh call would. Default empty ⇒
byte-for-byte identical to a turn with no pending questions.
turn_start_unix_ms: Option<u64>This turn’s frozen dispatch clock (#1323), in Unix milliseconds:
the SAME value the control plane freezes once per dispatch, renders
as the top-level turn_start_block system message, and records as
ModelCallRecord.captured_clock_unix_ms. run_delegate_call renders
it into a worker’s own turn-start system message so a delegated
worker learns the turn’s start instant exactly like the top-level
turn does, instead of improvising one against its training-data era.
Never read from a fresh clock on this path: replay determinism
(INV-11) requires the worker’s rendered prompt to reproduce
byte-identically, which a second, independently-timed read could not
guarantee. None means no turn-start stamp is rendered for any
worker this turn delegates to (the caller didn’t resolve one, or the
instant was underivable) — a worker told nothing is safer than one
told a wrong time, mirroring turn_start_block’s own rule.
Trait Implementations§
Source§impl Clone for RunTurnOptions
impl Clone for RunTurnOptions
Source§fn clone(&self) -> RunTurnOptions
fn clone(&self) -> RunTurnOptions
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreSource§impl Debug for RunTurnOptions
impl Debug for RunTurnOptions
Source§impl Default for RunTurnOptions
impl Default for RunTurnOptions
Source§fn default() -> RunTurnOptions
fn default() -> RunTurnOptions
Auto Trait Implementations§
impl !RefUnwindSafe for RunTurnOptions
impl !UnwindSafe for RunTurnOptions
impl Freeze for RunTurnOptions
impl Send for RunTurnOptions
impl Sync for RunTurnOptions
impl Unpin for RunTurnOptions
impl UnsafeUnpin for RunTurnOptions
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more