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;