Skip to content

Commit 61e87f8

Browse files
committed
feat(subagents): implement coordinated subagents with unified tool dispatch and enhanced role management
- Introduced subagent events for tracking lifecycle: `SubagentStarted`, `SubagentStep`, `SubagentToolCall`, and `SubagentFinished`. - Enhanced the timeline to display subagent activities and their statuses. - Implemented environment detection and session caching for workspace management. - Added Git tools scoped to the workspace, including commands for status, diff, log, and more. - Established a shell execution framework with command allowlisting and child process management. - Refactored tool dispatching to support subagent operations across multiple providers (Anthropic, OpenRouter, OpenAI). - Updated documentation to reflect new features and usage guidelines for subagents.
1 parent 0c6589a commit 61e87f8

26 files changed

Lines changed: 2474 additions & 206 deletions

.agents/plans/coordinated-subagents.md

Lines changed: 60 additions & 35 deletions
Large diffs are not rendered by default.

src-tauri/src/agent/anthropic.rs

Lines changed: 18 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,16 @@
1111
1212
use super::system_prompt::system_prompt;
1313
use crate::agent::protocol::{AgentEvent, AgentImageContextItem};
14-
use crate::agent::state::{AgentEngineState, ClientToolResult};
15-
use crate::agent::tools::{self, ToolSite, WorkspaceRootGuard};
14+
use crate::agent::state::AgentEngineState;
15+
use crate::agent::tool_dispatch::{dispatch_tool, DispatchContext};
16+
use crate::agent::tools::{self, WorkspaceRootGuard};
1617
use crate::agent_settings::{AgentProviderSettings, ThinkingLevel};
1718
use crate::tasks;
1819
use serde::Deserialize;
1920
use serde_json::{json, Value};
2021
use std::sync::Arc;
2122
use std::time::Duration;
2223
use tokio::io::AsyncBufReadExt;
23-
use tokio::sync::oneshot;
2424

