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