Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::sync::Arc;
use crate::agent::prompt::{SYSTEM_PROMPT, TODO_TOOLS_PROMPT};
use crate::agent::tools;
use crate::agent::tools::ToolCache;
use crate::agent::tools::background::BackgroundStore;
use crate::agent::tools::question::QuestionSender;
use crate::cli::Cli;
use crate::config::Config;
Expand Down Expand Up @@ -218,8 +219,16 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
}

if let Some(pm) = parent_model {
let task_tool = Box::new(tools::TaskTool::new(permission.clone(), ask_tx.clone(), pm));
builder = builder.tools(vec![task_tool]);
let bg_store = BackgroundStore::new();
let task_tool = Box::new(tools::TaskTool::new(
permission.clone(),
ask_tx.clone(),
pm,
bg_store.clone(),
));
let status_tool =
Box::new(tools::TaskStatusTool::new(bg_store)) as Box<dyn rig::tool::ToolDyn>;
builder = builder.tools(vec![task_tool, status_tool]);
}

#[cfg(feature = "mcp")]
Expand Down
69 changes: 69 additions & 0 deletions src/agent/tools/background.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// Thread-safe store for background subagent tasks.
/// Completed/failed tasks are removed on read to avoid unbounded growth.
#[derive(Debug, Clone, Default)]
pub struct BackgroundStore(Arc<Mutex<HashMap<String, BackgroundTask>>>);

const MAX_TASK_OUTPUT_CHARS: usize = 3000;

#[derive(Debug, Clone)]
pub enum TaskState {
Running,
Completed(String),
Failed(String),
}

#[derive(Debug, Clone)]
pub struct BackgroundTask {
pub state: TaskState,
}

impl BackgroundStore {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(HashMap::new())))
}

pub fn insert(&self, id: String) {
self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(
id,
BackgroundTask {
state: TaskState::Running,
},
);
}

/// Get current task state. Completed/failed tasks are removed on read
/// so the store doesn't grow unbounded.
pub fn get(&self, id: &str) -> Option<BackgroundTask> {
let mut map = self.0.lock().unwrap_or_else(|e| e.into_inner());
let task = map.get(id).cloned();
// Remove completed/failed tasks to prevent unbounded growth
if let Some(ref t) = task {
if !matches!(t.state, TaskState::Running) {
map.remove(id);
}
}
task
}

/// Update task state (called by the spawned subagent).
/// Truncates output to MAX_TASK_OUTPUT_CHARS to avoid context bloat.
pub fn update(&self, id: &str, state: TaskState) {
if let Some(task) = self.0.lock().unwrap_or_else(|e| e.into_inner()).get_mut(id) {
let truncated = match state {
TaskState::Completed(text) => {
let t: String = text.chars().take(MAX_TASK_OUTPUT_CHARS).collect();
TaskState::Completed(t)
}
TaskState::Failed(err) => {
let e: String = err.chars().take(MAX_TASK_OUTPUT_CHARS).collect();
TaskState::Failed(e)
}
s => s,
};
task.state = truncated;
}
}
}
3 changes: 3 additions & 0 deletions src/agent/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod background;
mod bash;
pub(crate) mod cache;
pub(crate) mod edit;
Expand All @@ -11,6 +12,7 @@ mod read;
pub mod semantic;
mod skill;
mod task;
mod task_status;
mod todo;
mod webfetch;
mod websearch;
Expand All @@ -27,6 +29,7 @@ pub use question::QuestionTool;
pub use read::ReadTool;
pub use skill::SkillTool;
pub use task::TaskTool;
pub use task_status::TaskStatusTool;
pub use todo::WriteTodoList;
pub use webfetch::WebFetchTool;
pub use websearch::WebSearchTool;
Expand Down
95 changes: 74 additions & 21 deletions src/agent/tools/task.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use uuid::Uuid;

use crate::agent::tools::background::{BackgroundStore, TaskState};
use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm};
use crate::provider::AnyModel;

pub struct TaskTool {
pub permission: Option<PermCheck>,
pub ask_tx: Option<AskSender>,
model: AnyModel,
bg_store: BackgroundStore,
}

impl TaskTool {
pub fn new(permission: Option<PermCheck>, ask_tx: Option<AskSender>, model: AnyModel) -> Self {
pub fn new(
permission: Option<PermCheck>,
ask_tx: Option<AskSender>,
model: AnyModel,
bg_store: BackgroundStore,
) -> Self {
Self {
permission,
ask_tx,
model,
bg_store,
}
}
}

#[derive(Deserialize)]
pub struct Args {
prompt: String,
pub prompt: String,
#[serde(default)]
pub background: Option<bool>,
}

impl Tool for TaskTool {
Expand All @@ -34,34 +45,76 @@ impl Tool for TaskTool {
type Output = String;

async fn definition(&self, _prompt: String) -> ToolDefinition {
let description = "Spawn a subagent to handle a specific subtask. The subagent runs as a one-shot query (no tools) and returns its result inline. Use for research, analysis, or planning subtasks that don't require file access. Set background=true to run asynchronously — use task_status to poll for the result."
.to_string();

let properties = serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Task description for the subagent"
},
"background": {
"type": "boolean",
"description": "Run asynchronously (default: false). When true, returns a task_id immediately for use with task_status."
}
},
"required": ["prompt"]
});

ToolDefinition {
name: "task".to_string(),
description: "Spawn a subagent to handle a specific subtask. The subagent runs as a one-shot query (no tools) and returns its result inline. Use for research, analysis, or planning subtasks that don't require file access.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Task description for the subagent"
}
},
"required": ["prompt"]
}),
description,
parameters: properties,
}
}

async fn call(&self, args: Args) -> Result<String, ToolError> {
check_perm(&self.permission, &self.ask_tx, "task", &args.prompt).await?;

let result = self
.model
.btw_query(format!(
"You are a subagent working on a specific subtask. Complete it thoroughly.\n\nTask: {}",
args.prompt
let background = args.background.unwrap_or(false);

if background {
let task_id = Uuid::new_v4().to_string();
self.bg_store.insert(task_id.clone());

let model = self.model.clone();
let prompt = args.prompt;
let store = self.bg_store.clone();
let tid = task_id.clone();

tokio::spawn(async move {
let result = model
.btw_query(format!(
"You are a subagent working on a specific subtask. Complete it thoroughly.\n\nTask: {}",
prompt
))
.await;
store.update(
&tid,
match result {
Ok(text) => TaskState::Completed(text),
Err(e) => TaskState::Failed(e.to_string()),
},
);
});

Ok(format!(
"background task started\n\ntask_id: {}\nstate: running\n\nUse task_status to check progress.",
task_id
))
.await
.map_err(|e| ToolError::Msg(format!("Subagent error: {}", e)))?;
} else {
let result = self
.model
.btw_query(format!(
"You are a subagent working on a specific subtask. Complete it thoroughly.\n\nTask: {}",
args.prompt
))
.await
.map_err(|e| ToolError::Msg(format!("Subagent error: {}", e)))?;

Ok(result)
Ok(result)
}
}
}
Loading
Loading