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/// Whether a tool name is the final-output tool this module handles.
49pub fn is_output_tool(name: &str) -> bool {
50 name == leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
51}
52
53/// Apply a `submit_output` call.
54///
55/// Returns the result text the model sees, and the recorded output when the
56/// submission was accepted. `None` means nothing was recorded and the model has
57/// been told why, so the caller must leave any previously submitted output in
58/// place: a rejected correction should not erase a good answer.
59///
60/// `spec` is the shape resolved for this stage (agent, stage, and caller
61/// combined). `None` means no level asked for a particular shape, which is not
62/// an error - the stage still wanted an answer, just not a specific form.
63pub fn handle_output_tool(
64 args: &serde_json::Value,
65 spec: Option<&OutputSpec>,
66 validators: Option<&crate::components::OutputValidators>,
67 stage: &str,
68 now: i64,
69 workdir: Option<&std::path::Path>,
70 window: &mut ContextWindow,
71) -> (String, Option<FinalOutput>) {
72 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
73 return ("[error] missing 'content' argument".to_string(), None);
74 };
75 if content.trim().is_empty() {
76 return (
77 "[error] final output is empty - submit the answer itself, not a placeholder"
78 .to_string(),
79 None,
80 );
81 }
82
83 // Is it the format it claims to be? Well-formedness only, and only for the
84 // handful of formats this crate can parse - a label it has never seen is
85 // carried through unchecked, which is the whole point of an opaque label.
86 // Runs before the schema check because "this is not even JSON" is a more
87 // useful thing to hear than a list of missing properties.
88 if let Some(format) = spec.and_then(|s| s.format.as_deref())
89 && let Err(reason) = leviath_tools::validate::format::check(Some(format), content)
90 {
91 return (
92 format!("[error] the final output is not valid {format}: {reason}"),
93 None,
94 );
95 }
96
97 // Does it have the shape the author asked for? Only when they supplied a
98 // schema to check against.
99 if let Some(schema) = spec.and_then(|s| s.schema.as_ref()) {
100 match leviath_tools::validate_output(schema, content) {
101 leviath_tools::ArgValidation::Invalid(message) => return (message, None),
102 // A schema that will not compile skips the check rather than
103 // refusing every submission, matching how tool-argument validation
104 // treats the same situation. Refusing here would let one bad schema
105 // make a run unable to finish.
106 leviath_tools::ArgValidation::SchemaUnusable(e) => {
107 tracing::warn!(
108 stage = %stage,
109 error = %e,
110 "output schema did not compile; recording the submission unchecked"
111 );
112 }
113 leviath_tools::ArgValidation::Valid => {}
114 }
115 }
116
117 // An agent's own validator, for a format nothing here can parse and a shape
118 // no JSON Schema can describe. A broken script is reported as broken rather
119 // than treated as a rejection: reading it as "the answer is wrong" would
120 // burn the retry budget on a script bug and end the run with nothing.
121 if let Some(script) = spec.and_then(|s| s.validator.as_deref())
122 && let Some(validator) = validators.and_then(|v| v.0.get(script))
123 {
124 match leviath_scripting::output_validator::validate(validator, content) {
125 leviath_scripting::output_validator::Verdict::Invalid(reason) => {
126 return (
127 format!("[error] the final output was rejected: {reason}"),
128 None,
129 );
130 }
131 leviath_scripting::output_validator::Verdict::Unusable(reason) => {
132 tracing::warn!(
133 stage = %stage,
134 error = %reason,
135 "output validator failed to run; recording the submission unchecked"
136 );
137 }
138 leviath_scripting::output_validator::Verdict::Valid => {}
139 }
140 }
141
142 // Refused rather than silently dropped: an answer whose artifact list
143 // quietly lost an entry sends the caller looking for a file that was named
144 // and then forgotten.
145 let artifacts = match resolve_artifacts(args, workdir) {
146 Ok(paths) => paths,
147 Err(message) => return (message, None),
148 };
149
150 let output = FinalOutput::new(
151 content,
152 spec.and_then(|s| s.format.clone()),
153 stage.to_string(),
154 now,
155 )
156 .with_artifacts(artifacts);
157 mirror_into_region(window, &output.content);
158
159 let mut ack = "Recorded as this run's final output.".to_string();
160 if output.truncated {
161 ack.push_str(&format!(
162 " It exceeded the {} KiB limit and was truncated; submit a shorter answer, or write \
163 the long form to a file and summarise it here.",
164 leviath_core::output::MAX_FINAL_OUTPUT_BYTES / 1024
165 ));
166 }
167 (ack, Some(output))
168}
169
170/// Split the `artifacts` argument into paths that resolve inside `workdir` and
171/// paths that do not.
172///
173/// The same containment rule the files endpoint enforces when serving one, so a
174/// path that survives here is a path a consumer can actually fetch. A missing
175/// file is fine: an agent may name something it is about to finish writing, and
176/// `resolves_within` checks where a path lands rather than whether it exists.
177fn resolve_artifacts(
178 args: &serde_json::Value,
179 workdir: Option<&std::path::Path>,
180) -> Result<Vec<String>, String> {
181 let listed: Vec<&str> = args
182 .get("artifacts")
183 .and_then(|v| v.as_array())
184 .map(|a| {
185 a.iter()
186 .filter_map(|v| v.as_str())
187 .filter(|p| !p.trim().is_empty())
188 .collect()
189 })
190 .unwrap_or_default();
191 if listed.is_empty() {
192 return Ok(Vec::new());
193 }
194 // No workdir means nothing to resolve against, so nothing can be verified.
195 // Unreachable for a real run (every one carries its metadata); loud rather
196 // than silent if it ever is.
197 let Some(workdir) = workdir else {
198 return Err(
199 "[error] cannot record artifacts: this run has no working directory to resolve \
200 them against"
201 .to_string(),
202 );
203 };
204 let (kept, rejected): (Vec<&str>, Vec<&str>) = listed
205 .into_iter()
206 .partition(|p| leviath_core::resolves_within(&workdir.join(p), workdir));
207 match rejected.is_empty() {
208 true => Ok(kept.into_iter().map(str::to_string).collect()),
209 false => Err(format!(
210 "[error] these artifact paths do not resolve inside the working directory: {}",
211 rejected.join(", ")
212 )),
213 }
214}
215
216/// Mirror the submission into the pinned `final_output` region, replacing
217/// whatever was there.
218///
219/// Best-effort: a world whose layout somehow lacks the region still records the
220/// output on the component, which is what every consumer actually reads. The
221/// region exists so the answer stays in the agent's own context (a later stage
222/// can revise it) and so it appears in `context.json`.
223fn mirror_into_region(window: &mut ContextWindow, content: &str) {
224 // Read the budget and clear in one borrow. Asking for the region twice
225 // leaves a second "what if it is missing" branch that the first check has
226 // already ruled out, so nothing can ever take it.
227 let budget = {
228 let Some(region) = window.get_region_mut(FINAL_OUTPUT_REGION) else {
229 return;
230 };
231 region.clear();
232 region.max_tokens
233 };
234 let mirrored = fit_to_region(content, budget);
235 let tokens = leviath_core::estimate_tokens(&mirrored);
236 window.current_tokens = window.calculate_tokens();
237 // Through the window method rather than the region directly, so a custom
238 // region's `on_write` hook fires - the same reason `context_write` does it.
239 let _ = window.add_to_region(FINAL_OUTPUT_REGION, mirrored, tokens);
240}
241
242/// Trim `content` to fit `budget` tokens, marking it when cut.
243///
244/// An over-budget entry is *rejected* by `add_entry`, not truncated, so mirroring
245/// a long answer without this would leave the region empty - the one outcome
246/// worse than a preview.
247fn fit_to_region(content: &str, budget: usize) -> String {
248 let allowed = budget.saturating_mul(4);
249 if content.len() <= allowed {
250 return content.to_string();
251 }
252 let Some(room) = allowed.checked_sub(MIRROR_TRUNCATION_MARKER.len()) else {
253 return String::new();
254 };
255 format!(
256 "{}{MIRROR_TRUNCATION_MARKER}",
257 leviath_core::truncate_at_boundary(content, room)
258 )
259}
260
261#[cfg(test)]
262mod tests;