Skip to main content

turnframe_provider/
purpose.rs

1//! Normalized request purposes (spec §20.2) and what each one demands.
2//!
3//! A [`ModelPurpose`] names *why* the runtime calls a model. It carries two
4//! policies: the [`CapabilityRequirements`] a provider must satisfy (spec §20.4,
5//! computed by [`ModelPurpose::requirements`]) and the [`LoggingPolicy`] that
6//! says how much of the exchange may be logged.
7//!
8//! The critical rule is spec §0 rule 9 / §20.4: a task that understands a
9//! message, and so can lead to an effect, needs native JSON Schema output, a
10//! native function schema used purely as transport, or grammar-constrained
11//! decoding. `PromptOnly`, `JsonObject` and `None` are rejected unless the
12//! application opts into [`SafetyMode::UnsafeExperimental`]. The default never
13//! downgrades.
14
15use serde::{Deserialize, Serialize};
16
17use crate::capabilities::{CapabilityRequirements, StructuredOutputCapability};
18
19/// Why the runtime is calling a model (spec §20.2).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum ModelPurpose {
24    /// Offline evaluation of a corpus (spec §27.6); never on the request path.
25    OfflineEvaluate,
26    /// Split a message into units: requests, questions, constraints and the rest.
27    Segment,
28    /// Find a request or question the units do not cover.
29    Coverage,
30    /// Choose which offer of the last reply a unit takes up, if any.
31    TakeUp,
32    /// Choose the operation a unit asks for.
33    Route,
34    /// Choose the record an act aims at.
35    Locate,
36    /// Fill an act's arguments.
37    Extract,
38    /// Check an act against the words it came from.
39    Verify,
40    /// Choose the record and subjects a question is about.
41    QuestionFrame,
42    /// Check the whole understanding of a message against the message.
43    CrossCheck,
44    /// Judge whether an act changes what a keep-unchanged constraint keeps.
45    Respects,
46    /// Ask for read-only context before a unit is understood.
47    Investigate,
48    /// Write what a turn did and what it needs next.
49    Acknowledge,
50    /// Answer one question.
51    Answer,
52    /// Check a written block against its facts.
53    Review,
54    /// Say, as it happens, what the assistant is doing to understand a message.
55    Progress,
56}
57
58/// Whether the application accepts structured-output transports that are not
59/// safe enough for understanding a message (spec §20.4).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum SafetyMode {
63    /// The default: only native JSON Schema, native function schema (as
64    /// transport) or grammar-constrained output may understand a message.
65    #[default]
66    Default,
67    /// Explicit opt-in that also accepts `JsonObject`, `PromptOnly` and `None`
68    /// for understanding. Unsafe: the only remaining guard is
69    /// all-or-nothing schema validation after the fact.
70    UnsafeExperimental,
71}
72
73/// How much of a model exchange may be written to logs and traces.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum LogDetail {
77    /// Identifiers, sizes, purpose and timing only.
78    Metadata,
79    /// Content after [`crate::secret::Redactor`] processing.
80    Redacted,
81    /// Full content. Only acceptable for offline corpora without personal data.
82    Full,
83}
84
85/// Logging and redaction policy attached to a purpose.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87pub struct LoggingPolicy {
88    /// What may be logged about the prompt (system, messages, tools).
89    pub prompt: LogDetail,
90    /// What may be logged about the model output.
91    pub output: LogDetail,
92    /// Whether raw provider bodies may be retained anywhere. Always `false` for
93    /// request-path purposes (spec §25.2).
94    pub retain_raw_bodies: bool,
95}
96
97impl LoggingPolicy {
98    /// Metadata only for both directions, no raw bodies.
99    pub const METADATA_ONLY: Self = Self {
100        prompt: LogDetail::Metadata,
101        output: LogDetail::Metadata,
102        retain_raw_bodies: false,
103    };
104
105    /// Metadata for the prompt, redacted output, no raw bodies.
106    pub const REDACTED_OUTPUT: Self = Self {
107        prompt: LogDetail::Metadata,
108        output: LogDetail::Redacted,
109        retain_raw_bodies: false,
110    };
111
112    /// Full logging, still without raw provider bodies.
113    pub const FULL: Self = Self {
114        prompt: LogDetail::Full,
115        output: LogDetail::Full,
116        retain_raw_bodies: false,
117    };
118
119    /// Returns `true` when any content (beyond metadata) may be logged.
120    #[must_use]
121    pub const fn logs_content(&self) -> bool {
122        !matches!(self.prompt, LogDetail::Metadata) || !matches!(self.output, LogDetail::Metadata)
123    }
124}
125
126/// The structured-output transports safe for understanding a message (spec §20.4).
127pub const MUTATION_SAFE_STRUCTURED_OUTPUT: [StructuredOutputCapability; 3] = [
128    StructuredOutputCapability::NativeJsonSchema,
129    StructuredOutputCapability::NativeFunctionSchema,
130    StructuredOutputCapability::GrammarConstrained,
131];
132
133/// Transports acceptable when the parsed output can only trigger reads.
134pub const READ_ONLY_STRUCTURED_OUTPUT: [StructuredOutputCapability; 4] = [
135    StructuredOutputCapability::NativeJsonSchema,
136    StructuredOutputCapability::NativeFunctionSchema,
137    StructuredOutputCapability::GrammarConstrained,
138    StructuredOutputCapability::JsonObject,
139];
140
141impl ModelPurpose {
142    /// Every purpose, for exhaustive registration and tests.
143    pub const ALL: [Self; 16] = [
144        Self::OfflineEvaluate,
145        Self::Segment,
146        Self::Coverage,
147        Self::TakeUp,
148        Self::Route,
149        Self::Locate,
150        Self::Extract,
151        Self::Verify,
152        Self::QuestionFrame,
153        Self::CrossCheck,
154        Self::Respects,
155        Self::Investigate,
156        Self::Acknowledge,
157        Self::Answer,
158        Self::Review,
159        Self::Progress,
160    ];
161
162    /// The tasks that understand a message, before any effect.
163    #[must_use]
164    pub const fn is_understanding(self) -> bool {
165        matches!(
166            self,
167            Self::Segment
168                | Self::Coverage
169                | Self::TakeUp
170                | Self::Route
171                | Self::Locate
172                | Self::Extract
173                | Self::Verify
174                | Self::QuestionFrame
175                | Self::CrossCheck
176                | Self::Respects
177                | Self::Investigate
178        )
179    }
180
181    /// Stable snake-case label, used in replay records and metrics.
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::OfflineEvaluate => "offline_evaluate",
186            Self::Segment => "segment",
187            Self::Coverage => "coverage",
188            Self::TakeUp => "take_up",
189            Self::Route => "route",
190            Self::Locate => "locate",
191            Self::Extract => "extract",
192            Self::Verify => "verify",
193            Self::QuestionFrame => "question_frame",
194            Self::CrossCheck => "cross_check",
195            Self::Respects => "respects",
196            Self::Investigate => "investigate",
197            Self::Acknowledge => "acknowledge",
198            Self::Answer => "answer",
199            Self::Review => "review",
200            Self::Progress => "progress",
201        }
202    }
203
204    /// Returns `true` for the purposes whose output can lead to commands or
205    /// reads being executed (spec §20.4 "critical stage").
206    #[must_use]
207    pub const fn is_critical(self) -> bool {
208        self.is_understanding()
209    }
210
211    /// Capability requirements under [`SafetyMode::Default`] (spec §20.4).
212    #[must_use]
213    pub fn requirements(self) -> CapabilityRequirements {
214        self.requirements_in(SafetyMode::Default)
215    }
216
217    /// Capability requirements under an explicit safety mode.
218    ///
219    /// * The understanding tasks: one of [`MUTATION_SAFE_STRUCTURED_OUTPUT`],
220    ///   except `Investigate`, whose output can only trigger reads.
221    /// * `Investigate` and the narration tasks: one of
222    ///   [`READ_ONLY_STRUCTURED_OUTPUT`]; their documents only become text.
223    /// * `OfflineEvaluate`: none; evaluation deliberately measures every transport.
224    /// * [`SafetyMode::UnsafeExperimental`] removes the structured-output
225    ///   requirement for every purpose, and nothing else.
226    #[must_use]
227    pub fn requirements_in(self, mode: SafetyMode) -> CapabilityRequirements {
228        let structured_output: Vec<StructuredOutputCapability> = match (self, mode) {
229            (_, SafetyMode::UnsafeExperimental) | (Self::OfflineEvaluate, _) => Vec::new(),
230            (
231                Self::Investigate
232                | Self::Acknowledge
233                | Self::Answer
234                | Self::Review
235                | Self::Progress,
236                SafetyMode::Default,
237            ) => READ_ONLY_STRUCTURED_OUTPUT.to_vec(),
238            (_, SafetyMode::Default) => MUTATION_SAFE_STRUCTURED_OUTPUT.to_vec(),
239        };
240        CapabilityRequirements {
241            structured_output,
242            needs_tools: false,
243            needs_streaming: false,
244            min_context_tokens: None,
245            needs_vision: false,
246            needs_documents: false,
247        }
248    }
249
250    /// Logging policy of the purpose.
251    ///
252    /// Request-path purposes never log prompts beyond metadata because prompts
253    /// carry user text and case state; outputs are logged redacted where they
254    /// are useful for debugging the understanding. `OfflineEvaluate` may log in
255    /// full because evaluation corpora are curated. No purpose retains raw
256    /// provider bodies (spec §25.2).
257    #[must_use]
258    pub const fn logging_policy(self) -> LoggingPolicy {
259        match self {
260            Self::Segment
261            | Self::Coverage
262            | Self::TakeUp
263            | Self::Route
264            | Self::Locate
265            | Self::Extract
266            | Self::Verify
267            | Self::QuestionFrame
268            | Self::CrossCheck
269            | Self::Respects
270            | Self::Investigate => LoggingPolicy::REDACTED_OUTPUT,
271            Self::Acknowledge | Self::Answer | Self::Review | Self::Progress => {
272                LoggingPolicy::METADATA_ONLY
273            }
274            Self::OfflineEvaluate => LoggingPolicy::FULL,
275        }
276    }
277}
278
279impl std::fmt::Display for ModelPurpose {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.write_str(self.as_str())
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::capabilities::ProviderCapabilities;
289
290    fn caps(structured: StructuredOutputCapability) -> ProviderCapabilities {
291        ProviderCapabilities::minimal().with_structured_output(structured)
292    }
293
294    #[test]
295    fn understanding_rejects_prompt_only_json_object_and_none() {
296        let requirements = ModelPurpose::Extract.requirements();
297        for unsafe_transport in [
298            StructuredOutputCapability::PromptOnly,
299            StructuredOutputCapability::JsonObject,
300            StructuredOutputCapability::None,
301        ] {
302            assert!(requirements.satisfied_by(&caps(unsafe_transport)).is_err());
303        }
304        for safe in MUTATION_SAFE_STRUCTURED_OUTPUT {
305            assert!(requirements.satisfied_by(&caps(safe)).is_ok());
306        }
307    }
308
309    #[test]
310    fn unsafe_experimental_is_an_explicit_opt_in() {
311        let requirements = ModelPurpose::Extract.requirements_in(SafetyMode::UnsafeExperimental);
312        assert!(requirements.structured_output.is_empty());
313        assert!(
314            requirements
315                .satisfied_by(&caps(StructuredOutputCapability::PromptOnly))
316                .is_ok()
317        );
318    }
319
320    #[test]
321    fn a_read_only_task_accepts_json_object_but_not_prompt_only() {
322        for purpose in [ModelPurpose::Investigate, ModelPurpose::Acknowledge] {
323            let requirements = purpose.requirements();
324            assert_eq!(
325                requirements.structured_output,
326                READ_ONLY_STRUCTURED_OUTPUT.to_vec()
327            );
328            assert!(
329                requirements
330                    .satisfied_by(&caps(StructuredOutputCapability::PromptOnly))
331                    .is_err()
332            );
333        }
334    }
335
336    #[test]
337    fn narration_logs_metadata_only_and_is_not_critical() {
338        assert!(!ModelPurpose::Acknowledge.logging_policy().logs_content());
339        assert!(!ModelPurpose::Acknowledge.is_critical());
340        assert!(ModelPurpose::Extract.is_critical());
341        assert!(
342            ModelPurpose::OfflineEvaluate
343                .requirements()
344                .structured_output
345                .is_empty()
346        );
347    }
348
349    #[test]
350    fn labels_are_unique_and_never_retain_raw_bodies() {
351        let mut labels: Vec<&str> = ModelPurpose::ALL.iter().map(|p| p.as_str()).collect();
352        labels.sort_unstable();
353        labels.dedup();
354        assert_eq!(labels.len(), ModelPurpose::ALL.len());
355        for purpose in ModelPurpose::ALL {
356            assert!(!purpose.logging_policy().retain_raw_bodies);
357            let json = serde_json::to_string(&purpose).unwrap();
358            assert_eq!(json, format!("\"{}\"", purpose.as_str()));
359        }
360    }
361}