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