1use super::*;
4
5#[derive(Resource)]
7pub struct ToolResults(pub UnboundedReceiver<ToolOutcome>);
8
9pub(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 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 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 add_kind(
128 window,
129 "conversation",
130 result_kind(),
131 result_text,
132 result_tokens,
133 );
134 } else {
135 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
171pub(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
188pub(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
238pub(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
272pub(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#[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; };
359 crate::tick_scope::enter(outcome.entity);
360 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 record_modifications(
372 &infer.tool_calls,
373 &merged,
374 &stage_modifying_tools(blueprint, cursor),
375 progress,
376 flags,
377 );
378 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 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 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 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}