2525
const ANTHROPIC_URL: &str = "https://api.anthropic.com/v1/messages";
2626
const ANTHROPIC_VERSION: &str = "2023-06-01";
@@ -131,6 +131,11 @@ pub async fn run_chat_turn(
131131
let thinking_cfg = thinking_budget(settings.thinking_level)
132132
.map(|budget| json!({ "type": "enabled", "budget_tokens": budget }));
133133

134+
let dispatch_ctx = DispatchContext {
135+
settings: settings.clone(),
136+
api_key: api_key.clone(),
137+
};
138+
134139
let client = match reqwest::Client::builder()
135140
.timeout(Duration::from_secs(180))
136141
.build()
@@ -234,7 +239,15 @@ pub async fn run_chat_turn(
234239
let args_val: Value =
235240
serde_json::from_str(&call.arguments).unwrap_or_else(|_| json!({}));
236241
let outcome =
237-
dispatch_tool(&state, &call.id, &call.name, &args_val, root_guard.as_ref()).await;
242+
dispatch_tool(
243+
&state,
244+
&call.id,
245+
&call.name,
246+
&args_val,
247+
root_guard.as_ref(),
248+
Some(&dispatch_ctx),
249+
)
250+
.await;
238251

239252
if outcome.ok && call.name.starts_with("task_") {
240253
maybe_emit_task_snapshot(&state, root_guard.as_ref());
@@ -287,6 +300,7 @@ fn maybe_emit_task_snapshot(state: &Arc<AgentEngineState>, root: Option<&Workspa
287300
}
288301

289302
fn emit_aborted(state: &Arc<AgentEngineState>) {
303+
crate::agent::shell_exec::kill_all_children();
290304
state.push(AgentEvent::AssistantDelta {
291305
delta: "\n_Abgebrochen._\n".into(),
292306
});
@@ -648,65 +662,3 @@ mod tests {
648662
assert!(encoded.contains("marked read"));
649663
}
650664
}
651-
652-
async fn dispatch_tool(
653-
state: &Arc<AgentEngineState>,
654-
call_id: &str,
655-
name: &str,
656-
args: &Value,
657-
root: Option<&WorkspaceRootGuard>,
658-
) -> tools::ToolOutcome {
659-
state.push(AgentEvent::ToolCall {
660-
tool: name.to_owned(),
661-
call_id: Some(call_id.to_owned()),
662-
args: Some(args.clone()),
663-
});
664-
665-
let Some(def) = tools::find(name) else {
666-
return tools::ToolOutcome {
667-
ok: false,
668-
content: format!("unknown tool: {name}"),
669-
};
670-
};
671-
672-
match def.site {
673-
ToolSite::Server => tools::execute_server_tool(name, args, root),
674-
ToolSite::Client => wait_for_client_tool(state, call_id, name).await,
675-
}
676-
}
677-
678-
async fn wait_for_client_tool(
679-
state: &Arc<AgentEngineState>,
680-
call_id: &str,
681-
name: &str,
682-
) -> tools::ToolOutcome {
683-
let (tx, rx) = oneshot::channel::<ClientToolResult>();
684-
state.register_client_tool(call_id.to_owned(), tx);
685-
686-
match rx.await {
687-
Ok(res) => {
688-
let mut body = res.message.unwrap_or_default();
689-
if let Some(data) = res.data {
690-
if !body.is_empty() {
691-
body.push('\n');
692-
}
693-
body.push_str(&data.to_string());
694-
}
695-
if body.is_empty() {
696-
body = if res.ok {
697-
format!("{name} ok")
698-
} else {
699-
format!("{name} failed")
700-
};
701-
}
702-
tools::ToolOutcome {
703-
ok: res.ok,
704-
content: body,
705-
}
706-
}
707-
Err(_) => tools::ToolOutcome {
708-
ok: false,
709-
content: format!("{name}: tool result channel closed"),
710-
},
711-
}
712-
}

src-tauri/src/agent/environment.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! Workspace environment detection and session cache.
2+
3+
use crate::agent::tools::{ToolOutcome, WorkspaceRootGuard};
4+
use serde_json::{json, Value};
5+
use std::process::Command;
6+
use std::sync::{Mutex, OnceLock};
7+
8+
#[derive(Clone, Debug)]
9+
struct CacheEntry {
10+
workspace: String,
11+
snapshot: Value,
12+
}
13+
14+
static ENV_CACHE: OnceLock<Mutex<Option<CacheEntry>>> = OnceLock::new();
15+
16+
fn cache() -> &'static Mutex<Option<CacheEntry>> {
17+
ENV_CACHE.get_or_init(|| Mutex::new(None))
18+
}
19+
20+
pub fn invalidate_cache() {
21+
if let Ok(mut g) = cache().lock() {
22+
*g = None;
23+
}
24+
}
25+
26+
pub fn workspace_has_cache(workspace: &str) -> bool {
27+
cache()
28+
.lock()
29+
.ok()
30+
.and_then(|g| g.as_ref().map(|e| e.workspace == workspace))
31+
.unwrap_or(false)
32+
}
33+
34+
pub fn require_environment(workspace: &str) -> Result<(), ToolOutcome> {
35+
if workspace_has_cache(workspace) {
36+
Ok(())
37+
} else {
38+
Err(ToolOutcome {
39+
ok: false,
40+
content: "call environment_detect first for this workspace".into(),
41+
})
42+
}
43+
}
44+
45+
fn detect_os() -> &'static str {
46+
match std::env::consts::OS {
47+
"linux" => "linux",
48+
"macos" => "macos",
49+
"windows" => "windows",
50+
other => other,
51+
}
52+
}
53+
54+
fn default_shell() -> &'static str {
55+
if cfg!(windows) {
56+
"powershell"
57+
} else {
58+
"bash"
59+
}
60+
}
61+
62+
fn available_shells() -> Vec<&'static str> {
63+
if cfg!(windows) {
64+
vec!["powershell", "pwsh", "cmd"]
65+
} else {
66+
vec!["bash", "sh"]
67+
}
68+
}
69+
70+
fn git_available() -> bool {
71+
Command::new("git")
72+
.arg("--version")
73+
.output()
74+
.map(|o| o.status.success())
75+
.unwrap_or(false)
76+
}
77+
78+
pub fn detect(root: &WorkspaceRootGuard) -> Value {
79+
json!({
80+
"os": detect_os(),
81+
"arch": std::env::consts::ARCH,
82+
"defaultShell": default_shell(),
83+
"availableShells": available_shells(),
84+
"pathSeparator": std::path::MAIN_SEPARATOR.to_string(),
85+
"lineEnding": if cfg!(windows) { "\r\n" } else { "\n" },
86+
"gitAvailable": git_available(),
87+
"workspaceRoot": root.as_str(),
88+
})
89+
}
90+
91+
pub fn tool_environment_detect(root: Option<&WorkspaceRootGuard>) -> ToolOutcome {
92+
let Some(root) = root else {
93+
return ToolOutcome {
94+
ok: false,
95+
content: "no workspace configured".into(),
96+
};
97+
};
98+
let snapshot = detect(root);
99+
let ws = root.as_str();
100+
if let Ok(mut g) = cache().lock() {
101+
*g = Some(CacheEntry {
102+
workspace: ws.clone(),
103+
snapshot: snapshot.clone(),
104+
});
105+
}
106+
match serde_json::to_string(&snapshot) {
107+
Ok(body) => ToolOutcome {
108+
ok: true,
109+
content: body,
110+
},
111+
Err(e) => ToolOutcome {
112+
ok: false,
113+
content: format!("serialize environment: {e}"),
114+
},
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
#[test]
123+
fn cache_invalidated_on_clear() {
124+
let dir = std::env::temp_dir().join(format!("blx-env-test-{}", std::process::id()));
125+
std::fs::create_dir_all(&dir).unwrap();
126+
let root = WorkspaceRootGuard::parse(dir.to_str().unwrap())
127+
.unwrap()
128+
.unwrap();
129+
let _ = tool_environment_detect(Some(&root));
130+
assert!(workspace_has_cache(&root.as_str()));
131+
invalidate_cache();
132+
assert!(!workspace_has_cache(&root.as_str()));
133+
let _ = std::fs::remove_dir_all(&dir);
134+
}
135+
}

0 commit comments

Comments
 (0)