Skip to main content

leviath_runtime/pipeline/
tool_results.rs

1//! Applying completed tool batches: results, file tracking, modification accounting.
2
3use super::*;
4
5/// The receiving end of the tool-outcomes channel, as a world resource.
6#[derive(Resource)]
7pub struct ToolResults(pub UnboundedReceiver<ToolOutcome>);
8
9/// Apply a completed tool batch to an agent's context window: add the assistant
10/// turn (with its tool calls) then each tool result, honoring the stage's
11/// tool-result routing (target region, `persist=false`→scratch, per-result
12/// truncation) and, when a per-tool sensitivity is provided, tagging the result
13/// with that taint level. Tool results MUST be added (Anthropic requires a
14/// `tool_result` for every `tool_use`), so an over-budget region truncates or
15/// falls back to a placeholder rather than dropping. Ported from the core of
16/// `AgentEngine::loop_apply_tool_results` (repetition + message draining are
17/// separate systems).
18pub(crate) fn apply_tool_results(
19    window: &mut ContextWindow,
20    response_content: &str,
21    tool_calls: &[crate::components::ToolCall],
22    tool_results: &[(String, String)],
23    routing: Option<&leviath_core::blueprint::ToolResultRouting>,
24    sensitivities: Option<&std::collections::HashMap<String, leviath_core::TaintLevel>>,
25) {
26    let response_tokens = leviath_core::estimate_tokens(response_content);
27    let serialized: Vec<leviath_core::SerializedToolCall> = tool_calls
28        .iter()
29        .map(|tc| leviath_core::SerializedToolCall {
30            id: tc.tool_id.clone(),
31            name: tc.name.clone(),
32            arguments: tc.arguments.clone(),
33            thought_signature: tc.thought_signature.clone(),
34        })
35        .collect();
36    let _ = window.add_typed_entry(
37        "conversation",
38        leviath_core::EntryKind::AssistantTurn {
39            tool_calls: serialized,
40        },
41        response_content.to_string(),
42        response_tokens,
43    );
44
45    for (tool_call_id, result) in tool_results {
46        let mut result_text = result.clone();
47        let tool_name = tool_calls
48            .iter()
49            .find(|tc| tc.tool_id == *tool_call_id)
50            .map(|tc| tc.name.clone())
51            .unwrap_or_default();
52
53        if let Some(routing) = routing
54            && let Some(max_tokens) = routing.max_result_tokens
55        {
56            let max_chars = max_tokens * 4;
57            if result_text.len() > max_chars {
58                result_text = truncate_on_char_boundary(&result_text, max_chars);
59                result_text.push_str("\n[...truncated]");
60            }
61        }
62        let result_tokens = leviath_core::estimate_tokens(&result_text);
63
64        let base_region = match routing {
65            Some(r) => {
66                // Match overrides by CANONICAL tool name so a `bash = "..."` override
67                // routes the `shell` tool (bash is an alias - the model calls the
68                // canonical `shell`, so a literal-key lookup would silently miss).
69                let canon = leviath_tools::canonical_tool_name(&tool_name);
70                r.tool_overrides
71                    .iter()
72                    .find(|(k, _)| leviath_tools::canonical_tool_name(k) == canon)
73                    .map(|(_, v)| v.as_str())
74                    .unwrap_or(r.default_region.as_str())
75            }
76            None => "conversation",
77        };
78        let target_region = match routing {
79            Some(r) if !r.persist && window.get_region("scratch").is_some() => "scratch",
80            _ => base_region,
81        };
82
83        let taint_level = sensitivities.map(|s| {
84            s.get(&tool_name)
85                .copied()
86                .unwrap_or(leviath_core::TaintLevel::Public)
87        });
88        // Add `content` (with entry `kind`) to `region`, honoring taint and falling
89        // back to a truncated (then omitted) entry if the region is full.
90        let add_kind = |window: &mut ContextWindow,
91                        region: &str,
92                        kind: leviath_core::EntryKind,
93                        content: String,
94                        tokens: usize| {
95            let put = |w: &mut ContextWindow, c: String, t: usize| match taint_level {
96                Some(level) => w.add_typed_tainted_to_region(region, kind.clone(), c, t, level),
97                None => w.add_typed_entry(region, kind.clone(), c, t),
98            };
99            if put(window, content.clone(), tokens).is_err() {
100                let available = window
101                    .get_region(region)
102                    .map(|r| r.max_tokens.saturating_sub(r.current_tokens))
103                    .unwrap_or(0);
104                let truncated = if available > 100 {
105                    let char_budget = (available - 10) * 4;
106                    let prefix = truncate_on_char_boundary(&content, char_budget);
107                    let omitted = content.len().saturating_sub(prefix.len());
108                    format!("{}... [truncated, {} chars omitted]", prefix, omitted)
109                } else {
110                    "[tool result truncated - context window full]".to_string()
111                };
112                let trunc_tokens = leviath_core::estimate_tokens(&truncated);
113                if put(window, truncated, trunc_tokens).is_err() {
114                    let _ = put(window, "[result omitted]".to_string(), 5);
115                }
116            }
117        };
118        let result_kind = || leviath_core::EntryKind::ToolResult {
119            tool_call_id: tool_call_id.clone(),
120            tool_name: tool_name.clone(),
121            is_error: false,
122        };
123
124        if target_region == "conversation" {
125            // Not routed (or routed back to the message stream): the tool_result
126            // lives in `conversation`, paired with its tool_use.
127            add_kind(
128                window,
129                "conversation",
130                result_kind(),
131                result_text,
132                result_tokens,
133            );
134        } else {
135            // Routed to a knowledge region. Anthropic requires each tool_result to sit
136            // in the message immediately after its tool_use, so the PAIR must stay in
137            // `conversation`: we keep a short pointer tool_result there (valid + cheap)
138            // and store the FULL output in the target region as TEXT. Text renders as a
139            // stable knowledge block for any region kind - a ToolResult block in a
140            // second sliding_window would desync from its tool_use (→ API 400), and
141            // dropping the conversation tool_result would orphan the tool_use (the
142            // assembler strips it, so the model can't see its own call landed → loops).
143            let preview: String = result_text.chars().take(160).collect();
144            let ellipsis = if result_text.len() > preview.len() {
145                "…"
146            } else {
147                ""
148            };
149            let pointer = format!(
150                "[output stored in context region '{target_region}' ({result_tokens} tokens) - read that region for the full result. Preview: {preview}{ellipsis}]"
151            );
152            let pointer_tokens = leviath_core::estimate_tokens(&pointer);
153            add_kind(
154                window,
155                "conversation",
156                result_kind(),
157                pointer,
158                pointer_tokens,
159            );
160            add_kind(
161                window,
162                target_region,
163                leviath_core::EntryKind::Text,
164                result_text,
165                result_tokens,
166            );
167        }
168    }
169}
170
171/// Truncate a file body to `max_tokens` (≈4 chars/token) with a marker, or return
172/// it unchanged when no cap is set or it already fits.
173pub(crate) fn truncate_file(content: String, max_tokens: Option<usize>) -> String {
174    match max_tokens {
175        Some(max) => {
176            let approx_chars = max * 4;
177            if content.len() > approx_chars {
178                let head: String = content.chars().take(approx_chars).collect();
179                format!("{head}\n\n[... truncated at {max} tokens ...]")
180            } else {
181                content
182            }
183        }
184        None => content,
185    }
186}
187
188/// File tracking: for each `read_file`/`write_file` result (per the stage's
189/// [`FileTrackingConfig`](leviath_core::blueprint::FileTrackingConfig)), upsert
190/// the file body into the configured HashMap region (keyed by path, so re-reads
191/// de-dup) and replace the inline tool result with a short reference - keeping
192/// large file bodies out of the rolling conversation. No-op unless the region
193/// exists and is a HashMap. `read_file`'s body is the result; `write_file`'s is
194/// its `content` argument (no re-read needed in the ECS).
195pub(crate) fn apply_file_tracking(
196    window: &mut ContextWindow,
197    ft: &leviath_core::blueprint::FileTrackingConfig,
198    tool_calls: &[crate::components::ToolCall],
199    merged: &mut [(String, String)],
200) {
201    let is_hashmap = window
202        .get_region(&ft.region)
203        .is_some_and(|r| matches!(r.kind, leviath_core::RegionKind::HashMap { .. }));
204    if !is_hashmap {
205        return;
206    }
207    for (call, (_id, result)) in tool_calls.iter().zip(merged.iter_mut()) {
208        if call_had_no_effect(result) {
209            continue;
210        }
211        let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) else {
212            continue;
213        };
214        let (body, verb) = match call.name.as_str() {
215            "read_file" if ft.track_reads => (result.clone(), "stored"),
216            "write_file" if ft.track_writes => {
217                match call.arguments.get("content").and_then(|v| v.as_str()) {
218                    Some(c) => (c.to_string(), "written"),
219                    None => continue,
220                }
221            }
222            _ => continue,
223        };
224        let body = truncate_file(body, ft.max_file_tokens);
225        let tokens = leviath_core::estimate_tokens(&body);
226        window
227            .get_region_mut(&ft.region)
228            .expect("region presence checked above")
229            .upsert_by_key(path, body, tokens)
230            .ok();
231        *result = format!(
232            "File {verb} in [{}] → ### [{}] ({} tokens). Reference it there; do not re-read this path.",
233            ft.region, path, tokens
234        );
235    }
236}
237
238/// The tool names that count as a file modification for the agent's current
239/// stage: the built-in [`MODIFYING_TOOLS`](leviath_core::blueprint::MODIFYING_TOOLS)
240/// plus any extra names declared by that stage's outgoing transition gates (for
241/// agents whose writes go through MCP or script tools). All canonical, so a
242/// `bash`-style alias in a gate's `tools` list still matches its real tool.
243pub(crate) fn stage_modifying_tools(
244    blueprint: Option<&AgentBlueprint>,
245    cursor: Option<&StageCursor>,
246) -> Vec<String> {
247    let mut names: Vec<String> = leviath_core::blueprint::MODIFYING_TOOLS
248        .iter()
249        .map(|t| (*t).to_string())
250        .collect();
251    let (Some(bp), Some(cursor)) = (blueprint, cursor) else {
252        return names;
253    };
254    let Some(stage) = bp.0.stages.get(cursor.index) else {
255        return names;
256    };
257    let Some(transitions) = &stage.transitions else {
258        return names;
259    };
260    for edge in transitions.values() {
261        let Some(gate) = &edge.gate else { continue };
262        for tool in &gate.tools {
263            let canonical = leviath_tools::canonical_tool_name(tool).to_string();
264            if !names.contains(&canonical) {
265                names.push(canonical);
266            }
267        }
268    }
269    names
270}
271
272/// Tally this batch's file-modifying tool calls onto the stage's progress and the
273/// run's outcome flags. A result prefixed `[denied]` (permission layer) counts as
274/// *blocked* rather than successful - the agent tried and was refused, which a
275/// gate treats differently from never having tried. `[error]` results (the write
276/// itself failed) count as neither.
277pub(crate) fn record_modifications(
278    tool_calls: &[crate::components::ToolCall],
279    merged: &[(String, String)],
280    modifying: &[String],
281    progress: Option<bevy_ecs::prelude::Mut<'_, StageProgress>>,
282    flags: Option<bevy_ecs::prelude::Mut<'_, crate::persistence::RunOutcomeFlags>>,
283) {
284    let mut progress = progress;
285    let mut flags = flags;
286    for (call, (_id, result)) in tool_calls.iter().zip(merged.iter()) {
287        let canonical = leviath_tools::canonical_tool_name(&call.name);
288        if !modifying.iter().any(|t| t == canonical) {
289            continue;
290        }
291        if result.starts_with("[denied]") {
292            if let Some(progress) = progress.as_mut() {
293                progress.blocked_modification_calls += 1;
294            }
295            continue;
296        }
297        if call_had_no_effect(result) {
298            continue;
299        }
300        if let Some(progress) = progress.as_mut() {
301            progress.modifying_tool_calls += 1;
302        }
303        if let Some(flags) = flags.as_mut() {
304            let path = call
305                .arguments
306                .get("path")
307                .and_then(|v| v.as_str())
308                .unwrap_or("<unknown>");
309            flags.0.record_modification(path);
310        }
311    }
312}
313
314/// Tool-collect system: drain finished tool batches and apply them. Results are
315/// written into the agent's context window (routing/truncation/taint honored)
316/// and the agent loops back to `ReadyToInfer`. Outcomes for agents no longer
317/// `AwaitingTools` (cancelled/despawned) are dropped.
318#[allow(clippy::type_complexity)]
319pub fn collect_tools(
320    mut results: ResMut<ToolResults>,
321    mut agents: Query<
322        (
323            &mut ContextWindow,
324            &crate::components::InferenceResult,
325            Option<&crate::components::ToolResultRoutingComponent>,
326            Option<&ToolSensitivities>,
327            Option<&ContextToolResults>,
328            Option<&StageCursor>,
329            Option<&mut StageIoBuffer>,
330            Option<&AgentBlueprint>,
331            Option<&mut crate::repetition::RepetitionDetector>,
332            Option<&mut StageProgress>,
333            Option<&mut crate::persistence::RunOutcomeFlags>,
334            Option<&mut crate::telemetry::StageActivity>,
335        ),
336        With<AwaitingTools>,
337    >,
338    mut commands: Commands,
339) {
340    crate::tick_scope::clear();
341    while let Ok(outcome) = results.0.try_recv() {
342        let Ok((
343            mut window,
344            infer,
345            routing,
346            sensitivities,
347            context_results,
348            cursor,
349            buffer,
350            blueprint,
351            repetition,
352            progress,
353            flags,
354            activity,
355        )) = agents.get_mut(outcome.entity)
356        else {
357            continue; // stale: agent cancelled/despawned since dispatch
358        };
359        crate::tick_scope::enter(outcome.entity);
360        // Merge the inline context-tool results (if any) with the lane results,
361        // ordered by the original tool calls.
362        let mut parts = outcome.results;
363        if let Some(ctx) = context_results {
364            parts.extend(ctx.0.iter().cloned());
365        }
366        let mut merged = merge_in_call_order(&infer.tool_calls, &parts);
367        // Modification accounting (issue #107): count the file-writing calls this
368        // stage actually landed, so a `require_modifications` transition gate can
369        // tell "analyzed the code and wrote nothing" from "made the change".
370        // Done before file tracking, which rewrites successful results.
371        record_modifications(
372            &infer.tool_calls,
373            &merged,
374            &stage_modifying_tools(blueprint, cursor),
375            progress,
376            flags,
377        );
378        // Record each call for the telemetry observer before file tracking
379        // rewrites successful results; success is the `[error] ` result-text
380        // convention every executor follows.
381        if let Some(mut activity) = activity {
382            let batch_latency_ms = u64::try_from(outcome.elapsed.as_millis()).unwrap_or(u64::MAX);
383            for (call, (_id, result)) in infer.tool_calls.iter().zip(merged.iter()) {
384                activity.0.push(crate::telemetry::ActivityRecord::ToolCall {
385                    tool_name: call.name.clone(),
386                    batch_latency_ms,
387                    success: !result.starts_with("[error]"),
388                });
389            }
390        }
391        // File tracking: sync read/write results into the configured HashMap
392        // region and replace the inline result with a reference (de-dup context).
393        if let Some(ft) = blueprint.and_then(|bp| bp.0.file_tracking.as_ref()) {
394            apply_file_tracking(&mut window, ft, &infer.tool_calls, &mut merged);
395        }
396        // Buffer one readable `[tool] name: result` line per call for the stage's
397        // logs (merged is in call order, so it zips with the calls by index).
398        if let Some(mut buffer) = buffer {
399            let idx = cursor.map_or(0, |c| c.index);
400            for (call, (_id, result)) in infer.tool_calls.iter().zip(merged.iter()) {
401                buffer.logs.push((
402                    idx,
403                    format!("[tool] {}: {}", call.name, one_line(result, 200)),
404                ));
405            }
406        }
407        apply_tool_results(
408            &mut window,
409            &infer.response,
410            &infer.tool_calls,
411            &merged,
412            routing.map(|c| &c.routing),
413            sensitivities.map(|s| &s.0),
414        );
415        // Repetition detection: record each call and inject a `[System]` nudge
416        // when the agent is looping (same tool+args, or a long read-only streak).
417        if let Some(mut detector) = repetition {
418            let nudges: Vec<String> = infer
419                .tool_calls
420                .iter()
421                .filter_map(|call| detector.record_call(&call.name, &call.arguments.to_string()))
422                .collect();
423            for nudge in nudges {
424                let content = format!("[System] {nudge}");
425                let tokens = leviath_core::estimate_tokens(&content);
426                let _ = window.add_to_region("conversation", content, tokens);
427            }
428        }
429        commands
430            .entity(outcome.entity)
431            .remove::<AwaitingTools>()
432            .remove::<ContextToolResults>()
433            .remove::<InFlightWork>()
434            .insert(ReadyToInfer);
435    }
436}