Skip to main content

leviath_runtime/
output_tool.rs

1//! Handling for the `submit_output` tool: an agent handing back its answer.
2//!
3//! Applied inline by the tool-dispatch pipeline system rather than on the async
4//! tool lane, for the same reason the `context_*` tools are: it writes to the
5//! live [`ContextWindow`] and to an ECS component, neither of which the lane can
6//! reach. [`handle_output_tool`] is the pure core that system calls.
7//!
8//! # What this does not do
9//!
10//! It does not interpret the format. A submission asking for a2ui, XML, CSV, or
11//! anything else is recorded byte for byte; the label travels alongside it for
12//! consumers to dispatch on, and nothing here reads it.
13//!
14//! The one exception is opt-in: when the resolved [`OutputSpec`] carries a JSON
15//! Schema, the submission is parsed and validated, and a failure is refused back
16//! to the model as an `[error] …` result so the next turn can correct it. That
17//! refusal path is deliberately the same shape as the dispatch layer's Layer 2
18//! argument refusal, down to the `[error]` prefix, which is already in the
19//! no-effect list and so keeps a rejected submission from counting as work.
20//!
21//! [`OutputSpec`]: leviath_core::output::OutputSpec
22
23use leviath_core::output::{FinalOutput, OutputSpec};
24
25use crate::components::ContextWindow;
26
27/// The context region a submitted output is mirrored into.
28///
29/// Created automatically alongside `conversation` and `tool_results` (see
30/// `context_setup`), and pinned, so the answer stays visible to later stages and
31/// lands in the run's `context.json` with no extra persistence work.
32pub const FINAL_OUTPUT_REGION: &str = "final_output";
33
34/// Token budget for [`FINAL_OUTPUT_REGION`].
35///
36/// Deliberately far smaller than a maximal answer. The region is a convenience,
37/// not the storage: it exists so a later stage can see what was submitted and
38/// revise it, while the authoritative copy lives on the component and on disk.
39/// Sized at the whole answer, a maximal submission would pin ~65k tokens into
40/// every subsequent inference for the rest of the run, which costs more than the
41/// convenience is worth. A long answer is mirrored as a preview instead.
42pub const FINAL_OUTPUT_REGION_TOKENS: usize = 2_000;
43
44/// Marker appended to a mirrored answer that did not fit the region.
45const MIRROR_TRUNCATION_MARKER: &str =
46    "\n[...the full answer is on the run's final output, not in context]";
47
48/// The stage name a submission is really just naming, if it is doing that.
49///
50/// A stage entered by a `dead_end` edge sees a heavily compacted context and a
51/// prompt that has, moments earlier, been asking it which edge to take. Some
52/// models answer that older question: they call `submit_output` with the name of
53/// a stage. Every check in [`handle_output_tool`] passes - it is non-empty, it is
54/// valid text, no schema forbids it - and the run finishes `complete` with one
55/// word as its deliverable. That is worse than an error, because `complete`
56/// reads as success to every consumer: a benchmark harness scored such a run 0.0
57/// and carried it in a results matrix as finished until a person read the answer.
58///
59/// Matched against the blueprint's own stage names rather than a general "single
60/// short word" rule. A one-word answer is often perfectly legitimate - a
61/// classifier replying `positive`, a yes/no question - and refusing those would
62/// break working agents to catch this one. A submission that is exactly the name
63/// of a stage in the same blueprint is not a plausible answer to anything.
64///
65/// Case-insensitive because the token is being echoed by a model, and `Analyze`
66/// is the same mistake as `analyze`.
67fn routing_token<'a>(content: &str, stage_names: &'a [String]) -> Option<&'a str> {
68    let trimmed = content.trim();
69    stage_names
70        .iter()
71        .find(|name| name.eq_ignore_ascii_case(trimmed))
72        .map(String::as_str)
73}
74
75/// Whether a tool name is the final-output tool this module handles.
76pub fn is_output_tool(name: &str) -> bool {
77    name == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
78}
79
80/// Everything a submission is judged against that is not the submission.
81///
82/// Grouped rather than threaded positionally: these five travel together, three
83/// of them are `Option<&_>`, and the two `&str`-ish ones sit adjacent - a
84/// transposition type-checks, and the compiler is the only thing that was ever
85/// going to notice. Adding the stage-name list as a seventh positional argument
86/// is what pushed this over the workspace's argument-count lint, which does not
87/// permit a suppression.
88pub struct OutputContext<'a> {
89    /// The shape this stage asked for: format, schema, validator.
90    pub spec: Option<&'a OutputSpec>,
91    /// The agent's own Rhai validators, by script name.
92    pub validators: Option<&'a crate::components::OutputValidators>,
93    /// The stage doing the submitting, recorded on the answer.
94    pub stage: &'a str,
95    /// Every stage name in this blueprint, for the routing-token guard.
96    pub stage_names: &'a [String],
97    /// Where relative artifact paths resolve from.
98    pub workdir: Option<&'a std::path::Path>,
99}
100
101/// Apply a `submit_output` call.
102///
103/// Returns the result text the model sees, and the recorded output when the
104/// submission was accepted. `None` means nothing was recorded and the model has
105/// been told why, so the caller must leave any previously submitted output in
106/// place: a rejected correction should not erase a good answer.
107///
108/// `ctx.spec` is the shape resolved for this stage (agent, stage, and caller
109/// combined). `None` means no level asked for a particular shape, which is not
110/// an error - the stage still wanted an answer, just not a specific form.
111pub fn handle_output_tool(
112    args: &serde_json::Value,
113    ctx: &OutputContext<'_>,
114    now: i64,
115    window: &mut ContextWindow,
116) -> (String, Option<FinalOutput>) {
117    let OutputContext {
118        spec,
119        validators,
120        stage,
121        stage_names,
122        workdir,
123    } = *ctx;
124    let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
125        return ("[error] missing 'content' argument".to_string(), None);
126    };
127    if content.trim().is_empty() {
128        return (
129            "[error] final output is empty - submit the answer itself, not a placeholder"
130                .to_string(),
131            None,
132        );
133    }
134
135    // A routing token is not an answer, however well-formed it is.
136    if let Some(token) = routing_token(content, stage_names) {
137        return (
138            format!(
139                "[error] '{token}' is the name of a stage in this agent, not an answer. \
140                 Submit the finished work itself - the whole thing, as the reader will see it."
141            ),
142            None,
143        );
144    }
145
146    // Is it the format it claims to be? Well-formedness only, and only for the
147    // handful of formats this crate can parse - a label it has never seen is
148    // carried through unchecked, which is the whole point of an opaque label.
149    // Runs before the schema check because "this is not even JSON" is a more
150    // useful thing to hear than a list of missing properties.
151    if let Some(format) = spec.and_then(|s| s.format.as_deref())
152        && let Err(reason) = leviath_tools::validate::format::check(Some(format), content)
153    {
154        return (
155            format!("[error] the final output is not valid {format}: {reason}"),
156            None,
157        );
158    }
159
160    // Does it have the shape the author asked for? Only when they supplied a
161    // schema to check against.
162    if let Some(schema) = spec.and_then(|s| s.schema.as_ref()) {
163        match leviath_tools::validate_output(schema, content) {
164            leviath_tools::ArgValidation::Invalid(message) => return (message, None),
165            // A schema that will not compile skips the check rather than
166            // refusing every submission, matching how tool-argument validation
167            // treats the same situation. Refusing here would let one bad schema
168            // make a run unable to finish.
169            leviath_tools::ArgValidation::SchemaUnusable(e) => {
170                tracing::warn!(
171                    stage = %stage,
172                    error = %e,
173                    "output schema did not compile; recording the submission unchecked"
174                );
175            }
176            leviath_tools::ArgValidation::Valid => {}
177        }
178    }
179
180    // An agent's own validator, for a format nothing here can parse and a shape
181    // no JSON Schema can describe. A broken script is reported as broken rather
182    // than treated as a rejection: reading it as "the answer is wrong" would
183    // burn the retry budget on a script bug and end the run with nothing.
184    if let Some(script) = spec.and_then(|s| s.validator.as_deref())
185        && let Some(validator) = validators.and_then(|v| v.0.get(script))
186    {
187        match leviath_scripting::output_validator::validate(validator, content) {
188            leviath_scripting::output_validator::Verdict::Invalid(reason) => {
189                return (
190                    format!("[error] the final output was rejected: {reason}"),
191                    None,
192                );
193            }
194            leviath_scripting::output_validator::Verdict::Unusable(reason) => {
195                tracing::warn!(
196                    stage = %stage,
197                    error = %reason,
198                    "output validator failed to run; recording the submission unchecked"
199                );
200            }
201            leviath_scripting::output_validator::Verdict::Valid => {}
202        }
203    }
204
205    // Refused rather than silently dropped: an answer whose artifact list
206    // quietly lost an entry sends the caller looking for a file that was named
207    // and then forgotten.
208    let artifacts = match resolve_artifacts(args, workdir) {
209        Ok(paths) => paths,
210        Err(message) => return (message, None),
211    };
212
213    let output = FinalOutput::new(
214        content,
215        spec.and_then(|s| s.format.clone()),
216        stage.to_string(),
217        now,
218    )
219    .with_artifacts(artifacts);
220    mirror_into_region(window, &output.content);
221
222    let mut ack = "Recorded as this run's final output.".to_string();
223    if output.truncated {
224        ack.push_str(&format!(
225            " It exceeded the {} KiB limit and was truncated; submit a shorter answer, or write \
226             the long form to a file and summarise it here.",
227            leviath_core::output::MAX_FINAL_OUTPUT_BYTES / 1024
228        ));
229    }
230    (ack, Some(output))
231}
232
233/// Split the `artifacts` argument into paths that resolve inside `workdir` and
234/// paths that do not.
235///
236/// The same containment rule the files endpoint enforces when serving one, so a
237/// path that survives here is a path a consumer can actually fetch. A missing
238/// file is fine: an agent may name something it is about to finish writing, and
239/// `resolves_within` checks where a path lands rather than whether it exists.
240fn resolve_artifacts(
241    args: &serde_json::Value,
242    workdir: Option<&std::path::Path>,
243) -> Result<Vec<String>, String> {
244    let listed: Vec<&str> = args
245        .get("artifacts")
246        .and_then(|v| v.as_array())
247        .map(|a| {
248            a.iter()
249                .filter_map(|v| v.as_str())
250                .filter(|p| !p.trim().is_empty())
251                .collect()
252        })
253        .unwrap_or_default();
254    if listed.is_empty() {
255        return Ok(Vec::new());
256    }
257    // No workdir means nothing to resolve against, so nothing can be verified.
258    // Unreachable for a real run (every one carries its metadata); loud rather
259    // than silent if it ever is.
260    let Some(workdir) = workdir else {
261        return Err(
262            "[error] cannot record artifacts: this run has no working directory to resolve \
263             them against"
264                .to_string(),
265        );
266    };
267    let (kept, rejected): (Vec<&str>, Vec<&str>) = listed
268        .into_iter()
269        .partition(|p| leviath_core::resolves_within(&workdir.join(p), workdir));
270    match rejected.is_empty() {
271        true => Ok(kept.into_iter().map(str::to_string).collect()),
272        false => Err(format!(
273            "[error] these artifact paths do not resolve inside the working directory: {}",
274            rejected.join(", ")
275        )),
276    }
277}
278
279/// Mirror the submission into the pinned `final_output` region, replacing
280/// whatever was there.
281///
282/// Best-effort: a world whose layout somehow lacks the region still records the
283/// output on the component, which is what every consumer actually reads. The
284/// region exists so the answer stays in the agent's own context (a later stage
285/// can revise it) and so it appears in `context.json`.
286fn mirror_into_region(window: &mut ContextWindow, content: &str) {
287    // Read the budget and clear in one borrow. Asking for the region twice
288    // leaves a second "what if it is missing" branch that the first check has
289    // already ruled out, so nothing can ever take it.
290    let budget = {
291        let Some(region) = window.get_region_mut(FINAL_OUTPUT_REGION) else {
292            return;
293        };
294        region.clear();
295        region.max_tokens
296    };
297    let mirrored = fit_to_region(content, budget);
298    let tokens = leviath_core::estimate_tokens(&mirrored);
299    window.current_tokens = window.calculate_tokens();
300    // Through the window method rather than the region directly, so a custom
301    // region's `on_write` hook fires - the same reason `context_write` does it.
302    let _ = window.add_to_region(FINAL_OUTPUT_REGION, mirrored, tokens);
303}
304
305/// Trim `content` to fit `budget` tokens, marking it when cut.
306///
307/// An over-budget entry is *rejected* by `add_entry`, not truncated, so mirroring
308/// a long answer without this would leave the region empty - the one outcome
309/// worse than a preview.
310fn fit_to_region(content: &str, budget: usize) -> String {
311    let allowed = budget.saturating_mul(4);
312    if content.len() <= allowed {
313        return content.to_string();
314    }
315    let Some(room) = allowed.checked_sub(MIRROR_TRUNCATION_MARKER.len()) else {
316        return String::new();
317    };
318    format!(
319        "{}{MIRROR_TRUNCATION_MARKER}",
320        leviath_core::truncate_at_boundary(content, room)
321    )
322}
323
324#[cfg(test)]
325mod tests;