unify tool tracking: ActiveToolCall with JoinHandle

One data structure for all in-flight tool calls — metadata for
TUI display + JoinHandle for result collection and cancellation.
Agent spawns tool calls via tokio::spawn, pushes to shared
Arc<Mutex<Vec<ActiveToolCall>>>. TUI reads metadata, can abort().
No separate inflight/background collections.

Non-background: awaited after stream ends.
Background: persists, drained at next turn start.

Co-Developed-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-04-03 23:42:27 -04:00
parent 17a018ff12
commit a78f310e4d
5 changed files with 106 additions and 82 deletions

View file

@ -85,10 +85,6 @@ pub struct Agent {
pub scoring_in_flight: bool, pub scoring_in_flight: bool,
/// Shared active tools — Agent writes, TUI reads. /// Shared active tools — Agent writes, TUI reads.
pub active_tools: crate::user::ui_channel::SharedActiveTools, pub active_tools: crate::user::ui_channel::SharedActiveTools,
/// Background tool calls that outlive the current turn.
background_tasks: futures::stream::FuturesUnordered<
std::pin::Pin<Box<dyn std::future::Future<Output = (ToolCall, tools::ToolOutput)> + Send>>
>,
} }
fn render_journal(entries: &[journal::JournalEntry]) -> String { fn render_journal(entries: &[journal::JournalEntry]) -> String {
@ -142,7 +138,6 @@ impl Agent {
memory_scores: None, memory_scores: None,
scoring_in_flight: false, scoring_in_flight: false,
active_tools, active_tools,
background_tasks: futures::stream::FuturesUnordered::new(),
}; };
agent.load_startup_journal(); agent.load_startup_journal();
@ -245,19 +240,31 @@ impl Agent {
} }
// Inject completed background task results // Inject completed background task results
// Collect completed background tool calls
{ {
use futures::{StreamExt, FutureExt};
let mut bg_ds = DispatchState { let mut bg_ds = DispatchState {
yield_requested: false, had_tool_calls: false, yield_requested: false, had_tool_calls: false,
tool_errors: 0, model_switch: None, dmn_pause: false, tool_errors: 0, model_switch: None, dmn_pause: false,
}; };
while let Some(Some((call, output))) = let finished: Vec<_> = {
std::pin::Pin::new(&mut self.background_tasks).next().now_or_never() let mut tools = self.active_tools.lock().unwrap();
{ let mut done = Vec::new();
// Show result in TUI and inject into conversation let mut i = 0;
while i < tools.len() {
if tools[i].handle.is_finished() {
done.push(tools.remove(i));
} else {
i += 1;
}
}
done
};
for mut entry in finished {
if let Ok((call, output)) = entry.handle.await {
self.apply_tool_result(&call, output, ui_tx, &mut bg_ds); self.apply_tool_result(&call, output, ui_tx, &mut bg_ds);
} }
} }
}
// User input — clean, just what was typed // User input — clean, just what was typed
self.push_message(Message::user(user_input)); self.push_message(Message::user(user_input));
@ -296,10 +303,6 @@ impl Agent {
let mut tool_call_buf = String::new(); let mut tool_call_buf = String::new();
let mut stream_error = None; let mut stream_error = None;
let mut first_content = true; let mut first_content = true;
// Tool calls fired during streaming (XML path)
let mut inflight: futures::stream::FuturesOrdered<
std::pin::Pin<Box<dyn std::future::Future<Output = (ToolCall, tools::ToolOutput)> + Send>>
> = futures::stream::FuturesOrdered::new();
// Buffer for content not yet sent to UI — holds a tail // Buffer for content not yet sent to UI — holds a tail
// that might be a partial <tool_call> tag. // that might be a partial <tool_call> tag.
let mut display_buf = String::new(); let mut display_buf = String::new();
@ -326,27 +329,26 @@ impl Agent {
name: call.function.name.clone(), name: call.function.name.clone(),
args_summary: args_summary.clone(), args_summary: args_summary.clone(),
}); });
self.active_tools.write().unwrap().push(
crate::user::ui_channel::ActiveTool {
id: call.id.clone(),
name: call.function.name.clone(),
detail: args_summary,
started: std::time::Instant::now(),
}
);
let tracker = self.process_tracker.clone();
let is_background = args.get("run_in_background") let is_background = args.get("run_in_background")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
let future = Box::pin(async move { let call_id = call.id.clone();
let call_name = call.function.name.clone();
let tracker = self.process_tracker.clone();
let handle = tokio::spawn(async move {
let output = tools::dispatch(&call.function.name, &args, &tracker).await; let output = tools::dispatch(&call.function.name, &args, &tracker).await;
(call, output) (call, output)
}); });
if is_background { self.active_tools.lock().unwrap().push(
self.background_tasks.push(future); crate::user::ui_channel::ActiveToolCall {
} else { id: call_id,
inflight.push_back(future); name: call_name,
detail: args_summary,
started: std::time::Instant::now(),
background: is_background,
handle,
} }
);
} }
// Reset for potential next tool call // Reset for potential next tool call
let remaining = tool_call_buf[end + "</tool_call>".len()..].to_string(); let remaining = tool_call_buf[end + "</tool_call>".len()..].to_string();
@ -491,16 +493,32 @@ impl Agent {
empty_retries = 0; empty_retries = 0;
} }
// Collect tool calls that were fired during streaming // Collect non-background tool calls fired during streaming
if !inflight.is_empty() { {
use futures::StreamExt; let pending: Vec<_> = {
let mut tools = self.active_tools.lock().unwrap();
let mut non_bg = Vec::new();
let mut i = 0;
while i < tools.len() {
if !tools[i].background {
non_bg.push(tools.remove(i));
} else {
i += 1;
}
}
non_bg
};
if !pending.is_empty() {
self.push_message(msg.clone()); self.push_message(msg.clone());
while let Some((call, output)) = inflight.next().await { for mut entry in pending {
if let Ok((call, output)) = entry.handle.await {
self.apply_tool_result(&call, output, ui_tx, &mut ds); self.apply_tool_result(&call, output, ui_tx, &mut ds);
} }
}
self.publish_context_state(); self.publish_context_state();
continue; continue;
} }
}
// Tool calls (structured API path — not fired during stream). // Tool calls (structured API path — not fired during stream).
if let Some(ref tool_calls) = msg.tool_calls { if let Some(ref tool_calls) = msg.tool_calls {
@ -553,45 +571,46 @@ impl Agent {
name: call.function.name.clone(), name: call.function.name.clone(),
args_summary: args_summary.clone(), args_summary: args_summary.clone(),
}); });
self.active_tools.write().unwrap().push( // Handle working_stack — needs &mut self, can't be spawned
crate::user::ui_channel::ActiveTool {
id: call.id.clone(),
name: call.function.name.clone(),
detail: args_summary,
started: std::time::Instant::now(),
}
);
// Handle working_stack tool — needs &mut self for context state
if call.function.name == "working_stack" { if call.function.name == "working_stack" {
let result = tools::working_stack::handle(&args, &mut self.context.working_stack); let result = tools::working_stack::handle(&args, &mut self.context.working_stack);
let output = tools::ToolOutput { let output = tools::ToolOutput::text(result.clone());
text: result.clone(), self.apply_tool_result(call, output, ui_tx, ds);
is_yield: false,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
};
let _ = ui_tx.send(UiMessage::ToolResult {
name: call.function.name.clone(),
result: output.text.clone(),
});
self.active_tools.write().unwrap().retain(|t| t.id != call.id);
self.push_message(Message::tool_result(&call.id, &output.text));
ds.had_tool_calls = true;
// Re-render the context message so the model sees the updated stack
if !result.starts_with("Error:") { if !result.starts_with("Error:") {
self.refresh_context_state(); self.refresh_context_state();
} }
return; return;
} }
// Dispatch through unified path // Spawn, push to active_tools, await handle
let output = let call_id = call.id.clone();
tools::dispatch(&call.function.name, &args, &self.process_tracker).await; let call_name = call.function.name.clone();
let call = call.clone();
let tracker = self.process_tracker.clone();
let handle = tokio::spawn(async move {
let output = tools::dispatch(&call.function.name, &args, &tracker).await;
(call, output)
});
self.active_tools.lock().unwrap().push(
tools::ActiveToolCall {
id: call_id,
name: call_name,
detail: args_summary,
started: std::time::Instant::now(),
background: false,
handle,
}
);
self.apply_tool_result(call, output, ui_tx, ds); // Wait for this non-background tool to complete
let entry = {
let mut tools = self.active_tools.lock().unwrap();
// It's the last one we pushed
tools.pop().unwrap()
};
if let Ok((call, output)) = entry.handle.await {
self.apply_tool_result(&call, output, ui_tx, ds);
}
} }
/// Apply a completed tool result to conversation state. /// Apply a completed tool result to conversation state.
@ -624,7 +643,7 @@ impl Agent {
name: call.function.name.clone(), name: call.function.name.clone(),
result: output.text.clone(), result: output.text.clone(),
}); });
self.active_tools.write().unwrap().retain(|t| t.id != call.id); self.active_tools.lock().unwrap().retain(|t| t.id != call.id);
// Tag memory_render results for context deduplication // Tag memory_render results for context deduplication
if call.function.name == "memory_render" && !output.text.starts_with("Error:") { if call.function.name == "memory_render" && !output.text.starts_with("Error:") {

View file

@ -118,6 +118,17 @@ pub struct ProcessInfo {
pub started: Instant, pub started: Instant,
} }
/// A tool call in flight — metadata for TUI + JoinHandle for
/// result collection and cancellation.
pub struct ActiveToolCall {
pub id: String,
pub name: String,
pub detail: String,
pub started: Instant,
pub background: bool,
pub handle: tokio::task::JoinHandle<(ToolCall, ToolOutput)>,
}
/// Shared tracker for running child processes. Allows the TUI to /// Shared tracker for running child processes. Allows the TUI to
/// display what's running and kill processes by PID. /// display what's running and kill processes by PID.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]

View file

@ -168,7 +168,7 @@ impl App {
))); )));
lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort))); lines.push(Line::raw(format!(" Reasoning: {}", self.reasoning_effort)));
lines.push(Line::raw(format!(" Running processes: {}", self.running_processes))); lines.push(Line::raw(format!(" Running processes: {}", self.running_processes)));
lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.read().unwrap().len()))); lines.push(Line::raw(format!(" Active tools: {}", self.active_tools.lock().unwrap().len())));
let block = Block::default() let block = Block::default()
.title_top(Line::from(SCREEN_LEGEND).left_aligned()) .title_top(Line::from(SCREEN_LEGEND).left_aligned())

