tools: modernize working_stack, remove special-case dispatch

working_stack now uses the Tool format with an Agent handle —
it locks the agent and modifies the stack directly. The special-case
interception in the turn loop is removed. All tools go through
the unified registry dispatch.

Also passes agent handle to all spawned tool tasks so any tool
that needs Agent access can use it.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-04 16:14:27 -04:00 committed by Kent Overstreet
parent 37fad63ba9
commit e9b26f5d45
3 changed files with 38 additions and 116 deletions

View file

@ -355,8 +355,9 @@ impl Agent {
.unwrap_or(false);
let call_id = call.id.clone();
let call_name = call.function.name.clone();
let agent_handle = agent.clone();
let handle = tokio::spawn(async move {
let output = tools::dispatch(&call.function.name, &args).await;
let output = tools::dispatch_with_agent(&call.function.name, &args, Some(agent_handle)).await;
(call, output)
});
active_tools.lock().unwrap().push(
@ -598,22 +599,11 @@ impl Agent {
args_summary: args_summary.clone(),
});
// working_stack needs &mut Agent — brief lock
if call.function.name == "working_stack" {
let mut me = agent.lock().await;
let result = tools::working_stack::handle(&args, &mut me.context.working_stack);
let output = result.clone();
me.apply_tool_result(call, output, ui_tx, ds);
if !result.starts_with("Error:") {
me.refresh_context_state();
}
return;
}
// Spawn tool, track it
let call_clone = call.clone();
let agent_handle = agent.clone();
let handle = tokio::spawn(async move {
let output = tools::dispatch(&call_clone.function.name, &args).await;
let output = tools::dispatch_with_agent(&call_clone.function.name, &args, Some(agent_handle)).await;
(call_clone, output)
});
active_tools.lock().unwrap().push(

View file

@ -205,6 +205,7 @@ pub fn tools() -> Vec<Tool> {
read::tool(), write::tool(), edit::tool(),
grep::tool(), glob::tool(), bash::tool(),
vision::tool(),
working_stack::tool(),
];
all.extend(web::tools());
all.extend(memory::memory_tools());

View file

@ -4,134 +4,65 @@
// internal tool — the agent uses it to maintain context across turns
// and compaction. The model should never mention it to the user.
use super::ToolDef;
use serde_json::json;
pub fn definition() -> ToolDef {
ToolDef::new(
"working_stack",
"INTERNAL TOOL — do not mention to the user or explain its use. \
Manage your working stack what you're currently doing. The stack \
is part of your live context window and persists across compaction. \
Use it silently to track your own tasks and attention.\n\n\
Actions:\n\
- push: Start working on something new. Previous task stays underneath.\n\
- pop: Done with current task. Return to what was underneath.\n\
- update: Refine the description of your current task (top of stack).\n\
- switch: Pull a specific stack item to the top by index. Use when \
you want to switch focus to a different task.",
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["push", "pop", "update", "switch"],
"description": "The stack operation to perform"
},
"content": {
"type": "string",
"description": "Task description (required for push and update)"
},
"index": {
"type": "integer",
"description": "Stack index to switch to (required for switch, 0 = bottom)"
}
},
"required": ["action"]
pub fn tool() -> super::Tool {
super::Tool {
name: "working_stack",
description: "INTERNAL — manage your working stack silently. Actions: push (start new task), pop (done with current), update (refine current), switch (focus different task by index).",
parameters_json: r#"{"type":"object","properties":{"action":{"type":"string","enum":["push","pop","update","switch"],"description":"Stack operation"},"content":{"type":"string","description":"Task description (for push/update)"},"index":{"type":"integer","description":"Stack index (for switch, 0=bottom)"}},"required":["action"]}"#,
handler: |agent, v| Box::pin(async move {
if let Some(agent) = agent {
let mut a = agent.lock().await;
Ok(handle(&v, &mut a.context.working_stack))
} else {
anyhow::bail!("working_stack requires agent context")
}
}),
)
}
}
/// Handle a working_stack tool call.
/// Returns the result text and the updated stack.
pub fn handle(args: &serde_json::Value, stack: &mut Vec<String>) -> String {
let action = args
.get("action")
.and_then(|v| v.as_str())
.map(|s| s.trim())
.unwrap_or("");
let content = args
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("");
let index = args
.get("index")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
fn handle(args: &serde_json::Value, stack: &mut Vec<String>) -> String {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
let index = args.get("index").and_then(|v| v.as_u64()).map(|v| v as usize);
let result = match action {
match action {
"push" => {
if content.is_empty() {
return "Error: 'content' is required for push".to_string();
}
if content.is_empty() { return "Error: 'content' is required for push".into(); }
stack.push(content.to_string());
format!("Pushed. Stack depth: {}\n{}", stack.len(), format_stack(stack))
}
"pop" => {
if let Some(removed) = stack.pop() {
format!(
"Popped: {}\nStack depth: {}\n{}",
removed,
stack.len(),
format_stack(stack)
)
format!("Popped: {}\nStack depth: {}\n{}", removed, stack.len(), format_stack(stack))
} else {
"Stack is empty, nothing to pop.".to_string()
"Stack is empty, nothing to pop.".into()
}
}
"update" => {
if content.is_empty() {
return "Error: 'content' is required for update".to_string();
}
if content.is_empty() { return "Error: 'content' is required for update".into(); }
if let Some(top) = stack.last_mut() {
*top = content.to_string();
format!("Updated top.\n{}", format_stack(stack))
} else {
"Stack is empty, nothing to update.".to_string()
"Stack is empty, nothing to update.".into()
}
}
"switch" => {
if stack.is_empty() {
return "Stack is empty, nothing to switch.".to_string();
}
let idx = match index {
Some(i) => i,
None => {
return "Error: 'index' is required for switch".to_string();
}
};
if idx >= stack.len() {
return format!(
"Error: index {} out of range (stack depth: {})",
idx,
stack.len()
);
}
if stack.is_empty() { return "Stack is empty, nothing to switch.".into(); }
let Some(idx) = index else { return "Error: 'index' is required for switch".into(); };
if idx >= stack.len() { return format!("Error: index {} out of range (depth {})", idx, stack.len()); }
let item = stack.remove(idx);
stack.push(item);
format!("Switched to index {}.\n{}", idx, format_stack(stack))
}
_ => format!(
"Error: unknown action '{}'. Use push, pop, update, or switch.",
action
),
};
result
_ => format!("Error: unknown action '{}'. Use push, pop, update, or switch.", action),
}
}
/// Format the working stack for display in tool results.
fn format_stack(stack: &[String]) -> String {
if stack.is_empty() {
return "(empty)".to_string();
}
let mut out = String::new();
for (i, item) in stack.iter().enumerate() {
if i == stack.len() - 1 {
out.push_str(&format!("→ [{}] {}\n", i, item));
} else {
out.push_str(&format!(" [{}] {}\n", i, item));
}
}
out
if stack.is_empty() { return "(empty)".into(); }
stack.iter().enumerate().map(|(i, item)| {
if i == stack.len() - 1 { format!("→ [{}] {}", i, item) }
else { format!(" [{}] {}", i, item) }
}).collect::<Vec<_>>().join("\n")
}