Add tool use to the orchestrator (#4)

Add tool use without sandboxing.

Currently available tools are list dir, read file, write file and exec bash.

Reviewed-on: #4
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2026-03-02 03:00:13 +00:00 committed by Drew
parent 6b85ff3cb8
commit 797d7564b7
20 changed files with 1822 additions and 129 deletions

View file

@ -11,11 +11,14 @@ use crate::core::types::{Role, UIEvent};
/// This is non-blocking: it processes all currently-available events and returns
/// immediately when the channel is empty.
///
/// | Event | Effect |
/// |--------------------|------------------------------------------------------------|
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push new |
/// | `TurnComplete` | No structural change; logged at debug level |
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
/// | Event | Effect |
/// |------------------------|------------------------------------------------------------|
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push |
/// | `ToolApprovalRequest` | Set `pending_approval` in state |
/// | `ToolExecuting` | Display tool execution info |
/// | `ToolResult` | Display tool result |
/// | `TurnComplete` | No structural change; logged at debug level |
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState) {
while let Ok(event) = event_rx.try_recv() {
match event {
@ -26,6 +29,36 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
state.messages.push((Role::Assistant, chunk));
}
}
UIEvent::ToolApprovalRequest {
tool_use_id,
tool_name,
input_summary,
} => {
state.pending_approval = Some(PendingApproval {
tool_use_id,
tool_name,
input_summary,
});
}
UIEvent::ToolExecuting {
tool_name,
input_summary,
} => {
state
.messages
.push((Role::Assistant, format!("[{tool_name}] {input_summary}")));
}
UIEvent::ToolResult {
tool_name,
output_summary,
is_error,
} => {
let prefix = if is_error { "error" } else { "result" };
state.messages.push((
Role::Assistant,
format!("[{tool_name} {prefix}] {output_summary}"),
));
}
UIEvent::TurnComplete => {
debug!("turn complete");
}
@ -38,6 +71,14 @@ pub(super) fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mu
}
}
/// A pending tool approval request waiting for user input.
#[derive(Debug, Clone)]
pub struct PendingApproval {
pub tool_use_id: String,
pub tool_name: String,
pub input_summary: String,
}
#[cfg(test)]
mod tests {
use super::*;
@ -69,4 +110,39 @@ mod tests {
assert_eq!(state.messages[1].0, Role::Assistant);
assert_eq!(state.messages[1].1, "hello");
}
#[tokio::test]
async fn drain_tool_approval_sets_pending() {
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let mut state = AppState::new();
tx.send(UIEvent::ToolApprovalRequest {
tool_use_id: "t1".to_string(),
tool_name: "write_file".to_string(),
input_summary: "path: foo.txt".to_string(),
})
.await
.unwrap();
drop(tx);
drain_ui_events(&mut rx, &mut state);
assert!(state.pending_approval.is_some());
let approval = state.pending_approval.unwrap();
assert_eq!(approval.tool_name, "write_file");
}
#[tokio::test]
async fn drain_tool_result_adds_message() {
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
let mut state = AppState::new();
tx.send(UIEvent::ToolResult {
tool_name: "read_file".to_string(),
output_summary: "file contents...".to_string(),
is_error: false,
})
.await
.unwrap();
drop(tx);
drain_ui_events(&mut rx, &mut state);
assert_eq!(state.messages.len(), 1);
assert!(state.messages[0].1.contains("read_file result"));
}
}