View file

@ -17,7 +17,7 @@ impl App {
/// Draw the main (F1) screen — four-pane layout with status bar. /// Draw the main (F1) screen — four-pane layout with status bar.
pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) { pub(crate) fn draw_main(&mut self, frame: &mut Frame, size: Rect) {
// Main layout: content area + active tools overlay + status bar // Main layout: content area + active tools overlay + status bar
let active_tools = self.active_tools.read().unwrap(); let active_tools = self.active_tools.lock().unwrap();
let tool_lines = active_tools.len() as u16; let tool_lines = active_tools.len() as u16;
let main_chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)

View file

@ -22,20 +22,14 @@ pub fn shared_context_state() -> SharedContextState {
Arc::new(RwLock::new(Vec::new())) Arc::new(RwLock::new(Vec::new()))
} }
/// Active tool info for TUI display. // ActiveToolCall lives in agent::tools — re-export for TUI access
#[derive(Debug, Clone)] pub use crate::agent::tools::ActiveToolCall;
pub struct ActiveTool {
pub id: String,
pub name: String,
pub detail: String,
pub started: std::time::Instant,
}
/// Shared active tools — agent writes, TUI reads. /// Shared active tool calls — agent spawns, TUI reads metadata / aborts.
pub type SharedActiveTools = Arc<RwLock<Vec<ActiveTool>>>; pub type SharedActiveTools = Arc<std::sync::Mutex<Vec<ActiveToolCall>>>;
pub fn shared_active_tools() -> SharedActiveTools { pub fn shared_active_tools() -> SharedActiveTools {
Arc::new(RwLock::new(Vec::new())) Arc::new(std::sync::Mutex::new(Vec::new()))
} }
/// Which pane streaming text should go to. /// Which pane streaming text should go to.