Skip to main content

leviath_core/
output.rs

1//! An agent's final output: the one value a run hands back to whoever asked.
2//!
3//! Every surface that reports a result reads this and nothing else:
4//! `GET /api/agents/{id}/result`, the completion webhook's `result` field,
5//! `wait_for_agent`'s answer to a parent, and a fan-out worker's contribution
6//! to its merge stage. None of them has to guess at a log tail or at whatever
7//! text sat in a last assistant message, which for a worker whose final turn
8//! was a tool call is nothing at all.
9//!
10//! # The format rule
11//!
12//! **Nothing here interprets the format.** There is no enum of supported
13//! formats, no per-format parser, and no branch on a format name anywhere in the
14//! engine. [`OutputSpec::format`] is an opaque label; markdown, JSON, XML, CSV,
15//! an [a2ui](https://a2ui.org/) document, and a house format invented next week
16//! all travel the same path: describe it to the model, record what comes back
17//! verbatim, hand it on unchanged.
18//!
19//! The single exception is opt-in and named as such. When an author supplies
20//! [`OutputSpec::schema`], the submission is parsed as JSON and validated
21//! against it. That is the only thing that ever looks inside the content, and it
22//! happens because someone asked for it, never because a format string said
23//! `"json"`.
24//!
25//! This is also why an unusual format needs no engine support. There is no
26//! usual: every format is produced by the model from
27//! [`OutputSpec::instructions`] and [`OutputSpec::example`].
28
29use serde::{Deserialize, Serialize};
30
31/// Largest final output kept, in bytes. Anything longer is cut at a character
32/// boundary and flagged [`FinalOutput::truncated`].
33///
34/// Sits between the log tail the result endpoint already serves (64 KiB) and the
35/// cap on reading a file the run wrote (1 MiB). A final output is meant to be an
36/// answer, not a payload; an agent with megabytes to hand back should write a
37/// file and say where it is.
38pub const MAX_FINAL_OUTPUT_BYTES: usize = 256 * 1024;
39
40/// What happens to a submission when its Rhai validator cannot run: the script
41/// threw, exhausted its operation budget, or returned something that is neither
42/// `()` nor a string.
43///
44/// Distinct from the validator *rejecting* the answer, which always refuses the
45/// submission back to the model. This knob is only about the script itself
46/// failing.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum OnValidatorError {
50    /// Refuse the submission, sending the script's error text to the model as
51    /// retry feedback. The default: an answer nothing checked must not ship as
52    /// if it passed, and a `parse_json` throw on malformed output is something
53    /// the model can act on.
54    #[default]
55    Reject,
56    /// Record the submission unchecked, as if no validator were declared. For
57    /// blueprints that would rather have an unchecked answer than a failed run.
58    /// The broken script is still flagged on the run either way.
59    Accept,
60}
61
62/// What shape an agent should return.
63///
64/// Declared by a blueprint (`[agent.output]`), narrowed by a stage
65/// (`[stages.<name>.output]`), and overridable by whoever starts the run. See
66/// [`resolve_output_spec`] for how the three combine.
67///
68/// Every field is optional, and an entirely empty spec is meaningful: it asks
69/// for a final output without constraining its shape.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct OutputSpec {
72    /// An opaque label for the shape, carried to the model and recorded beside
73    /// the result. `"markdown"`, `"json"`, `"a2ui"`, and
74    /// `"application/vnd.acme.report+xml"` are all equally valid and equally
75    /// uninterpreted. Consumers that render differently per format (a browser
76    /// UI, say) match on this string; the engine never does.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub format: Option<String>,
79
80    /// Free-form guidance folded into the `submit_output` tool description and
81    /// the output stage's system prompt. This is where a format that the model
82    /// has never seen gets explained.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub instructions: Option<String>,
85
86    /// A literal sample shown to the model verbatim. The most effective lever
87    /// for an unusual format, and the reason one needs no code support.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub example: Option<String>,
90
91    /// A JSON Schema describing the answer's shape. When present, a submission
92    /// is parsed as JSON and validated against it, and a failure is refused back
93    /// to the model so it can correct itself.
94    ///
95    /// Separate from `format` because they answer different questions.
96    /// `format = "json"` asks "does this parse as JSON"; a schema asks "does the
97    /// parsed document have the fields I need". A format check comes free for
98    /// the handful of formats the engine can parse; shape is only ever checked
99    /// when someone writes a schema down.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub schema: Option<serde_json::Value>,
102
103    /// A `.rhai` script that decides whether an answer is valid, as a path
104    /// relative to the blueprint directory.
105    ///
106    /// For a format the engine cannot parse and a shape a JSON Schema cannot
107    /// describe. The script defines `fn validate(content)` and returns `()` when
108    /// the answer is fine or a string saying what is wrong; the string goes back
109    /// to the agent as the same refusal a schema failure produces.
110    ///
111    /// Written for the format it accompanies, so a caller who overrides the
112    /// format retires it along with the schema.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub validator: Option<String>,
115
116    /// What to do when the validator itself cannot run. `None` means the
117    /// default, [`OnValidatorError::Reject`]. Travels with the validator: a
118    /// caller who retires the validator by overriding the format retires this
119    /// setting along with it.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub on_validator_error: Option<OnValidatorError>,
122
123    /// Whether an artifact named after a part the run produced may replace a
124    /// different file already at that path in the working directory. `None`
125    /// defers to the user's `[mime] overwrite_artifacts`, which defaults to
126    /// false: the existing file is left alone and the part is written beside
127    /// it under a name carrying its hash.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub overwrite_artifacts: Option<bool>,
130
131    /// The files the stage hands back beside its answer, by name and type.
132    /// A submission is checked against them: a `required` one must be
133    /// present, and one that is present must be of the declared type.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub artifacts: Vec<ArtifactSpec>,
136}
137
138impl OutputSpec {
139    /// Whether this spec constrains anything at all. An empty spec still asks
140    /// for an output, so this is about wording the request, not skipping it.
141    pub fn is_empty(&self) -> bool {
142        self.format.is_none()
143            && self.instructions.is_none()
144            && self.example.is_none()
145            && self.schema.is_none()
146            && self.validator.is_none()
147            && self.on_validator_error.is_none()
148            && self.overwrite_artifacts.is_none()
149            && self.artifacts.is_empty()
150    }
151}
152
153/// A file a stage declares it hands back beside its answer.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ArtifactSpec {
156    /// What the submission calls it: `final`, `scene`, `track`.
157    pub name: String,
158    /// The mime type it must be, or a pattern it must match (`video/*`).
159    #[serde(rename = "type")]
160    pub mime_type: String,
161    /// Whether a submission without it is refused.
162    #[serde(default)]
163    pub required: bool,
164    /// What it is for, shown to the model.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub description: Option<String>,
167}
168
169/// A file a run produced, as recorded on its answer.
170///
171/// Every field but `path` is what the run could tell from the bytes: the
172/// registry's type (or the declared one), the size, and the hash the run's
173/// blob store holds the file under. An answer recorded before artifacts were
174/// typed carried a bare path, and reads back as one with the rest unknown.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct Artifact {
177    /// The name the stage declared, or the file name when it declared none.
178    pub name: String,
179    /// The file, relative to the working directory. An answer emitted from
180    /// routed parts alone never wrote a file, and carries the part's name here
181    /// instead; its bytes are reachable only through `sha256`.
182    pub path: String,
183    /// The file's type.
184    pub mime_type: crate::mime::MimeType,
185    /// Size in bytes.
186    #[serde(default)]
187    pub size: u64,
188    /// The sha256 the run's blob store holds the bytes under; empty when the
189    /// file was too large to store, or the answer predates typed artifacts.
190    #[serde(default, skip_serializing_if = "String::is_empty")]
191    pub sha256: String,
192}
193
194impl Artifact {
195    /// `name (type, size)`: how a produced file reads in a sentence.
196    pub fn short_label(&self) -> String {
197        format!(
198            "{} ({}, {})",
199            self.name,
200            self.mime_type,
201            crate::mime::human_size(self.size)
202        )
203    }
204
205    /// The columns after the name in a listing: path, type, size, and the
206    /// hash prefix when the bytes are stored.
207    pub fn detail_columns(&self) -> String {
208        let sha = match self.sha256.is_empty() {
209            true => String::new(),
210            false => format!(
211                "  sha256:{}",
212                self.sha256.chars().take(12).collect::<String>()
213            ),
214        };
215        format!(
216            "{}  {}  {}{sha}",
217            self.path,
218            self.mime_type,
219            crate::mime::human_size(self.size)
220        )
221    }
222
223    /// An artifact known only by its path: the shape every answer recorded
224    /// before artifacts were typed carried.
225    pub fn from_path(path: &str) -> Self {
226        let name = path
227            .rsplit(['/', '\\'])
228            .find(|s| !s.is_empty())
229            .unwrap_or(path)
230            .to_string();
231        Self {
232            name,
233            path: path.to_string(),
234            mime_type: crate::mime::octet_stream(),
235            size: 0,
236            sha256: String::new(),
237        }
238    }
239}
240
241/// One artifact on the wire: the typed record, or the bare path older
242/// answers wrote.
243#[derive(Deserialize)]
244#[serde(untagged)]
245enum ArtifactWire {
246    Full(Artifact),
247    Path(String),
248}
249
250/// Read an artifacts list that may hold bare paths.
251fn artifacts_from_wire<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Artifact>, D::Error> {
252    let listed: Vec<ArtifactWire> = Vec::deserialize(d)?;
253    Ok(listed
254        .into_iter()
255        .map(|a| match a {
256            ArtifactWire::Full(a) => a,
257            ArtifactWire::Path(p) => Artifact::from_path(&p),
258        })
259        .collect())
260}
261
262/// What an agent actually produced, content included.
263///
264/// [`content`](Self::content) is stored exactly as submitted. Nothing in the
265/// engine reformats, re-indents, or re-serializes it, so a consumer that asked
266/// for a particular byte sequence receives that byte sequence.
267///
268/// This is the in-memory and one-shot form: the live ECS component, the
269/// completion event, a webhook body, a reply to a waiting parent. What a run's
270/// `meta.json` carries is the [`FinalOutputDescriptor`], because that file is
271/// read for every run on every listing and must not carry a payload.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273pub struct FinalOutput {
274    /// The submission, verbatim (subject only to [`MAX_FINAL_OUTPUT_BYTES`]).
275    pub content: String,
276
277    /// The format label in effect when this was submitted, if any. Copied from
278    /// the resolved spec rather than guessed from the content.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub format: Option<String>,
281
282    /// The stage that produced it. Read by the enforcement gate, which must
283    /// tell "this stage submitted" from "some earlier stage did".
284    pub stage: String,
285
286    /// Unix seconds at submission.
287    pub submitted_at: i64,
288
289    /// Whether [`MAX_FINAL_OUTPUT_BYTES`] cut the content short.
290    #[serde(default)]
291    pub truncated: bool,
292
293    /// Files the run produced, typed and hashed.
294    ///
295    /// An answer is one model response; anything larger is a file. A run that
296    /// gathers two million rows writes them incrementally and names the file
297    /// here, so a consumer can fetch it rather than parse the path out of prose.
298    /// Validated to resolve inside the run's working directory, the same rule
299    /// the files endpoint enforces when serving one.
300    #[serde(
301        default,
302        skip_serializing_if = "Vec::is_empty",
303        deserialize_with = "artifacts_from_wire"
304    )]
305    pub artifacts: Vec<Artifact>,
306}
307
308impl FinalOutput {
309    /// Record a submission, truncating at a character boundary if it exceeds
310    /// [`MAX_FINAL_OUTPUT_BYTES`].
311    ///
312    /// Truncation walks back to a boundary rather than slicing by byte index:
313    /// this workspace denies `clippy::string_slice` because a byte cut through a
314    /// multi-byte character once double-panicked and aborted the whole daemon.
315    pub fn new(content: &str, format: Option<String>, stage: String, submitted_at: i64) -> Self {
316        let truncated = content.len() > MAX_FINAL_OUTPUT_BYTES;
317        let kept = crate::text::truncate_at_boundary(content, MAX_FINAL_OUTPUT_BYTES);
318        Self {
319            content: kept.to_string(),
320            format,
321            stage,
322            submitted_at,
323            truncated,
324            artifacts: Vec::new(),
325        }
326    }
327
328    /// The same submission with `artifacts` attached.
329    pub fn with_artifacts(mut self, artifacts: Vec<Artifact>) -> Self {
330        self.artifacts = artifacts;
331        self
332    }
333
334    /// Everything about this answer except the bytes.
335    pub fn descriptor(&self) -> FinalOutputDescriptor {
336        FinalOutputDescriptor {
337            format: self.format.clone(),
338            stage: self.stage.clone(),
339            submitted_at: self.submitted_at,
340            bytes: self.content.len(),
341            truncated: self.truncated,
342            artifacts: self.artifacts.clone(),
343        }
344    }
345}
346
347/// What a run's `meta.json` records about its answer: everything but the bytes.
348///
349/// The content lives beside it in a sidecar file
350/// ([`FINAL_OUTPUT_FILE`]). `meta.json` is
351/// parsed for every run on every `lev ps`, every `/api/runs` page, and every
352/// restart scan, so a payload in it is paid for by operations that never wanted
353/// it: a thousand answered runs would mean hundreds of megabytes of JSON per
354/// listing. A descriptor is a couple of hundred bytes and stays that way.
355#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
356pub struct FinalOutputDescriptor {
357    /// The format label the answer was produced under, if any.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub format: Option<String>,
360    /// The stage that produced it.
361    pub stage: String,
362    /// Unix seconds at submission.
363    pub submitted_at: i64,
364    /// Size of the answer in bytes, so a caller can decide whether to fetch it.
365    #[serde(default)]
366    pub bytes: usize,
367    /// Whether [`MAX_FINAL_OUTPUT_BYTES`] cut the answer short.
368    #[serde(default)]
369    pub truncated: bool,
370    /// Files the run produced, typed and hashed.
371    #[serde(
372        default,
373        skip_serializing_if = "Vec::is_empty",
374        deserialize_with = "artifacts_from_wire"
375    )]
376    pub artifacts: Vec<Artifact>,
377}
378
379/// The file, inside a run's directory, holding the answer's bytes.
380///
381/// Raw content with no wrapper, so serving it is a read and `lev result --raw`
382/// is a copy.
383pub const FINAL_OUTPUT_FILE: &str = "final_output";
384
385/// Combine the blueprint's, the stage's, and the caller's output specs into the
386/// one that governs a stage. Later levels win field by field, the way
387/// [`resolve_nudge`](crate::blueprint::resolve_nudge) cascades.
388///
389/// Returns `None` when no level asks for an output at all, which is how a stage
390/// that has nothing to hand back stays silent.
391///
392/// # The schema drop
393///
394/// A caller who names a `format` and supplies no `schema` **drops the declared
395/// schema**. Validating an a2ui document against the agent's own JSON schema
396/// would be nonsense: the caller asked for a different shape, so the check
397/// written for the old shape no longer applies. A caller who wants validation
398/// supplies a schema alongside the format. This is the one place where fields do
399/// not cascade independently, and it is deliberate.
400pub fn resolve_output_spec(
401    agent: Option<&OutputSpec>,
402    stage: Option<&OutputSpec>,
403    request: Option<&OutputSpec>,
404) -> Option<OutputSpec> {
405    if agent.is_none() && stage.is_none() && request.is_none() {
406        return None;
407    }
408
409    fn field<T: Clone>(
410        agent: Option<&OutputSpec>,
411        stage: Option<&OutputSpec>,
412        request: Option<&OutputSpec>,
413        get: impl Fn(&OutputSpec) -> Option<T>,
414    ) -> Option<T> {
415        request
416            .and_then(&get)
417            .or_else(|| stage.and_then(&get))
418            .or_else(|| agent.and_then(&get))
419    }
420
421    // A shape check is written for one format. When a caller asks for a
422    // different one, a check the blueprint declared no longer describes what is
423    // being produced, so it is retired rather than applied to something it was
424    // never about. A caller who wants their new shape checked supplies their own.
425    let declared_format = field(agent, stage, None, |s| s.format.clone());
426    let requested_format = request.and_then(|r| r.format.clone());
427    let reshaped = requested_format.is_some() && requested_format != declared_format;
428
429    let shape_field = |get: fn(&OutputSpec) -> Option<serde_json::Value>| match reshaped {
430        true => request.and_then(get),
431        false => field(agent, stage, request, get),
432    };
433    let validator = match reshaped {
434        true => request.and_then(|r| r.validator.clone()),
435        false => field(agent, stage, request, |s| s.validator.clone()),
436    };
437    // The error policy accompanies the validator it is about, so it follows the
438    // validator's cascade: retired with it on a reshape, inherited otherwise.
439    let on_validator_error = match reshaped {
440        true => request.and_then(|r| r.on_validator_error),
441        false => field(agent, stage, request, |s| s.on_validator_error),
442    };
443
444    // Declared artifacts describe the declared shape, so they go with the
445    // schema and the validator when a caller reshapes. Otherwise the nearest
446    // non-empty list wins whole: a stage that names its own files replaces
447    // the agent's list rather than adding to it.
448    let artifacts = match reshaped {
449        true => request.map(|r| r.artifacts.clone()).unwrap_or_default(),
450        false => field(agent, stage, request, |s| {
451            (!s.artifacts.is_empty()).then(|| s.artifacts.clone())
452        })
453        .unwrap_or_default(),
454    };
455
456    Some(OutputSpec {
457        format: field(agent, stage, request, |s| s.format.clone()),
458        instructions: field(agent, stage, request, |s| s.instructions.clone()),
459        example: field(agent, stage, request, |s| s.example.clone()),
460        schema: shape_field(|s| s.schema.clone()),
461        validator,
462        on_validator_error,
463        overwrite_artifacts: field(agent, stage, request, |s| s.overwrite_artifacts),
464        artifacts,
465    })
466}
467
468/// The warnings a caller's requested output shape earns at spawn: what
469/// [`resolve_output_spec`] will retire, said out loud before it happens.
470///
471/// Retiring the declared Rhai validator and JSON schema when the request names
472/// a different format is deliberate and stays: a check written for one shape
473/// cannot judge another. These warnings say so out loud, so a caller who types
474/// `--output-format json` over a blueprint with a validator does not go on
475/// believing the run is still being checked.
476///
477/// One line per group of stages losing the same checks, so an agent-level
478/// validator shared by four stages reads as one sentence naming four stages.
479/// Empty when there is nothing to say: no request, no format in it, the
480/// declared format re-stated (which retires nothing), or nothing declared that
481/// could be retired. A declared schema the request *replaces* with its own is
482/// also not warned about: supplying a schema for the new shape is exactly what
483/// the warning would have asked for. A declared validator is always worth the
484/// line, because no request can bring a replacement for it.
485pub fn retired_check_warnings(
486    blueprint: &crate::Blueprint,
487    request: Option<&OutputSpec>,
488) -> Vec<String> {
489    let Some(requested) = request.and_then(|r| r.format.as_deref()) else {
490        return Vec::new();
491    };
492    let request_has_schema = request.is_some_and(|r| r.schema.is_some());
493    let mut groups: Vec<(RetiredChecks, Vec<String>)> = Vec::new();
494    for stage in &blueprint.stages {
495        let Some(retired) = retired_checks_for_stage(
496            blueprint.output.as_ref(),
497            stage.output.as_ref(),
498            requested,
499            request_has_schema,
500        ) else {
501            continue;
502        };
503        match groups.iter_mut().find(|(g, _)| *g == retired) {
504            Some((_, stages)) => stages.push(stage.name.clone()),
505            None => groups.push((retired, vec![stage.name.clone()])),
506        }
507    }
508    groups
509        .iter()
510        .map(|(checks, stages)| checks.warning_line(requested, stages, request_has_schema))
511        .collect()
512}
513
514/// The declared checks one stage loses to a format override. Two stages with
515/// equal values lose the same thing and share one warning line.
516#[derive(PartialEq, Eq)]
517struct RetiredChecks {
518    /// The format the retired checks were written for, when one was declared.
519    declared_format: Option<String>,
520    /// The retired Rhai validator's path, when one was declared.
521    validator: Option<String>,
522    /// Whether a declared JSON schema is retired with nothing in its place.
523    schema: bool,
524}
525
526/// What `requested` retires for one stage, or `None` when it retires nothing.
527///
528/// Asks [`resolve_output_spec`]'s question ahead of time, with the same
529/// cascade: the stage's declaration wins over the agent's, and re-stating the
530/// declared format keeps every check. Kept beside it so the two cannot drift.
531fn retired_checks_for_stage(
532    agent: Option<&OutputSpec>,
533    stage: Option<&OutputSpec>,
534    requested: &str,
535    request_has_schema: bool,
536) -> Option<RetiredChecks> {
537    let declared =
538        |get: fn(&OutputSpec) -> Option<&str>| stage.and_then(get).or_else(|| agent.and_then(get));
539    let declared_format = declared(|s| s.format.as_deref());
540    if declared_format == Some(requested) {
541        return None;
542    }
543    let validator = declared(|s| s.validator.as_deref());
544    let schema = !request_has_schema
545        && stage
546            .and_then(|s| s.schema.as_ref())
547            .or_else(|| agent.and_then(|a| a.schema.as_ref()))
548            .is_some();
549    if validator.is_none() && !schema {
550        return None;
551    }
552    Some(RetiredChecks {
553        declared_format: declared_format.map(str::to_string),
554        validator: validator.map(str::to_string),
555        schema,
556    })
557}
558
559impl RetiredChecks {
560    /// The warning itself, worded for a person on any spawn path: what was
561    /// requested, what it retires, and how to get the new shape checked. The
562    /// closing advice depends on the request: a caller who already brought a
563    /// schema for the new shape has nothing further to supply.
564    fn warning_line(&self, requested: &str, stages: &[String], request_has_schema: bool) -> String {
565        let cause = match &self.declared_format {
566            Some(declared) => format!(
567                "requested output format '{requested}' differs from the declared '{declared}'"
568            ),
569            None => format!(
570                "requested output format '{requested}' reshapes an output declared without a \
571                 format"
572            ),
573        };
574        let what = match (&self.validator, self.schema) {
575            (Some(v), true) => format!("the Rhai validator '{v}' and the JSON schema"),
576            (Some(v), false) => format!("the Rhai validator '{v}'"),
577            (None, _) => "the JSON schema".to_string(),
578        };
579        let tail = match request_has_schema {
580            true => "the schema supplied with the request is what checks the answer now",
581            false => {
582                "nothing checks the answer's shape; supply a schema with the request if the new \
583                 shape needs one"
584            }
585        };
586        format!(
587            "{cause}: {what} declared for {} will not run, because a check written for one shape \
588             cannot judge another. Instead, {tail}.",
589            stage_phrase(stages)
590        )
591    }
592}
593
594/// `stage 'plan'`, `stages 'plan' and 'wrap'`, `stages 'a', 'b', and 'c'`.
595/// Callers only group stages they saw, so the slice is never empty.
596fn stage_phrase(stages: &[String]) -> String {
597    let quoted: Vec<String> = stages.iter().map(|s| format!("'{s}'")).collect();
598    match quoted.split_last() {
599        Some((last, [])) => format!("stage {last}"),
600        Some((last, [first])) => format!("stages {first} and {last}"),
601        Some((last, head)) => format!("stages {}, and {last}", head.join(", ")),
602        // Unreachable by construction; an empty phrase keeps the sentence
603        // grammatical if a future caller ever passes one.
604        None => "its stages".to_string(),
605    }
606}
607
608/// Render a resolved spec as the guidance an agent reads.
609///
610/// Used twice for the same text: once in the `submit_output` tool description
611/// and once in an output stage's system prompt. Saying it in both places matters
612/// most for a format the model has no prior knowledge of, which is exactly the
613/// case this module is built to support.
614///
615/// A constrained spec closes with a precedence sentence, because without one
616/// this text and the stage's own system prompt are two peer instructions and
617/// which wins is model-dependent: a stage prompt saying "lead with the
618/// diagnosis" beats `--output-instructions "reply with only the integer"` on
619/// some models and loses on others. By the time this runs, [`resolve_output_spec`]
620/// has already picked one winner per field - a caller's flag replaces the
621/// blueprint's line rather than joining it - so there is exactly one shape here
622/// and it is the one that should govern. The sentence is scoped to presentation
623/// so a bare `format` does not read as licence to drop content.
624///
625/// Returns an empty string for a spec that constrains nothing, so callers can
626/// append it unconditionally.
627pub fn describe_spec(spec: &OutputSpec) -> String {
628    let mut parts = Vec::new();
629    if let Some(format) = &spec.format {
630        parts.push(format!("Return it in this format: {format}."));
631    }
632    if let Some(instructions) = &spec.instructions {
633        parts.push(instructions.clone());
634    }
635    if let Some(schema) = &spec.schema {
636        parts.push(format!(
637            "It must be JSON valid against this schema:\n{schema}"
638        ));
639    }
640    if let Some(example) = &spec.example {
641        parts.push(format!(
642            "Here is an example of the expected shape:\n{example}"
643        ));
644    }
645    if !spec.artifacts.is_empty() {
646        let listed: Vec<String> = spec
647            .artifacts
648            .iter()
649            .map(|a| {
650                let mut line = format!("- {} ({}", a.name, a.mime_type);
651                if a.required {
652                    line.push_str(", required");
653                }
654                line.push(')');
655                if let Some(d) = &a.description {
656                    line.push_str(": ");
657                    line.push_str(d);
658                }
659                line
660            })
661            .collect();
662        parts.push(format!(
663            "Hand back these files in `artifacts`, each as {{ name, path }} with the name \
664             given here and the path of the file you wrote:\n{}",
665            listed.join("\n")
666        ));
667    }
668    if !parts.is_empty() {
669        parts.push(
670            "This governs how the answer is presented. Where anything else you were told says \
671             to present it differently - its length, its structure, what to lead with - follow \
672             this."
673                .to_string(),
674        );
675    }
676    parts.join("\n\n")
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use serde_json::json;
683
684    fn spec(format: Option<&str>, schema: Option<serde_json::Value>) -> OutputSpec {
685        OutputSpec {
686            format: format.map(str::to_string),
687            schema,
688            ..OutputSpec::default()
689        }
690    }
691
692    /// The artifacts list is how an answer points at what it could never
693    /// contain: a dataset, a report, a directory of generated files. It travels
694    /// with the descriptor so a caller can fetch them without parsing paths back
695    /// out of prose.
696    #[test]
697    fn artifacts_attach_to_a_submission_and_reach_the_descriptor() {
698        let output = FinalOutput::new(
699            "the summary",
700            Some("markdown".to_string()),
701            "present".to_string(),
702            42,
703        )
704        .with_artifacts(vec![
705            Artifact::from_path("data/dataset.csv"),
706            Artifact::from_path("report.pdf"),
707        ]);
708
709        assert_eq!(output.artifacts[0].path, "data/dataset.csv");
710        assert_eq!(output.artifacts[0].name, "dataset.csv");
711        assert_eq!(output.artifacts[1].name, "report.pdf");
712        assert_eq!(output.descriptor().artifacts, output.artifacts);
713        // An answer recorded before artifacts were typed carried bare paths.
714        let old: FinalOutputDescriptor = serde_json::from_str(
715            "{\"stage\":\"s\",\"submitted_at\":1,\"artifacts\":[\"a/b.csv\",{\"name\":\"final\",\"path\":\"out.mp4\",\"mime_type\":\"video/mp4\",\"size\":9}]}",
716        )
717        .unwrap();
718        assert_eq!(old.artifacts[0].name, "b.csv");
719        assert_eq!(
720            old.artifacts[0].mime_type.as_str(),
721            "application/octet-stream"
722        );
723        assert_eq!(old.artifacts[1].name, "final");
724        assert_eq!(old.artifacts[1].size, 9);
725        assert_eq!(Artifact::from_path("").name, "");
726        assert!(
727            serde_json::from_str::<FinalOutputDescriptor>(
728                "{\"stage\":\"s\",\"submitted_at\":1,\"artifacts\":5}"
729            )
730            .is_err()
731        );
732        assert_eq!(Artifact::from_path("dir\\x.png").name, "x.png");
733        // The bytes stay out of the descriptor: it goes in `meta.json`, which is
734        // read for every run in a listing.
735        assert_eq!(output.descriptor().bytes, "the summary".len());
736    }
737
738    #[test]
739    fn a_submission_carries_no_artifacts_unless_given_some() {
740        assert!(
741            FinalOutput::new("x", None, "present".to_string(), 0)
742                .artifacts
743                .is_empty()
744        );
745    }
746
747    #[test]
748    fn declared_artifacts_cascade_whole_and_retire_on_a_reshape() {
749        let art = |name: &str| ArtifactSpec {
750            name: name.to_string(),
751            mime_type: "video/*".to_string(),
752            required: true,
753            description: None,
754        };
755        let agent = OutputSpec {
756            format: Some("markdown".to_string()),
757            artifacts: vec![art("agent-file")],
758            ..OutputSpec::default()
759        };
760        let stage = OutputSpec {
761            artifacts: vec![art("stage-file")],
762            ..OutputSpec::default()
763        };
764        // The nearest non-empty list, whole.
765        let resolved = resolve_output_spec(Some(&agent), Some(&stage), None).unwrap();
766        assert_eq!(resolved.artifacts[0].name, "stage-file");
767        let resolved = resolve_output_spec(Some(&agent), None, None).unwrap();
768        assert_eq!(resolved.artifacts[0].name, "agent-file");
769        // A reshaping request retires them with the schema and validator.
770        let request = OutputSpec {
771            format: Some("json".to_string()),
772            ..OutputSpec::default()
773        };
774        let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
775        assert!(resolved.artifacts.is_empty());
776        // Unless it brings its own.
777        let request = OutputSpec {
778            format: Some("json".to_string()),
779            artifacts: vec![art("request-file")],
780            ..OutputSpec::default()
781        };
782        let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
783        assert_eq!(resolved.artifacts[0].name, "request-file");
784        assert!(!agent.is_empty());
785    }
786
787    #[test]
788    fn empty_spec_constrains_nothing() {
789        assert!(OutputSpec::default().is_empty());
790        assert!(!spec(Some("json"), None).is_empty());
791        assert!(!spec(None, Some(json!({}))).is_empty());
792        assert!(
793            !OutputSpec {
794                instructions: Some("be brief".to_string()),
795                ..OutputSpec::default()
796            }
797            .is_empty()
798        );
799        assert!(
800            !OutputSpec {
801                example: Some("<doc/>".to_string()),
802                ..OutputSpec::default()
803            }
804            .is_empty()
805        );
806        assert!(
807            !OutputSpec {
808                on_validator_error: Some(OnValidatorError::Accept),
809                ..OutputSpec::default()
810            }
811            .is_empty()
812        );
813    }
814
815    #[test]
816    fn no_level_asking_for_output_resolves_to_none() {
817        assert_eq!(resolve_output_spec(None, None, None), None);
818    }
819
820    #[test]
821    fn later_levels_win_field_by_field() {
822        let agent = OutputSpec {
823            format: Some("markdown".to_string()),
824            instructions: Some("agent guidance".to_string()),
825            example: Some("agent example".to_string()),
826            schema: None,
827            validator: None,
828            on_validator_error: None,
829            overwrite_artifacts: None,
830            artifacts: Vec::new(),
831        };
832        let stage = OutputSpec {
833            instructions: Some("stage guidance".to_string()),
834            ..OutputSpec::default()
835        };
836        let resolved = resolve_output_spec(Some(&agent), Some(&stage), None)
837            .expect("some level asked for an output");
838        // The stage narrows one field; the rest fall through to the agent.
839        assert_eq!(resolved.instructions.as_deref(), Some("stage guidance"));
840        assert_eq!(resolved.format.as_deref(), Some("markdown"));
841        assert_eq!(resolved.example.as_deref(), Some("agent example"));
842    }
843
844    /// The listing columns every surface prints: the hash prefix rides along
845    /// only when the bytes were stored.
846    #[test]
847    fn an_artifact_lists_its_columns_with_the_hash_only_when_stored() {
848        let mut artifact = Artifact::from_path("out/hero.png");
849        assert!(
850            artifact
851                .short_label()
852                .starts_with("hero.png (application/octet-stream, ")
853        );
854        let bare = artifact.detail_columns();
855        assert!(
856            bare.starts_with("out/hero.png  application/octet-stream  "),
857            "{bare}"
858        );
859        assert!(!bare.contains("sha256"));
860        artifact.sha256 = "abcdef0123456789".repeat(4);
861        assert!(artifact.detail_columns().ends_with("  sha256:abcdef012345"));
862    }
863
864    #[test]
865    fn a_stage_alone_can_ask_for_an_output() {
866        let stage = spec(Some("a2ui"), None);
867        let resolved =
868            resolve_output_spec(None, Some(&stage), None).expect("the stage asked for one");
869        assert_eq!(resolved.format.as_deref(), Some("a2ui"));
870    }
871
872    /// Naming the format the blueprint already declared keeps its schema: a
873    /// caller who asks for exactly what is on offer must not lose the check
874    /// that comes with it.
875    #[test]
876    fn re_stating_the_declared_format_keeps_its_shape_checks() {
877        let agent = OutputSpec {
878            format: Some("json".to_string()),
879            schema: Some(json!({"type": "object"})),
880            validator: Some("v.rhai".to_string()),
881            ..OutputSpec::default()
882        };
883        let request = spec(Some("json"), None);
884        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
885            .expect("the agent asked for one");
886        assert_eq!(resolved.schema, Some(json!({"type": "object"})));
887        assert_eq!(resolved.validator.as_deref(), Some("v.rhai"));
888    }
889
890    /// A Rhai validator is written for one format, so it retires with the schema
891    /// when a caller asks for a different one - and its error policy, which is
892    /// about that validator, retires with it.
893    #[test]
894    fn reshaping_retires_the_validator_too() {
895        let agent = OutputSpec {
896            format: Some("a2ui".to_string()),
897            validator: Some("a2ui.rhai".to_string()),
898            on_validator_error: Some(OnValidatorError::Accept),
899            ..OutputSpec::default()
900        };
901        let request = spec(Some("xml"), None);
902        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
903            .expect("the agent asked for one");
904        assert_eq!(resolved.format.as_deref(), Some("xml"));
905        assert_eq!(resolved.validator, None);
906        assert_eq!(resolved.on_validator_error, None);
907    }
908
909    /// The error policy cascades the way the validator does: a stage's setting
910    /// beats the agent's.
911    #[test]
912    fn the_stage_error_policy_overrides_the_agents() {
913        let agent = OutputSpec {
914            format: Some("a2ui".to_string()),
915            validator: Some("a2ui.rhai".to_string()),
916            on_validator_error: Some(OnValidatorError::Reject),
917            ..OutputSpec::default()
918        };
919        let stage = OutputSpec {
920            on_validator_error: Some(OnValidatorError::Accept),
921            ..OutputSpec::default()
922        };
923        let resolved =
924            resolve_output_spec(Some(&agent), Some(&stage), None).expect("the agent asked for one");
925        assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
926        assert_eq!(
927            resolved.validator.as_deref(),
928            Some("a2ui.rhai"),
929            "the validator itself still falls through from the agent"
930        );
931    }
932
933    /// The artifact overwrite policy cascades on its own, reshape or not: a
934    /// stage beats the agent, a request beats both, and unset stays unset so
935    /// the user's config can decide.
936    #[test]
937    fn the_overwrite_policy_cascades_field_by_field() {
938        let agent = OutputSpec {
939            format: Some("markdown".to_string()),
940            overwrite_artifacts: Some(true),
941            ..OutputSpec::default()
942        };
943        let stage = OutputSpec {
944            overwrite_artifacts: Some(false),
945            ..OutputSpec::default()
946        };
947        let resolved = resolve_output_spec(Some(&agent), None, None).unwrap();
948        assert_eq!(resolved.overwrite_artifacts, Some(true));
949        let resolved = resolve_output_spec(Some(&agent), Some(&stage), None).unwrap();
950        assert_eq!(resolved.overwrite_artifacts, Some(false));
951        let request = OutputSpec {
952            format: Some("json".to_string()),
953            overwrite_artifacts: Some(true),
954            ..OutputSpec::default()
955        };
956        let resolved = resolve_output_spec(Some(&agent), Some(&stage), Some(&request)).unwrap();
957        assert_eq!(resolved.overwrite_artifacts, Some(true));
958        let resolved = resolve_output_spec(None, Some(&OutputSpec::default()), None).unwrap();
959        assert_eq!(resolved.overwrite_artifacts, None);
960        assert!(
961            !OutputSpec {
962                overwrite_artifacts: Some(false),
963                ..OutputSpec::default()
964            }
965            .is_empty()
966        );
967    }
968
969    /// A caller who reshapes and brings their own validator can bring their own
970    /// error policy with it.
971    #[test]
972    fn a_reshaping_caller_can_supply_their_own_error_policy() {
973        let agent = OutputSpec {
974            format: Some("a2ui".to_string()),
975            validator: Some("a2ui.rhai".to_string()),
976            ..OutputSpec::default()
977        };
978        let request = OutputSpec {
979            format: Some("xml".to_string()),
980            validator: Some("xml.rhai".to_string()),
981            on_validator_error: Some(OnValidatorError::Accept),
982            ..OutputSpec::default()
983        };
984        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
985            .expect("the agent asked for one");
986        assert_eq!(resolved.validator.as_deref(), Some("xml.rhai"));
987        assert_eq!(resolved.on_validator_error, Some(OnValidatorError::Accept));
988    }
989
990    /// A caller that brings its own checks keeps them.
991    #[test]
992    fn a_caller_can_supply_shape_checks_with_its_own_format() {
993        let agent = OutputSpec {
994            format: Some("a2ui".to_string()),
995            validator: Some("a2ui.rhai".to_string()),
996            ..OutputSpec::default()
997        };
998        let request = OutputSpec {
999            format: Some("json".to_string()),
1000            schema: Some(json!({"type": "array"})),
1001            ..OutputSpec::default()
1002        };
1003        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1004            .expect("the agent asked for one");
1005        assert_eq!(resolved.schema, Some(json!({"type": "array"})));
1006        assert_eq!(resolved.validator, None, "the agent's own is still retired");
1007    }
1008
1009    #[test]
1010    fn a_caller_reshaping_the_output_drops_the_declared_schema() {
1011        let agent = spec(Some("json"), Some(json!({"type": "object"})));
1012        // Caller names a different format and supplies no schema of its own:
1013        // the schema written for the old shape no longer applies.
1014        let request = spec(Some("a2ui"), None);
1015        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1016            .expect("the agent asked for one");
1017        assert_eq!(resolved.format.as_deref(), Some("a2ui"));
1018        assert_eq!(resolved.schema, None);
1019    }
1020
1021    #[test]
1022    fn a_caller_supplying_its_own_schema_keeps_it() {
1023        let agent = spec(Some("json"), Some(json!({"type": "object"})));
1024        let request = spec(Some("json"), Some(json!({"type": "array"})));
1025        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1026            .expect("the agent asked for one");
1027        assert_eq!(resolved.schema, Some(json!({"type": "array"})));
1028    }
1029
1030    #[test]
1031    fn a_caller_that_names_no_format_leaves_the_schema_alone() {
1032        let agent = spec(Some("json"), Some(json!({"type": "object"})));
1033        // Only instructions differ, so the declared shape still stands.
1034        let request = OutputSpec {
1035            instructions: Some("keep it short".to_string()),
1036            ..OutputSpec::default()
1037        };
1038        let resolved = resolve_output_spec(Some(&agent), None, Some(&request))
1039            .expect("the agent asked for one");
1040        assert_eq!(resolved.format.as_deref(), Some("json"));
1041        assert_eq!(resolved.schema, Some(json!({"type": "object"})));
1042    }
1043
1044    /// A parsed blueprint whose agent-level output is `agent`, over stages
1045    /// named and shaped by `stages`. Both take TOML output tables (or `None`),
1046    /// because a manifest is where these declarations really come from.
1047    fn blueprint_with(agent: Option<&str>, stages: &[(&str, Option<&str>)]) -> crate::Blueprint {
1048        let mut manifest =
1049            String::from("[agent]\nname = \"checked\"\nversion = \"1.0.0\"\ndescription = \"d\"\n");
1050        if let Some(fields) = agent {
1051            manifest.push_str(&format!("\n[agent.output]\n{fields}\n"));
1052        }
1053        for (name, output) in stages {
1054            manifest.push_str(&format!("\n[stages.{name}]\nsystem_prompt = \"p\"\n"));
1055            if let Some(fields) = output {
1056                manifest.push_str(&format!("\n[stages.{name}.output]\n{fields}\n"));
1057            }
1058        }
1059        crate::manifest::parse_manifest(&manifest).expect("the test manifest parses")
1060    }
1061
1062    /// The headline: a differing format retires the declared validator, and
1063    /// the warning names the stage, the script, and both formats.
1064    #[test]
1065    fn a_differing_format_earns_a_warning_naming_the_retired_validator() {
1066        let bp = blueprint_with(
1067            Some("format = \"markdown\"\nvalidator = \"checks/report.rhai\""),
1068            &[("plan", None)],
1069        );
1070        let request = spec(Some("json"), None);
1071        let warnings = retired_check_warnings(&bp, Some(&request));
1072        assert_eq!(warnings.len(), 1, "{warnings:?}");
1073        let line = &warnings[0];
1074        assert!(line.contains("'json'"), "{line}");
1075        assert!(line.contains("'markdown'"), "{line}");
1076        assert!(
1077            line.contains("the Rhai validator 'checks/report.rhai'"),
1078            "{line}"
1079        );
1080        assert!(line.contains("stage 'plan'"), "{line}");
1081    }
1082
1083    /// Re-stating the declared format keeps the checks (see
1084    /// `re_stating_the_declared_format_keeps_its_shape_checks`), so it earns
1085    /// no warning - and neither does a request with no format in it, nor no
1086    /// request at all.
1087    #[test]
1088    fn nothing_retired_means_nothing_warned() {
1089        let bp = blueprint_with(
1090            Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1091            &[("plan", None)],
1092        );
1093        let restated = spec(Some("markdown"), None);
1094        assert!(retired_check_warnings(&bp, Some(&restated)).is_empty());
1095        let formatless = OutputSpec {
1096            instructions: Some("keep it short".to_string()),
1097            ..OutputSpec::default()
1098        };
1099        assert!(retired_check_warnings(&bp, Some(&formatless)).is_empty());
1100        assert!(retired_check_warnings(&bp, None).is_empty());
1101        // And a blueprint with nothing retirable has nothing to lose.
1102        let unchecked = blueprint_with(Some("format = \"markdown\""), &[("plan", None)]);
1103        let reshaped = spec(Some("json"), None);
1104        assert!(retired_check_warnings(&unchecked, Some(&reshaped)).is_empty());
1105    }
1106
1107    /// A schema alone, a validator alone, and the two together each word the
1108    /// loss precisely.
1109    #[test]
1110    fn the_warning_names_exactly_what_is_lost() {
1111        let request = spec(Some("json"), None);
1112        let schema_only = blueprint_with(
1113            Some("format = \"markdown\"\nschema = { type = \"object\" }"),
1114            &[("plan", None)],
1115        );
1116        let warnings = retired_check_warnings(&schema_only, Some(&request));
1117        assert!(
1118            warnings[0].contains("the JSON schema declared"),
1119            "{warnings:?}"
1120        );
1121        let both = blueprint_with(
1122            Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1123            &[("plan", None)],
1124        );
1125        let warnings = retired_check_warnings(&both, Some(&request));
1126        assert!(
1127            warnings[0].contains("the Rhai validator 'v.rhai' and the JSON schema"),
1128            "{warnings:?}"
1129        );
1130    }
1131
1132    /// A caller who brings a schema for the new shape replaced the declared
1133    /// one on purpose, so only the validator - which nothing can replace - is
1134    /// still worth a warning.
1135    #[test]
1136    fn a_replacement_schema_is_not_warned_about() {
1137        let bp = blueprint_with(
1138            Some("format = \"markdown\"\nvalidator = \"v.rhai\"\nschema = { type = \"object\" }"),
1139            &[("plan", None)],
1140        );
1141        let request = spec(Some("json"), Some(json!({"type": "array"})));
1142        let warnings = retired_check_warnings(&bp, Some(&request));
1143        assert_eq!(warnings.len(), 1, "{warnings:?}");
1144        assert!(
1145            warnings[0].contains("the Rhai validator 'v.rhai'"),
1146            "{warnings:?}"
1147        );
1148        assert!(
1149            !warnings[0].contains("JSON schema declared"),
1150            "{warnings:?}"
1151        );
1152        // And the advice acknowledges the schema they brought rather than
1153        // asking for one.
1154        assert!(
1155            warnings[0].contains("the schema supplied with the request"),
1156            "{warnings:?}"
1157        );
1158        // With nothing but the schema declared, the replacement leaves nothing
1159        // retired at all.
1160        let schema_only = blueprint_with(
1161            Some("format = \"markdown\"\nschema = { type = \"object\" }"),
1162            &[("plan", None)],
1163        );
1164        assert!(retired_check_warnings(&schema_only, Some(&request)).is_empty());
1165    }
1166
1167    /// An agent-level validator shared by several stages is one line naming
1168    /// them all, and a stage with its own distinct declaration gets its own.
1169    #[test]
1170    fn stages_losing_the_same_checks_share_one_line() {
1171        let bp = blueprint_with(
1172            Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
1173            &[
1174                ("plan", None),
1175                ("draft", None),
1176                ("wrap", Some("format = \"a2ui\"\nvalidator = \"a2ui.rhai\"")),
1177            ],
1178        );
1179        let request = spec(Some("json"), None);
1180        let warnings = retired_check_warnings(&bp, Some(&request));
1181        assert_eq!(warnings.len(), 2, "{warnings:?}");
1182        assert!(
1183            warnings[0].contains("stages 'plan' and 'draft'"),
1184            "{warnings:?}"
1185        );
1186        assert!(warnings[1].contains("stage 'wrap'"), "{warnings:?}");
1187        assert!(warnings[1].contains("'a2ui'"), "{warnings:?}");
1188    }
1189
1190    /// A declaration that lives only on a stage retires the same way: the
1191    /// warning does not need an agent-level `[agent.output]` to exist.
1192    #[test]
1193    fn a_stage_level_declaration_retires_without_an_agent_one() {
1194        let bp = blueprint_with(
1195            None,
1196            &[(
1197                "plan",
1198                Some("format = \"markdown\"\nvalidator = \"v.rhai\""),
1199            )],
1200        );
1201        let request = spec(Some("json"), None);
1202        let warnings = retired_check_warnings(&bp, Some(&request));
1203        assert_eq!(warnings.len(), 1, "{warnings:?}");
1204        assert!(
1205            warnings[0].contains("the Rhai validator 'v.rhai'"),
1206            "{warnings:?}"
1207        );
1208    }
1209
1210    /// A stage that re-declares the requested format keeps its checks even
1211    /// while its siblings lose theirs, because the cascade is per stage.
1212    #[test]
1213    fn a_stage_already_in_the_requested_format_keeps_its_checks() {
1214        let bp = blueprint_with(
1215            Some("format = \"markdown\"\nvalidator = \"shared.rhai\""),
1216            &[("plan", None), ("emit", Some("format = \"json\""))],
1217        );
1218        let request = spec(Some("json"), None);
1219        let warnings = retired_check_warnings(&bp, Some(&request));
1220        assert_eq!(warnings.len(), 1, "{warnings:?}");
1221        assert!(warnings[0].contains("stage 'plan'"), "{warnings:?}");
1222    }
1223
1224    /// A validator declared without any format is still retired by naming one
1225    /// (the request reshapes an output that never named its shape), and the
1226    /// warning says so without inventing a declared format.
1227    #[test]
1228    fn a_formatless_declaration_is_reshaped_by_any_request() {
1229        let bp = blueprint_with(Some("validator = \"v.rhai\""), &[("plan", None)]);
1230        let request = spec(Some("json"), None);
1231        let warnings = retired_check_warnings(&bp, Some(&request));
1232        assert_eq!(warnings.len(), 1, "{warnings:?}");
1233        assert!(
1234            warnings[0].contains("declared without a format"),
1235            "{warnings:?}"
1236        );
1237    }
1238
1239    /// The three list shapes, plus the guard for a slice no caller produces.
1240    #[test]
1241    fn stage_phrases_read_as_prose() {
1242        let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
1243        assert_eq!(stage_phrase(&names(&["a"])), "stage 'a'");
1244        assert_eq!(stage_phrase(&names(&["a", "b"])), "stages 'a' and 'b'");
1245        assert_eq!(
1246            stage_phrase(&names(&["a", "b", "c"])),
1247            "stages 'a', 'b', and 'c'"
1248        );
1249        assert_eq!(stage_phrase(&[]), "its stages");
1250    }
1251
1252    #[test]
1253    fn short_content_is_stored_verbatim() {
1254        let out = FinalOutput::new(
1255            "done: 3 files",
1256            Some("markdown".to_string()),
1257            "wrap".into(),
1258            7,
1259        );
1260        assert_eq!(out.content, "done: 3 files");
1261        assert_eq!(out.format.as_deref(), Some("markdown"));
1262        assert_eq!(out.stage, "wrap");
1263        assert_eq!(out.submitted_at, 7);
1264        assert!(!out.truncated);
1265    }
1266
1267    #[test]
1268    fn oversized_content_is_cut_at_a_char_boundary_and_flagged() {
1269        // A multi-byte character straddling the cap: slicing by byte index here
1270        // is what once aborted the daemon, so the cut must walk back.
1271        let mut content = "a".repeat(MAX_FINAL_OUTPUT_BYTES - 1);
1272        content.push('\u{1f600}');
1273        let out = FinalOutput::new(&content, None, "wrap".into(), 0);
1274        assert!(out.truncated);
1275        assert_eq!(out.content.len(), MAX_FINAL_OUTPUT_BYTES - 1);
1276        assert!(out.format.is_none());
1277    }
1278
1279    #[test]
1280    fn describe_spec_is_empty_when_nothing_is_constrained() {
1281        assert_eq!(describe_spec(&OutputSpec::default()), "");
1282    }
1283
1284    #[test]
1285    fn describe_spec_renders_every_field_it_has() {
1286        let described = describe_spec(&OutputSpec {
1287            format: Some("a2ui".to_string()),
1288            instructions: Some("One card per finding.".to_string()),
1289            example: Some("{\"root\": {}}".to_string()),
1290            schema: Some(json!({"type": "object"})),
1291            validator: None,
1292            on_validator_error: None,
1293            overwrite_artifacts: None,
1294            artifacts: Vec::new(),
1295        });
1296        assert!(described.contains("Return it in this format: a2ui."));
1297        assert!(described.contains("One card per finding."));
1298        assert!(described.contains("valid against this schema"));
1299        assert!(described.contains("{\"root\": {}}"));
1300        let with_files = describe_spec(&OutputSpec {
1301            artifacts: vec![
1302                ArtifactSpec {
1303                    name: "final".to_string(),
1304                    mime_type: "video/mp4".to_string(),
1305                    required: true,
1306                    description: Some("the cut".to_string()),
1307                },
1308                ArtifactSpec {
1309                    name: "notes".to_string(),
1310                    mime_type: "text/*".to_string(),
1311                    required: false,
1312                    description: None,
1313                },
1314            ],
1315            ..OutputSpec::default()
1316        });
1317        assert!(
1318            with_files.contains("- final (video/mp4, required): the cut\n- notes (text/*)"),
1319            "{with_files}"
1320        );
1321    }
1322
1323    /// Without this the spec and the stage's own system prompt are two peer
1324    /// instructions, and a strongly-shaped stage prompt wins on some models
1325    /// and loses on others.
1326    #[test]
1327    fn a_constrained_spec_says_it_outranks_the_stage_prompt() {
1328        let described = describe_spec(&OutputSpec {
1329            instructions: Some("Reply with only the integer.".to_string()),
1330            ..OutputSpec::default()
1331        });
1332        assert!(
1333            described.contains("Where anything else you were told"),
1334            "{described}"
1335        );
1336        // Last, so it is read as governing what precedes it rather than as one
1337        // more line the next paragraph can override.
1338        assert!(
1339            described.trim_end().ends_with("follow this."),
1340            "{described}"
1341        );
1342    }
1343
1344    /// A format on its own is still a shape, so it still outranks a prompt that
1345    /// describes a different one.
1346    #[test]
1347    fn a_format_only_spec_claims_precedence_too() {
1348        let described = describe_spec(&OutputSpec {
1349            format: Some("text".to_string()),
1350            ..OutputSpec::default()
1351        });
1352        assert!(
1353            described.contains("Where anything else you were told"),
1354            "{described}"
1355        );
1356    }
1357
1358    /// The claim is scoped to presentation. A spec that constrains nothing must
1359    /// not tell a model to disregard its stage prompt.
1360    #[test]
1361    fn an_unconstrained_spec_claims_nothing() {
1362        assert!(!describe_spec(&OutputSpec::default()).contains("follow this"));
1363    }
1364
1365    #[test]
1366    fn a_spec_round_trips_through_serde() {
1367        let original = spec(Some("a2ui"), Some(json!({"type": "object"})));
1368        let text = serde_json::to_string(&original).expect("a spec serializes");
1369        let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1370        assert_eq!(back, original);
1371        // Unset fields stay off the wire rather than serializing as nulls.
1372        assert!(!text.contains("instructions"));
1373        assert!(!text.contains("on_validator_error"));
1374    }
1375
1376    /// The wire spelling is the manifest spelling: `accept` and `reject`,
1377    /// nothing else. A request naming a third policy is refused rather than
1378    /// quietly mapped to either behaviour.
1379    #[test]
1380    fn the_error_policy_uses_the_manifest_spelling_on_the_wire() {
1381        let original = OutputSpec {
1382            on_validator_error: Some(OnValidatorError::Accept),
1383            ..OutputSpec::default()
1384        };
1385        let text = serde_json::to_string(&original).expect("a spec serializes");
1386        assert!(text.contains(r#""on_validator_error":"accept""#), "{text}");
1387        let back: OutputSpec = serde_json::from_str(&text).expect("and deserializes");
1388        assert_eq!(back, original);
1389
1390        let rejected = serde_json::from_str::<OutputSpec>(r#"{"on_validator_error":"sometimes"}"#);
1391        assert!(rejected.is_err(), "an unknown policy must not deserialize");
1392    }
1393
1394    #[test]
1395    fn a_final_output_round_trips_through_serde() {
1396        let original = FinalOutput::new("answer", None, "wrap".into(), 1);
1397        let text = serde_json::to_string(&original).expect("an output serializes");
1398        let back: FinalOutput = serde_json::from_str(&text).expect("and deserializes");
1399        assert_eq!(back, original);
1400    }
1401}