Skip to main content

polyc_llm/
request.rs

1//! Request-side LLM types: [`CompletionRequest`], [`Message`], [`Content`],
2//! [`ToolSpec`], [`ToolChoice`], and [`JsonSchema`].
3
4use serde::{Deserialize, Serialize};
5
6// ── CompletionRequest ─────────────────────────────────────────────────────────
7
8/// Top-level request to an LLM provider.
9///
10/// Construct via [`CompletionRequest::new`], then populate fields directly.
11///
12/// `#[non_exhaustive]`: provider-shaped sampling fields (`top_p`, `seed`, …)
13/// will be added over time; build through [`new`](CompletionRequest::new) so
14/// such additions stay non-breaking.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct CompletionRequest {
18    /// The model identifier (e.g. `"fast-2"`, `"reasoning-pro"`).
19    pub model: String,
20    /// Optional system prompt text prepended before the conversation.
21    pub system: Option<String>,
22    /// Ordered list of messages in the conversation.
23    pub messages: Vec<Message>,
24    /// Tool definitions available to the model.
25    pub tools: Vec<ToolSpec>,
26    /// How the model should decide whether to call a tool.
27    pub tool_choice: ToolChoice,
28    /// When set, instructs the provider to return structured JSON output.
29    pub response_format: Option<JsonSchema>,
30    /// Maximum number of tokens the model may generate.
31    pub max_tokens: Option<u32>,
32    /// Sampling temperature in `[0.0, 2.0]`. Lower is more deterministic.
33    pub temperature: Option<f32>,
34    /// Token sequences that cause the model to stop generating.
35    pub stop: Vec<String>,
36    /// When `true`, the model may search the public web to ground its answer.
37    ///
38    /// This is a provider-agnostic capability hint: a provider maps it to its
39    /// native mechanism (Vertex Gemini → the `googleSearch` grounding tool,
40    /// alongside any `tools` function declarations) and a provider without web
41    /// search ignores it. Defaults to `false`; the agent's answering loop
42    /// (`run_turn`) sets it from its `RunTurnOptions.web_search`, so auxiliary
43    /// calls (summarization, classification) that bypass that loop never offer
44    /// search.
45    pub web_search: bool,
46    /// Provider-agnostic hint about caching the request's stable prefix.
47    ///
48    /// See [`CacheHint`]. Defaults to [`CacheHint::None`]; the agent's answering
49    /// loop sets it from its `RunTurnOptions` so a multi-step turn can cache the
50    /// system text + tool-spec block once and skip re-processing it each step.
51    pub cache: CacheHint,
52}
53
54impl CompletionRequest {
55    /// Creates a new request for the given `model` with sensible defaults:
56    /// empty `messages`, `tools`, and `stop` lists; `tool_choice` set to
57    /// [`ToolChoice::Auto`]; all optional fields `None`.
58    #[must_use]
59    pub fn new(model: impl Into<String>) -> Self {
60        Self {
61            model: model.into(),
62            system: None,
63            messages: Vec::new(),
64            tools: Vec::new(),
65            tool_choice: ToolChoice::Auto,
66            response_format: None,
67            max_tokens: None,
68            temperature: None,
69            stop: Vec::new(),
70            web_search: false,
71            cache: CacheHint::None,
72        }
73    }
74}
75
76// ── CacheHint ─────────────────────────────────────────────────────────────────
77
78/// Provider-agnostic hint about caching a request's stable prefix.
79///
80/// A multi-step turn re-sends a growing conversation behind an unchanging
81/// prefix — the system text plus the tool-spec block (built once per turn). This
82/// hint marks that prefix as stable so a provider that supports prompt caching
83/// can cache it and skip re-processing it on every step, the single biggest
84/// latency lever on multi-step turns. It is purely advisory: a provider maps it
85/// to its native mechanism, and a provider with no caching ignores it with no
86/// behavior change.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
88#[serde(rename_all = "snake_case")]
89#[non_exhaustive]
90pub enum CacheHint {
91    /// No caching is requested; a provider processes the full prompt on every
92    /// call. The default so auxiliary calls that bypass the answering loop never
93    /// opt in accidentally.
94    #[default]
95    None,
96    /// The request's leading stable prefix (system text + tool-spec block) stays
97    /// byte-identical across the steps of a turn — and across turns of a
98    /// conversation — so a provider that supports prompt caching should cache it.
99    StablePrefix {
100        /// Optional stable per-conversation identifier a provider MAY use to pin
101        /// cache routing (a provider maps it to its own cache-key field). A
102        /// provider with only implicit prefix caching, or none at all, ignores
103        /// it. `None` leaves routing to the provider's implicit prefix match.
104        key: Option<String>,
105    },
106}
107
108impl CacheHint {
109    /// Reconstructs a hint from its flat single-string form: a non-empty `key`
110    /// is a keyed [`CacheHint::StablePrefix`]; an empty one is
111    /// [`CacheHint::None`]. The inverse of [`Self::key`] — together they carry
112    /// the hint across a boundary that has one string field (the control-plane
113    /// → harness turn input), which cannot express a keyless stable-prefix
114    /// hint (the control plane always keys by conversation).
115    #[must_use]
116    pub fn from_key(key: String) -> Self {
117        if key.is_empty() {
118            Self::None
119        } else {
120            Self::StablePrefix { key: Some(key) }
121        }
122    }
123
124    /// The cache-routing key this hint carries, if any. See [`Self::from_key`]
125    /// for the flat form the pair round-trips.
126    #[must_use]
127    pub fn key(&self) -> Option<&str> {
128        match self {
129            Self::StablePrefix { key: Some(key) } => Some(key),
130            _ => None,
131        }
132    }
133}
134
135// ── Message ───────────────────────────────────────────────────────────────────
136
137/// A single turn in a conversation, composed of one or more [`Content`] parts.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Message {
140    /// The participant that produced this message.
141    pub role: Role,
142    /// Ordered content blocks that make up the message body.
143    pub content: Vec<Content>,
144}
145
146impl Message {
147    /// Creates a [`Role::User`] message with a single [`Content::Text`] block.
148    #[must_use]
149    pub fn user(text: impl Into<String>) -> Self {
150        Self {
151            role: Role::User,
152            content: vec![Content::Text(text.into())],
153        }
154    }
155
156    /// Creates a [`Role::Assistant`] message with a single [`Content::Text`] block.
157    #[must_use]
158    pub fn assistant(text: impl Into<String>) -> Self {
159        Self {
160            role: Role::Assistant,
161            content: vec![Content::Text(text.into())],
162        }
163    }
164
165    /// Creates a [`Role::System`] message with a single [`Content::Text`] block.
166    #[must_use]
167    pub fn system(text: impl Into<String>) -> Self {
168        Self {
169            role: Role::System,
170            content: vec![Content::Text(text.into())],
171        }
172    }
173}
174
175// ── Role ──────────────────────────────────────────────────────────────────────
176
177/// The participant role for a [`Message`].
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum Role {
182    /// A human turn.
183    User,
184    /// A model-generated turn.
185    Assistant,
186    /// A system-level instruction (not all providers support this as a role).
187    System,
188    /// A tool-result turn injected back into the conversation.
189    Tool,
190}
191
192// ── Content ───────────────────────────────────────────────────────────────────
193
194/// A single content block within a [`Message`].
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197#[non_exhaustive]
198pub enum Content {
199    /// Plain text.
200    Text(String),
201    /// A tool invocation emitted by the model.
202    ToolUse(ToolCall),
203    /// The result of a prior [`Content::ToolUse`], fed back to the model.
204    ToolResult(ToolResult),
205    /// A reference to an image (HTTP URL or `data:` URI).
206    Image(ImageRef),
207}
208
209impl Content {
210    /// Wraps `s` in a [`Content::Text`] variant.
211    #[must_use]
212    pub fn text(s: impl Into<String>) -> Self {
213        Self::Text(s.into())
214    }
215
216    /// Constructs a [`Content::ToolUse`] block.
217    #[must_use]
218    pub fn tool_use(
219        id: impl Into<String>,
220        name: impl Into<String>,
221        args_json: impl Into<String>,
222    ) -> Self {
223        Self::ToolUse(ToolCall {
224            id: id.into(),
225            name: name.into(),
226            args_json: args_json.into(),
227            signature: None,
228            approval_turn_id: None,
229        })
230    }
231
232    /// Constructs a [`Content::ToolUse`] block carrying an opaque
233    /// provider-specific `signature` (e.g. a thinking model's thought
234    /// signature, which some providers require echoed back on the next
235    /// request that includes this call).
236    #[must_use]
237    pub fn tool_use_signed(
238        id: impl Into<String>,
239        name: impl Into<String>,
240        args_json: impl Into<String>,
241        signature: Option<String>,
242    ) -> Self {
243        Self::ToolUse(ToolCall {
244            id: id.into(),
245            name: name.into(),
246            args_json: args_json.into(),
247            signature,
248            approval_turn_id: None,
249        })
250    }
251
252    /// Constructs a [`Content::ToolResult`] block.
253    ///
254    /// `first_party` is the ingestion-time provenance bit (see
255    /// [`ToolResult::first_party`]) — pass the caller's already-computed
256    /// verdict (the static per-tool-name check for an ordinary tool, or the
257    /// worker-derived verdict for a `__delegate_to` result), not a fixed
258    /// default: the whole point of carrying this on [`ToolResult`] is that
259    /// the live same-turn taint scan needs the REAL per-call answer.
260    #[must_use]
261    pub fn tool_result(
262        tool_call_id: impl Into<String>,
263        result_json: impl Into<String>,
264        is_error: bool,
265        first_party: bool,
266    ) -> Self {
267        Self::ToolResult(ToolResult {
268            tool_call_id: tool_call_id.into(),
269            result_json: result_json.into(),
270            is_error,
271            first_party,
272        })
273    }
274
275    /// Constructs a [`Content::Image`] block.
276    #[must_use]
277    pub fn image(url: impl Into<String>, mime_type: Option<String>) -> Self {
278        Self::Image(ImageRef {
279            url: url.into(),
280            mime_type,
281        })
282    }
283}
284
285// ── ToolCall ──────────────────────────────────────────────────────────────────
286
287/// A tool call emitted by the model inside an assistant [`Message`].
288///
289/// Mirrors the wire-side `polychrome.agent.v1.ToolCall`; surfaced inside a
290/// [`Content::ToolUse`] block.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ToolCall {
293    /// Provider-assigned call identifier, used to correlate with [`ToolResult`].
294    pub id: String,
295    /// Name of the tool being called.
296    pub name: String,
297    /// Arguments serialized as a JSON string (opaque at this layer).
298    pub args_json: String,
299    /// Opaque, provider-specific signature attached to this call (e.g. a
300    /// thinking model's thought signature). Some providers require it to be
301    /// echoed back verbatim on the follow-up request that carries this call
302    /// in the history; `None` when the provider emits no such token.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub signature: Option<String>,
305    /// Durable turn that emitted this call, when reconstructed from the event
306    /// log for an approval resume. Provider output leaves it unset.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub approval_turn_id: Option<String>,
309}
310
311// ── ToolResult ────────────────────────────────────────────────────────────────
312
313/// The result of executing a tool, fed back to the model as a [`Content`] block.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ToolResult {
316    /// Matches the [`ToolCall::id`] this result corresponds to.
317    pub tool_call_id: String,
318    /// Serialized JSON payload returned by the tool executor.
319    pub result_json: String,
320    /// `true` when the tool raised an error rather than producing output.
321    pub is_error: bool,
322    /// Ingestion-time provenance: `true` when the producing tool is
323    /// first-party and closed-domain (does not ingest untrusted open-world
324    /// content). Mirrors `polyc_proto`'s wire `ToolResultContent.first_party`
325    /// — this is the SAME bit, carried on the in-memory, provider-facing
326    /// representation so the live same-turn taint scan
327    /// (`untrusted_content_in_context`) reads a per-call signal instead of
328    /// re-deriving it from the tool name. A `__delegate_to` call's result
329    /// reflects what the delegated worker actually touched, not a static
330    /// per-tool-name check — see `polyc_agent`'s `DelegateRecord::first_party`.
331    pub first_party: bool,
332}
333
334// ── ImageRef ──────────────────────────────────────────────────────────────────
335
336/// A reference to an image attached to a [`Message`].
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338pub struct ImageRef {
339    /// HTTP URL or `data:` URI for the image bytes.
340    pub url: String,
341    /// Optional MIME type hint (e.g. `"image/png"`).
342    pub mime_type: Option<String>,
343}
344
345// ── ToolSpec ──────────────────────────────────────────────────────────────────
346
347/// Declaration of a tool the model may invoke.
348///
349/// Mirrors the MCP `Tool` shape so built-in and connector tools are described
350/// uniformly: `title` is the MCP `title` annotation, and `read_only` /
351/// `destructive` / `open_world` are the `readOnlyHint` / `destructiveHint` /
352/// `openWorldHint` annotations. Build with [`ToolSpec::new`] + the chainable
353/// setters rather than a struct literal.
354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
355// The flags are independent MCP-style annotation hints (each serialized as an
356// optional bool); folding them into an enum/bitflags would fight serde's
357// per-field optional-default model for no gain.
358#[allow(clippy::struct_excessive_bools)]
359pub struct ToolSpec {
360    /// Unique tool name; the model references this when emitting a [`ToolCall`].
361    pub name: String,
362    /// Human-readable description of what the tool does.
363    pub description: String,
364    /// JSON Schema object describing the tool's argument shape.
365    pub schema_json: serde_json::Value,
366    /// MCP-style human display name for this tool (the `title` annotation):
367    /// a friendly label shown to people (e.g. in an approval prompt) while the
368    /// machine-facing [`name`](ToolSpec::name) stays the audit identifier.
369    ///
370    /// `None` means no curated label was provided; callers derive a display
371    /// name from [`name`](ToolSpec::name) via `polyc_proto::humanize_tool_name`.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub title: Option<String>,
374    /// Intrinsic "this tool is side-effecting / requires human approval" flag.
375    ///
376    /// When `true` the tool must be routed through the harness's
377    /// human-in-the-loop (HITL) approval gate before it executes, even when no
378    /// operator-side allow-list names it. Pure, read-only tools leave this
379    /// `false`.
380    ///
381    /// This is the per-tool generalization of the old hard-coded
382    /// approval-by-name list: it maps from the MCP `destructiveHint` tool
383    /// annotation, so an upstream connector that advertises a destructive tool
384    /// is gated per-tool rather than per-connector.
385    ///
386    /// Defaults to `false` and is skipped when serializing the safe default, so
387    /// older payloads that omit the field still deserialize as ungated.
388    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
389    pub needs_approval: bool,
390    /// MCP `readOnlyHint`: the tool does not modify its environment. Advisory —
391    /// surfaced to the model and usable by callers (e.g. sandbox-mode gating
392    /// never gates a read-only tool). Defaults to `false`.
393    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
394    pub read_only: bool,
395    /// MCP `destructiveHint`: the tool may perform irreversible / side-effecting
396    /// changes. Drives sandbox-mode gating (destructive tools gate in read-only
397    /// mode) and maps from a connector's `destructiveHint`. Defaults to `false`.
398    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
399    pub destructive: bool,
400    /// MCP `openWorldHint`: the tool may interact with an open world of external
401    /// entities, so its RESULT can carry content of uncontrolled provenance. This
402    /// is the INBOUND ("untrusted content in context") leg of the lethal
403    /// trifecta — a tool with `open_world = true` seeds the leg when its result
404    /// is in context (see `polyc_agent`'s `untrusted_content_in_context`). The
405    /// built-in web fetchers set it; the sandbox coding tools do not. For a
406    /// dialed connector it is read from `openWorldHint` at connect. Defaults to
407    /// `false`.
408    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
409    pub open_world: bool,
410    /// Whether a single human approval for this tool may be *remembered* for the
411    /// rest of a conversation session (per-caller) and reused for later calls,
412    /// instead of re-prompting every time. Defaults to `false`.
413    ///
414    /// The session grant is **per-tool, not per-argument**: approving one call
415    /// authorizes the tool for ANY arguments for the rest of the session. So set
416    /// this ONLY when the tool's ENTIRE argument space is safe to auto-run within
417    /// the sandbox boundary — i.e. it is both idempotent AND can't reach anything
418    /// the human wouldn't have blanket-approved. `file_read` qualifies because it
419    /// is workspace-confined (`coding::workspace::resolve` rejects absolute/`..`
420    /// paths), so "approve one read" only ever grants reads inside the sandbox.
421    /// NEVER set it on a tool that spends money, has side effects, or whose risk
422    /// varies by argument (e.g. it could read/write outside a confined root):
423    /// those must get a fresh decision per call.
424    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
425    pub cacheable_approval: bool,
426    /// Structural "this tool pauses the turn to talk to a person" flag.
427    ///
428    /// A caller that dispatches into a conversation with no person present
429    /// (a routine's fire conversation) must exclude every spec with this set
430    /// from the advertised tool surface — the model can never pause a turn
431    /// nobody is there to answer. Defaults to `false`.
432    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
433    pub interactive: bool,
434}
435
436/// Serializes `value` with every object's keys sorted, so the result does
437/// not depend on the value's in-memory key order (`serde_json::Value`'s
438/// object type orders by insertion under some crate-graph feature
439/// selections, by key under others).
440fn canonical_json(value: &serde_json::Value, out: &mut String) {
441    match value {
442        serde_json::Value::Array(items) => {
443            out.push('[');
444            for (index, item) in items.iter().enumerate() {
445                if index > 0 {
446                    out.push(',');
447                }
448                canonical_json(item, out);
449            }
450            out.push(']');
451        }
452        serde_json::Value::Object(map) => {
453            let mut keys: Vec<&String> = map.keys().collect();
454            keys.sort();
455            out.push('{');
456            for (index, key) in keys.iter().enumerate() {
457                if index > 0 {
458                    out.push(',');
459                }
460                out.push_str(&serde_json::to_string(key).unwrap_or_default());
461                out.push(':');
462                canonical_json(&map[*key], out);
463            }
464            out.push('}');
465        }
466        // Null, Bool, Number, and String carry no nested key order to
467        // canonicalize — their own `to_string` is already deterministic.
468        other => out.push_str(&other.to_string()),
469    }
470}
471
472impl ToolSpec {
473    /// A canonical, deterministic hash of this tool's descriptor:
474    /// its `name`, `schema_json`, and the structural annotations
475    /// (`needs_approval`, `read_only`, `destructive`, `open_world`,
476    /// `cacheable_approval`, `interactive`) — every field that changes what
477    /// the tool DOES or how it is gated. `title` and `description` are
478    /// presentation only and excluded, so a copy-edit does not churn every
479    /// grant bound to the tool.
480    ///
481    /// A routine-fire tool grant signs this hash to preserve the reviewed
482    /// descriptor for drift detection and audit: a later change to any
483    /// covered field — a schema tweak, a newly-set annotation — changes the
484    /// hash, and a call the grant still admits despite the mismatch is
485    /// recorded as drifted, not refused. The grant's covered capability set
486    /// and its other signed bindings are what govern admission.
487    /// `schema_json`'s object keys are sorted before hashing (`canonical_json`),
488    /// so the result is independent of the value's in-memory key order.
489    ///
490    /// Returns a lower-hex SHA-256 digest prefixed `sha256:`.
491    #[must_use]
492    pub fn descriptor_hash(&self) -> String {
493        use sha2::{Digest as _, Sha256};
494        let mut schema = String::new();
495        canonical_json(&self.schema_json, &mut schema);
496        let mut hasher = Sha256::new();
497        hasher.update(self.name.as_bytes());
498        hasher.update([0x1f]);
499        hasher.update(schema.as_bytes());
500        hasher.update([0x1f]);
501        hasher.update([
502            u8::from(self.needs_approval),
503            u8::from(self.read_only),
504            u8::from(self.destructive),
505            u8::from(self.open_world),
506            u8::from(self.cacheable_approval),
507            u8::from(self.interactive),
508        ]);
509        let digest = hasher.finalize();
510        let hex = digest.iter().fold(String::new(), |mut acc, b| {
511            use std::fmt::Write as _;
512            let _ = write!(acc, "{b:02x}");
513            acc
514        });
515        format!("sha256:{hex}")
516    }
517}
518
519impl ToolSpec {
520    /// A tool spec with the given `name`, `description`, and JSON-Schema
521    /// `schema_json`; all annotations default off. Chain the setters below to
522    /// add a title or mark it read-only / destructive / approval-gated.
523    #[must_use]
524    pub fn new(
525        name: impl Into<String>,
526        description: impl Into<String>,
527        schema_json: serde_json::Value,
528    ) -> Self {
529        Self {
530            name: name.into(),
531            description: description.into(),
532            schema_json,
533            title: None,
534            needs_approval: false,
535            read_only: false,
536            destructive: false,
537            open_world: false,
538            cacheable_approval: false,
539            interactive: false,
540        }
541    }
542
543    /// Set the MCP `title` display annotation.
544    #[must_use]
545    pub fn titled(mut self, title: impl Into<String>) -> Self {
546        self.title = Some(title.into());
547        self
548    }
549
550    /// Mark the tool read-only (MCP `readOnlyHint`).
551    #[must_use]
552    pub const fn read_only(mut self) -> Self {
553        self.read_only = true;
554        self
555    }
556
557    /// Mark the tool destructive (MCP `destructiveHint`).
558    #[must_use]
559    pub const fn destructive(mut self) -> Self {
560        self.destructive = true;
561        self
562    }
563
564    /// Mark the tool open-world (MCP `openWorldHint`): its result can carry
565    /// content of uncontrolled provenance, seeding the untrusted-content leg.
566    #[must_use]
567    pub const fn open_world(mut self) -> Self {
568        self.open_world = true;
569        self
570    }
571
572    /// Mark a single approval for this tool as rememberable for the rest of a
573    /// conversation session (per-caller). Only set this on idempotent tools (see
574    /// [`Self::cacheable_approval`] field docs).
575    #[must_use]
576    pub const fn cacheable_approval(mut self) -> Self {
577        self.cacheable_approval = true;
578        self
579    }
580
581    /// Mark the tool as intrinsically requiring HITL approval (independent of
582    /// sandbox mode — e.g. `paid_fetch`).
583    #[must_use]
584    pub const fn approval_required(mut self) -> Self {
585        self.needs_approval = true;
586        self
587    }
588
589    /// Mark the tool interactive: calling it pauses the turn to ask a person
590    /// a question. A dispatch into a conversation with no person present
591    /// must exclude this spec from the advertised surface.
592    #[must_use]
593    pub const fn interactive(mut self) -> Self {
594        self.interactive = true;
595        self
596    }
597}
598
599/// The model-facing note appended to a gated tool's [`ToolSpec::description`]
600/// (`#743`) so the tool is self-describing as propose-first.
601///
602/// The model stops guessing at approval/execution status, which the runtime —
603/// never the model — owns and reports (the approval card, then the resume's
604/// genuine result narration).
605///
606/// This lives here, in `polyc-llm`, rather than in `polyc-agent` or
607/// `polyc-capability`, because the layer graph runs foundation ⇒ component:
608/// `polyc-agent` composes the intrinsic [`ToolSpec::needs_approval`] flag with
609/// the capability gate to decide WHICH specs are gated, then appends this
610/// shared literal ONCE per turn at spec-pinning time — so every provider
611/// builder that forwards [`ToolSpec::description`] verbatim carries the same
612/// wording without either of them depending back down into this crate's
613/// caller.
614///
615/// Per the project's copy rules: a complete, warm, plain sentence — no
616/// vendor names, no internal jargon, no apology words.
617pub static GATED_TOOL_APPROVAL_NOTE: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
618    format!(
619        "Calling this pauses while a person reviews the request. \
620             {APPROVAL_STATUS_GROUND_RULE} A result from this tool means it was approved and \
621             has already run."
622    )
623});
624
625/// The single authoritative statement of who reports approval status: the
626/// runtime, never the model.
627///
628/// Every model-facing note that touches approval status — the gated-tool
629/// description note ([`GATED_TOOL_APPROVAL_NOTE`]) and the agent loop's
630/// resume ground-truth note — embeds this sentence verbatim, so the
631/// guidance is defined once and cannot drift between the moments it is
632/// given. It lives here, in `polyc-llm`, for the same layer reason as
633/// [`GATED_TOOL_APPROVAL_NOTE`]: a foundation crate both the agent loop
634/// and every provider builder can reach without depending on each other.
635pub const APPROVAL_STATUS_GROUND_RULE: &str = "Never describe approval status yourself — the \
636    system shows what's pending and what ran.";
637
638// ── ToolChoice ────────────────────────────────────────────────────────────────
639
640/// Controls whether and how the model calls tools.
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642#[serde(rename_all = "snake_case")]
643#[non_exhaustive]
644pub enum ToolChoice {
645    /// The model decides whether to call a tool (default).
646    Auto,
647    /// The model must not call any tool.
648    None,
649    /// The model must call at least one tool.
650    Required,
651    /// Force the model to call the named tool.
652    Named(String),
653}
654
655// ── JsonSchema ────────────────────────────────────────────────────────────────
656
657/// Wrapper for a response-format JSON Schema.
658///
659/// Instructs the provider to return structured output conforming to the schema.
660/// Serializes transparently as the inner [`serde_json::Value`].
661#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(transparent)]
663pub struct JsonSchema(
664    /// The raw JSON Schema value.
665    pub serde_json::Value,
666);
667
668// ── Tests ─────────────────────────────────────────────────────────────────────
669
670#[cfg(test)]
671mod tests {
672    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
673
674    use serde_json::{Value, json};
675
676    use super::*;
677
678    #[test]
679    fn new_sets_model_and_defaults() {
680        let req = CompletionRequest::new("fast-2");
681        assert_eq!(req.model, "fast-2");
682        assert!(req.messages.is_empty());
683        assert!(req.tools.is_empty());
684        assert!(req.stop.is_empty());
685        assert!(req.system.is_none());
686        assert!(req.max_tokens.is_none());
687        assert!(req.temperature.is_none());
688        assert!(req.response_format.is_none());
689        assert_eq!(req.tool_choice, ToolChoice::Auto);
690    }
691
692    /// Hashing the same spec twice reproduces the identical digest,
693    /// and hashing it with its schema keys inserted in a different order
694    /// reproduces the SAME digest too (canonicalization is key-order
695    /// independent).
696    #[test]
697    fn descriptor_hash_is_deterministic_and_key_order_independent() {
698        let spec = ToolSpec::new(
699            "send_message",
700            "sends a message",
701            json!({"to": "string", "text": "string"}),
702        )
703        .destructive();
704        let a = spec.descriptor_hash();
705        let b = spec.descriptor_hash();
706        assert_eq!(a, b, "hashing twice must reproduce the same digest");
707        assert!(a.starts_with("sha256:"));
708
709        let reordered = ToolSpec::new(
710            "send_message",
711            "sends a message",
712            json!({"text": "string", "to": "string"}),
713        )
714        .destructive();
715        assert_eq!(
716            a,
717            reordered.descriptor_hash(),
718            "object key insertion order must not change the digest"
719        );
720    }
721
722    /// An independently-computed expected digest — not merely "call
723    /// the function twice" — pins the hash to its actual inputs.
724    #[test]
725    fn descriptor_hash_matches_an_independently_computed_digest() {
726        use sha2::{Digest as _, Sha256};
727        let spec = ToolSpec::new("grep", "search files", json!({"pattern": "string"}));
728        let mut hasher = Sha256::new();
729        hasher.update(b"grep");
730        hasher.update([0x1f]);
731        hasher.update(br#"{"pattern":"string"}"#);
732        hasher.update([0x1f]);
733        hasher.update([0u8, 0, 0, 0, 0, 0]); // every annotation off
734        let expected: String = hasher
735            .finalize()
736            .iter()
737            .map(|b| format!("{b:02x}"))
738            .collect();
739        assert_eq!(spec.descriptor_hash(), format!("sha256:{expected}"));
740    }
741
742    /// A change to any covered annotation, the name, or the schema changes
743    /// the digest — this is what makes the hash a reliable drift signal on
744    /// a grant that still admits the changed tool.
745    #[test]
746    fn descriptor_hash_is_sensitive_to_every_covered_field() {
747        let base = ToolSpec::new("grep", "search files", json!({"pattern": "string"}));
748        let base_hash = base.descriptor_hash();
749
750        let renamed = ToolSpec::new("grep2", "search files", json!({"pattern": "string"}));
751        assert_ne!(base_hash, renamed.descriptor_hash(), "name must be covered");
752
753        let reschemad = ToolSpec::new("grep", "search files", json!({"pattern": "number"}));
754        assert_ne!(
755            base_hash,
756            reschemad.descriptor_hash(),
757            "schema_json must be covered"
758        );
759
760        let variants = [
761            base.clone().approval_required(),
762            base.clone().read_only(),
763            base.clone().destructive(),
764            base.clone().open_world(),
765            base.clone().cacheable_approval(),
766            base.clone().interactive(),
767        ];
768        for (index, variant) in variants.iter().enumerate() {
769            assert_ne!(
770                base_hash,
771                variant.descriptor_hash(),
772                "annotation index {index} must be covered"
773            );
774        }
775
776        // description/title are presentation only, excluded from the hash.
777        let retitled = ToolSpec::new(
778            "grep",
779            "a different description",
780            json!({"pattern": "string"}),
781        )
782        .titled("Grep");
783        assert_eq!(
784            base_hash,
785            retitled.descriptor_hash(),
786            "description/title are presentation only and must not change the hash"
787        );
788    }
789
790    #[test]
791    fn role_serializes_to_snake_case() {
792        assert_eq!(serde_json::to_string(&Role::User).unwrap(), r#""user""#);
793        assert_eq!(
794            serde_json::to_string(&Role::Assistant).unwrap(),
795            r#""assistant""#
796        );
797        assert_eq!(serde_json::to_string(&Role::System).unwrap(), r#""system""#);
798        assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), r#""tool""#);
799    }
800
801    #[test]
802    fn role_round_trips() {
803        for role in [Role::User, Role::Assistant, Role::System, Role::Tool] {
804            let json = serde_json::to_string(&role).unwrap();
805            let back: Role = serde_json::from_str(&json).unwrap();
806            assert_eq!(back, role);
807        }
808    }
809
810    #[test]
811    fn tool_choice_unit_variants_serialize_as_strings() {
812        assert_eq!(
813            serde_json::to_string(&ToolChoice::Auto).unwrap(),
814            r#""auto""#
815        );
816        assert_eq!(
817            serde_json::to_string(&ToolChoice::None).unwrap(),
818            r#""none""#
819        );
820        assert_eq!(
821            serde_json::to_string(&ToolChoice::Required).unwrap(),
822            r#""required""#
823        );
824    }
825
826    #[test]
827    fn tool_choice_named_serializes_as_object() {
828        let tc = ToolChoice::Named("my_tool".to_owned());
829        let v: Value = serde_json::to_value(&tc).unwrap();
830        assert_eq!(v, json!({"named": "my_tool"}));
831    }
832
833    #[test]
834    fn tool_choice_round_trips() {
835        for tc in [
836            ToolChoice::Auto,
837            ToolChoice::None,
838            ToolChoice::Required,
839            ToolChoice::Named("search".to_owned()),
840        ] {
841            let json = serde_json::to_string(&tc).unwrap();
842            let back: ToolChoice = serde_json::from_str(&json).unwrap();
843            assert_eq!(back, tc);
844        }
845    }
846
847    #[test]
848    fn content_text_constructor() {
849        let c = Content::text("hello");
850        assert!(matches!(c, Content::Text(s) if s == "hello"));
851    }
852
853    #[test]
854    fn content_tool_use_constructor() {
855        let c = Content::tool_use("call-1", "search", r#"{"q":"rust"}"#);
856        match c {
857            Content::ToolUse(tu) => {
858                assert_eq!(tu.id, "call-1");
859                assert_eq!(tu.name, "search");
860                assert_eq!(tu.args_json, r#"{"q":"rust"}"#);
861            }
862            _ => panic!("wrong variant"),
863        }
864    }
865
866    #[test]
867    fn content_tool_result_constructor() {
868        let c = Content::tool_result("call-1", r#"{"result":"ok"}"#, false, true);
869        match c {
870            Content::ToolResult(tr) => {
871                assert_eq!(tr.tool_call_id, "call-1");
872                assert_eq!(tr.result_json, r#"{"result":"ok"}"#);
873                assert!(!tr.is_error);
874                assert!(tr.first_party);
875            }
876            _ => panic!("wrong variant"),
877        }
878    }
879
880    #[test]
881    fn content_image_constructor() {
882        let c = Content::image("https://example.com/img.png", Some("image/png".to_owned()));
883        match c {
884            Content::Image(img) => {
885                assert_eq!(img.url, "https://example.com/img.png");
886                assert_eq!(img.mime_type.as_deref(), Some("image/png"));
887            }
888            _ => panic!("wrong variant"),
889        }
890    }
891
892    #[test]
893    fn message_user_constructor() {
894        let m = Message::user("hi");
895        assert_eq!(m.role, Role::User);
896        assert_eq!(m.content.len(), 1);
897        assert!(matches!(&m.content[0], Content::Text(s) if s == "hi"));
898    }
899
900    #[test]
901    fn message_assistant_constructor() {
902        let m = Message::assistant("hello back");
903        assert_eq!(m.role, Role::Assistant);
904        assert_eq!(m.content.len(), 1);
905        assert!(matches!(&m.content[0], Content::Text(s) if s == "hello back"));
906    }
907
908    #[test]
909    fn message_system_constructor() {
910        let m = Message::system("You are helpful.");
911        assert_eq!(m.role, Role::System);
912        assert_eq!(m.content.len(), 1);
913        assert!(matches!(&m.content[0], Content::Text(_)));
914    }
915
916    #[test]
917    fn tool_use_args_json_preserved_as_opaque_string() {
918        let original = r#"{"nested":{"key":42},"arr":[1,2,3]}"#;
919        let c = Content::tool_use("id-42", "complex_tool", original);
920        let serialized = serde_json::to_string(&c).unwrap();
921        let back: Content = serde_json::from_str(&serialized).unwrap();
922        match back {
923            Content::ToolUse(tu) => assert_eq!(tu.args_json, original),
924            _ => panic!("wrong variant"),
925        }
926    }
927
928    #[test]
929    fn completion_request_round_trips_all_content_variants() {
930        let mut req = CompletionRequest::new("test-model");
931        req.system = Some("Be concise.".to_owned());
932        req.max_tokens = Some(256);
933        req.temperature = Some(0.7);
934        req.stop = vec!["<end>".to_owned()];
935        req.tool_choice = ToolChoice::Named("calculator".to_owned());
936        req.response_format = Some(JsonSchema(json!({"type": "object"})));
937        req.tools = vec![ToolSpec::new(
938            "calculator",
939            "Evaluates math expressions.",
940            json!({"type": "object", "properties": {"expr": {"type": "string"}}}),
941        )];
942        req.messages = vec![
943            Message::user("Compute 2+2"),
944            Message {
945                role: Role::Assistant,
946                content: vec![Content::tool_use(
947                    "call-1",
948                    "calculator",
949                    r#"{"expr":"2+2"}"#,
950                )],
951            },
952            Message {
953                role: Role::Tool,
954                content: vec![Content::tool_result(
955                    "call-1",
956                    r#"{"value":4}"#,
957                    false,
958                    true,
959                )],
960            },
961            Message {
962                role: Role::User,
963                content: vec![Content::image(
964                    "https://example.com/chart.png",
965                    Some("image/png".to_owned()),
966                )],
967            },
968        ];
969
970        let json_str = serde_json::to_string(&req).unwrap();
971        let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
972
973        assert_eq!(back.model, "test-model");
974        assert_eq!(back.system.as_deref(), Some("Be concise."));
975        assert_eq!(back.max_tokens, Some(256));
976        assert_eq!(back.messages.len(), 4);
977        assert_eq!(back.tools.len(), 1);
978        assert_eq!(back.tool_choice, ToolChoice::Named("calculator".to_owned()));
979    }
980
981    #[test]
982    fn cache_hint_defaults_to_none_and_round_trips_on_the_request() {
983        // A fresh request opts out of caching.
984        assert_eq!(CompletionRequest::new("m").cache, CacheHint::None);
985
986        // The stable-prefix hint (with a routing key) survives a serde round trip.
987        let mut req = CompletionRequest::new("m");
988        req.cache = CacheHint::StablePrefix {
989            key: Some("conv-7".to_owned()),
990        };
991        let json_str = serde_json::to_string(&req).unwrap();
992        let back: CompletionRequest = serde_json::from_str(&json_str).unwrap();
993        assert_eq!(
994            back.cache,
995            CacheHint::StablePrefix {
996                key: Some("conv-7".to_owned())
997            }
998        );
999    }
1000
1001    #[test]
1002    fn cache_hint_round_trips_through_its_flat_key_form() {
1003        // The single-string form the harness turn input carries: a non-empty
1004        // key is a keyed stable-prefix hint, an empty key is no hint.
1005        let keyed = CacheHint::StablePrefix {
1006            key: Some("conv-9".to_owned()),
1007        };
1008        assert_eq!(keyed.key(), Some("conv-9"));
1009        assert_eq!(CacheHint::from_key("conv-9".to_owned()), keyed);
1010
1011        assert_eq!(CacheHint::None.key(), None);
1012        assert_eq!(CacheHint::from_key(String::new()), CacheHint::None);
1013
1014        // The one lossy case, by design: a KEYLESS stable-prefix hint has no
1015        // flat form (the control plane always keys by conversation).
1016        assert_eq!((CacheHint::StablePrefix { key: None }).key(), None);
1017    }
1018
1019    #[test]
1020    fn cache_hint_snake_case_wire_shape() {
1021        let hint = CacheHint::StablePrefix { key: None };
1022        let v: Value = serde_json::to_value(&hint).unwrap();
1023        assert_eq!(v, json!({"stable_prefix": {"key": null}}));
1024        assert_eq!(
1025            serde_json::to_value(CacheHint::None).unwrap(),
1026            json!("none")
1027        );
1028    }
1029
1030    #[test]
1031    fn json_schema_serializes_transparently() {
1032        let schema = JsonSchema(json!({"type": "object", "required": ["name"]}));
1033        let v: Value = serde_json::to_value(&schema).unwrap();
1034        assert_eq!(v["type"], "object");
1035        assert_eq!(v["required"][0], "name");
1036    }
1037
1038    #[test]
1039    fn json_schema_round_trips() {
1040        let inner = json!({"type": "string", "maxLength": 100});
1041        let schema = JsonSchema(inner.clone());
1042        let json_str = serde_json::to_string(&schema).unwrap();
1043        let back: JsonSchema = serde_json::from_str(&json_str).unwrap();
1044        assert_eq!(back.0, inner);
1045    }
1046
1047    #[test]
1048    fn image_ref_default_is_sensible() {
1049        let img = ImageRef::default();
1050        assert!(img.url.is_empty());
1051        assert!(img.mime_type.is_none());
1052    }
1053
1054    #[test]
1055    fn tool_spec_carries_optional_title() {
1056        let spec = ToolSpec::new("paid_fetch", "d", json!({})).titled("Pay for & fetch a web page");
1057        assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
1058    }
1059
1060    #[test]
1061    fn tool_spec_carries_needs_approval_flag() {
1062        let spec = ToolSpec::new("delete_file", "d", json!({})).approval_required();
1063        assert!(spec.needs_approval);
1064    }
1065
1066    /// `needs_approval` is `skip_serializing_if` false, so a non-gated spec
1067    /// omits the field on the wire; deserialization must read that absence back
1068    /// as `false` (the `#[serde(default)]` counterpart).
1069    #[test]
1070    fn tool_spec_needs_approval_defaults_false_on_deserialize() {
1071        let payload = json!({
1072            "name": "calculator",
1073            "description": "math",
1074            "schema_json": {"type": "object"}
1075        });
1076        let spec: ToolSpec = serde_json::from_value(payload).unwrap();
1077        assert!(
1078            !spec.needs_approval,
1079            "omitted needs_approval must default to false"
1080        );
1081    }
1082
1083    /// `#743`: the shared gated-tool note is a complete, plain sentence that
1084    /// never leaks internal jargon or apology words — the same banned-word
1085    /// list every user-facing string in the project is checked against.
1086    #[test]
1087    fn gated_tool_approval_note_is_clean_user_facing_copy() {
1088        let lower = GATED_TOOL_APPROVAL_NOTE.to_lowercase();
1089        for banned in [
1090            "please",
1091            "sorry",
1092            "unfortunately",
1093            "operator",
1094            "sub-agent",
1095            "lethal-trifecta",
1096            "state-changing action",
1097        ] {
1098            assert!(
1099                !lower.contains(banned),
1100                "gated-tool note leaked banned word {banned:?}: {}",
1101                GATED_TOOL_APPROVAL_NOTE.as_str()
1102            );
1103        }
1104        assert!(
1105            GATED_TOOL_APPROVAL_NOTE.contains("pauses"),
1106            "note must say the call pauses, not that it ran"
1107        );
1108        assert!(
1109            GATED_TOOL_APPROVAL_NOTE.contains("already run"),
1110            "note must say a result means the tool already ran"
1111        );
1112        assert!(
1113            GATED_TOOL_APPROVAL_NOTE.contains(APPROVAL_STATUS_GROUND_RULE),
1114            "the note embeds the single authoritative approval-status rule verbatim"
1115        );
1116    }
1117}