Agents get delegate_task, task_status, task_cancel and orchestrator_capabilities. A delegated task runs in a subagent thread in the parent's folder, on any agent and model the app has. The subagents panel shows it working and opens its thread; the sidebar leaves it out, and its header leads back to the parent. Its answer goes to a waiting call, or to the parent as a message that wakes its agent. Nothing to install. Claude reaches the tools through an MCP server on loopback, with a token per thread. Pi gets them from an extension the app writes and loads with --extension, which also switches off other subagent tools before every run. jcode gets them as session tools over its API, with swarm and schedule off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 14 changed files with 1243 additions and 18 deletions.
| @@ -114,6 +114,19 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 114 | 114 | (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep |
| 115 | 115 | working in sessions of their own after the turn ends: the app checks on them every few |
| 116 | 116 | seconds through jcode's session list, and reads what each last said. |
| 117 | + - App-owned subagents, as T3 Code has them: every agent gets `delegate_task`, | |
| 118 | + `task_status`, `task_cancel` and `orchestrator_capabilities`, with instructions for | |
| 119 | + them. A delegated task runs in a subagent thread in the parent's folder, on the | |
| 120 | + parent's agent and model or any other agent and model the app has (Claude, Pi or | |
| 121 | + jcode). The subagents panel shows it working and opens its thread. The thread is | |
| 122 | + left out of the sidebar, and its header leads back to the parent. When the task | |
| 123 | + finishes, its answer goes to the call waiting on it (`mode: "wait"`) or reaches the | |
| 124 | + parent as a message, which wakes its agent. Nothing to install: Claude reaches the | |
| 125 | + tools through an MCP server the app runs on loopback, with a token per thread. | |
| 126 | + Pi reaches them through an extension the app writes and loads with `--extension`; | |
| 127 | + before every run, that extension also switches off Pi's other subagent tools. | |
| 128 | + jcode gets them as session tools over its API, with its `swarm` and `schedule` | |
| 129 | + tools turned off. Subagents can't delegate tasks of their own. | |
| 117 | 130 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 118 | 131 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 119 | 132 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -16,6 +16,7 @@ use anyhow::{Context as _, Result, anyhow}; | ||
| 16 | 16 | use serde_json::{Value, json}; |
| 17 | 17 | |
| 18 | 18 | use crate::browser; |
| 19 | + use crate::orchestrator; | |
| 19 | 20 | use crate::models::Provider; |
| 20 | 21 | use crate::settings::{ProviderInstance, expand_home}; |
| 21 | 22 | |
| @@ -152,6 +153,9 @@ pub struct LaunchConfig { | ||
| 152 | 153 | pub resume: Option<String>, |
| 153 | 154 | /// The thread's agent-browser session, shared with the browser panel. |
| 154 | 155 | pub browser_session: Option<String>, |
| 156 | + /// The thread, when its agent may delegate tasks to the app's subagents | |
| 157 | + /// (subagents' own threads don't). | |
| 158 | + pub delegating_thread: Option<String>, | |
| 155 | 159 | } |
| 156 | 160 | |
| 157 | 161 | /// Everything the background reader threads report back to the UI. |
| @@ -176,6 +180,17 @@ impl AgentProcess { | ||
| 176 | 180 | // environment; the thread tells it about the browser in a message. |
| 177 | 181 | Provider::Jcode => jcode_command(config)?, |
| 178 | 182 | }; |
| 183 | + // The app's subagent tools; jcode's are registered over its API. | |
| 184 | + match config.instance.kind { | |
| 185 | + Provider::Claude => { | |
| 186 | + if let Some(mcp) = config.delegating_thread.as_deref().and_then(orchestrator::claude_config) { | |
| 187 | + let allowed = format!("mcp__{}", orchestrator::SERVER); | |
| 188 | + command.args(["--mcp-config", &mcp, "--allowedTools", &allowed]); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + Provider::Pi => orchestrator::prepare_pi(&mut command, config.delegating_thread.as_deref()), | |
| 192 | + Provider::Jcode => {} | |
| 193 | + } | |
| 179 | 194 | // Claude and Pi take extra system prompt with this flag. |
| 180 | 195 | if config.instance.kind != Provider::Jcode |
| 181 | 196 | && let Some(session) = &config.browser_session |
| @@ -9,6 +9,7 @@ mod clock; | ||
| 9 | 9 | mod git; |
| 10 | 10 | mod http; |
| 11 | 11 | mod models; |
| 12 | + mod orchestrator; | |
| 12 | 13 | mod remote; |
| 13 | 14 | mod session; |
| 14 | 15 | mod settings; |
| @@ -0,0 +1,511 @@ | ||
| 1 | + //! App-owned subagents, the way T3 Code does them: the agent calls | |
| 2 | + //! `delegate_task`, and the app starts a child thread for the task, shows it | |
| 3 | + //! working and hands its result back. Any agent can run any task. | |
| 4 | + //! | |
| 5 | + //! The tools reach each agent its own way. Claude and Pi call an MCP server | |
| 6 | + //! the app runs on loopback: Claude through `--mcp-config`, Pi through an | |
| 7 | + //! extension the app writes and loads with `--extension`. That extension | |
| 8 | + //! also switches off Pi's other subagent tools before each run. jcode runs | |
| 9 | + //! them as session tools registered over its API, with its swarm switched | |
| 10 | + //! off. Nothing needs installing or setting up. | |
| 11 | + //! | |
| 12 | + //! Each thread's agent gets a token of its own, which tells the server who's | |
| 13 | + //! calling. Calls go to the workspace on the UI thread, which answers them. | |
| 14 | + | |
| 15 | + use std::{ | |
| 16 | + collections::HashMap, | |
| 17 | + io::{BufRead, BufReader, Read, Write}, | |
| 18 | + net::{TcpListener, TcpStream}, | |
| 19 | + path::PathBuf, | |
| 20 | + sync::{Mutex, OnceLock}, | |
| 21 | + thread, | |
| 22 | + time::Duration, | |
| 23 | + }; | |
| 24 | + | |
| 25 | + use serde_json::{Value, json}; | |
| 26 | + | |
| 27 | + /// The MCP server's name, as Claude's tool names carry it. | |
| 28 | + pub const SERVER: &str = "vectrum-code"; | |
| 29 | + pub const DELEGATE: &str = "delegate_task"; | |
| 30 | + pub const TASK_STATUS: &str = "task_status"; | |
| 31 | + pub const TASK_CANCEL: &str = "task_cancel"; | |
| 32 | + pub const CAPABILITIES: &str = "orchestrator_capabilities"; | |
| 33 | + | |
| 34 | + /// The environment that tells the Pi extension where the server is. | |
| 35 | + const URL_ENV: &str = "VECTRUM_CODE_MCP_URL"; | |
| 36 | + const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN"; | |
| 37 | + | |
| 38 | + /// What the tools are for, for agents whose instructions the app adds to: | |
| 39 | + /// the MCP server's instructions (Claude), the system prompt (Pi) and a | |
| 40 | + /// reminder with the first message (jcode). | |
| 41 | + pub const INSTRUCTIONS: &str = "\ | |
| 42 | + ## Vectrum Code subagents | |
| 43 | + | |
| 44 | + Vectrum Code runs this conversation and offers app-owned subagents through its `delegate_task`, `task_status`, `task_cancel` and `orchestrator_capabilities` tools. Tool names may carry a prefix, such as `mcp__vectrum-code__delegate_task`; they work the same. | |
| 45 | + | |
| 46 | + - `delegate_task` starts a child agent of this thread, working in the same folder. It sees only the task you give it, none of this conversation, so make the task self-contained: the goal, the files and facts that matter, and what to report back. The user watches it work in Vectrum Code's subagents panel. | |
| 47 | + - The child runs this thread's agent and model unless `target` names others. `orchestrator_capabilities` lists every agent (provider instance) and model the app has, so a task can go to another agent entirely. | |
| 48 | + - Use `delegate_task` for subagent work; other subagent tools may be switched off. For independent pieces of work, start several tasks so they run in parallel. | |
| 49 | + - `mode` defaults to `async`: the call returns a `taskId` at once, and the child's result arrives in this thread as a message once it finishes. Carry on with other work, or end your turn instead of polling. Use `task_status` only when you need a result mid-turn. `mode: \"wait\"` blocks until the child is done or `timeoutMs` runs out; the child keeps working after a timeout. | |
| 50 | + - `task_cancel` stops a task. For a further round of work, call `delegate_task` again with the full context: the brief, what came back and what's still open."; | |
| 51 | + | |
| 52 | + /// What the tools take, as MCP describes tools. | |
| 53 | + pub fn tool_definitions() -> Value { | |
| 54 | + json!([ | |
| 55 | + { | |
| 56 | + "name": DELEGATE, | |
| 57 | + "description": "Delegate one task to a subagent: a child agent of this thread that Vectrum Code runs in a thread of its own, in this thread's working folder. It gets only the task text, not this conversation, so make it self-contained. The provider instance and model default to this thread's; orchestrator_capabilities lists the others. mode 'async' (the default) returns a taskId at once, and the result arrives in this thread as a message when the child finishes; end your turn rather than polling. mode 'wait' blocks until the child finishes or timeoutMs passes (the child carries on after a timeout; its result then arrives as a message).", | |
| 58 | + "inputSchema": { | |
| 59 | + "type": "object", | |
| 60 | + "properties": { | |
| 61 | + "task": {"type": "string", "description": "The complete, self-contained task for the child agent."}, | |
| 62 | + "title": {"type": "string", "description": "A short title for the subagent, shown to the user."}, | |
| 63 | + "target": { | |
| 64 | + "type": "object", | |
| 65 | + "description": "Which agent and model run the task. Omitted fields inherit this thread's.", | |
| 66 | + "properties": { | |
| 67 | + "providerInstanceId": {"type": "string", "description": "A provider instance id from orchestrator_capabilities."}, | |
| 68 | + "model": {"type": "string", "description": "A model id that instance offers."} | |
| 69 | + } | |
| 70 | + }, | |
| 71 | + "mode": {"type": "string", "enum": ["async", "wait"], "description": "async (default) returns at once; wait blocks for the result."}, | |
| 72 | + "timeoutMs": {"type": "number", "description": "How long mode 'wait' waits. The child isn't stopped when it runs out."} | |
| 73 | + }, | |
| 74 | + "required": ["task"] | |
| 75 | + }, | |
| 76 | + "annotations": {"title": "Delegate a task", "destructiveHint": true, "openWorldHint": true} | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + "name": TASK_STATUS, | |
| 80 | + "description": "Where a delegated task stands: running, completed, failed or cancelled, and once it's done, its result.", | |
| 81 | + "inputSchema": { | |
| 82 | + "type": "object", | |
| 83 | + "properties": {"taskId": {"type": "string"}}, | |
| 84 | + "required": ["taskId"] | |
| 85 | + }, | |
| 86 | + "annotations": {"title": "Get a task's status", "readOnlyHint": true} | |
| 87 | + }, | |
| 88 | + { | |
| 89 | + "name": TASK_CANCEL, | |
| 90 | + "description": "Stop a delegated task that's still running. Its result so far stays readable with task_status.", | |
| 91 | + "inputSchema": { | |
| 92 | + "type": "object", | |
| 93 | + "properties": {"taskId": {"type": "string"}, "reason": {"type": "string"}}, | |
| 94 | + "required": ["taskId"] | |
| 95 | + }, | |
| 96 | + "annotations": {"title": "Cancel a task", "destructiveHint": true} | |
| 97 | + }, | |
| 98 | + { | |
| 99 | + "name": CAPABILITIES, | |
| 100 | + "description": "The agents (provider instances) and models Vectrum Code can run a delegated task with, and which ones this thread uses.", | |
| 101 | + "inputSchema": {"type": "object", "properties": {}}, | |
| 102 | + "annotations": {"title": "List agents and models", "readOnlyHint": true} | |
| 103 | + } | |
| 104 | + ]) | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// Whether a tool call, by name, is one of the app's: plain for Pi and | |
| 108 | + /// jcode, prefixed for Claude (`mcp__vectrum-code__delegate_task`). | |
| 109 | + pub fn own_tool(name: &str) -> Option<&'static str> { | |
| 110 | + let bare = name.strip_prefix("mcp__vectrum-code__").or_else(|| name.strip_prefix("mcp__vectrum_code__")).unwrap_or(name); | |
| 111 | + [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES].into_iter().find(|tool| *tool == bare) | |
| 112 | + } | |
| 113 | + | |
| 114 | + /// A call from an agent, for the workspace to answer. | |
| 115 | + pub struct ToolRequest { | |
| 116 | + /// The calling thread's id. | |
| 117 | + pub thread: String, | |
| 118 | + pub tool: String, | |
| 119 | + pub args: Value, | |
| 120 | + /// Takes the answer: the result, or what went wrong. | |
| 121 | + pub reply: smol::channel::Sender<Result<Value, String>>, | |
| 122 | + } | |
| 123 | + | |
| 124 | + struct Server { | |
| 125 | + url: String, | |
| 126 | + /// Thread id by token, and token by thread id. | |
| 127 | + tokens: Mutex<(HashMap<String, String>, HashMap<String, String>)>, | |
| 128 | + } | |
| 129 | + | |
| 130 | + static LISTENER: OnceLock<Option<Server>> = OnceLock::new(); | |
| 131 | + static REQUESTS: OnceLock<(smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>)> = OnceLock::new(); | |
| 132 | + | |
| 133 | + fn channel() -> &'static (smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>) { | |
| 134 | + REQUESTS.get_or_init(smol::channel::unbounded) | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// The calls agents make, for the workspace to answer. | |
| 138 | + pub fn requests() -> smol::channel::Receiver<ToolRequest> { | |
| 139 | + channel().1.clone() | |
| 140 | + } | |
| 141 | + | |
| 142 | + /// Starts the server the first time it's needed. `None` if it can't listen. | |
| 143 | + fn server() -> Option<&'static Server> { | |
| 144 | + LISTENER | |
| 145 | + .get_or_init(|| { | |
| 146 | + let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?; | |
| 147 | + let port = listener.local_addr().ok()?.port(); | |
| 148 | + thread::Builder::new() | |
| 149 | + .name("mcp-listener".into()) | |
| 150 | + .spawn(move || { | |
| 151 | + for stream in listener.incoming() { | |
| 152 | + let Ok(stream) = stream else { continue }; | |
| 153 | + let _ = thread::Builder::new().name("mcp-connection".into()).spawn(move || serve(stream)); | |
| 154 | + } | |
| 155 | + }) | |
| 156 | + .ok()?; | |
| 157 | + Some(Server { url: format!("http://127.0.0.1:{port}/mcp"), tokens: Mutex::default() }) | |
| 158 | + }) | |
| 159 | + .as_ref() | |
| 160 | + } | |
| 161 | + | |
| 162 | + /// The server's address and the token a thread's agent calls it with. | |
| 163 | + pub fn credentials(thread: &str) -> Option<(String, String)> { | |
| 164 | + let server = server()?; | |
| 165 | + let mut tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 166 | + let token = match tokens.1.get(thread) { | |
| 167 | + Some(token) => token.clone(), | |
| 168 | + None => { | |
| 169 | + let token = crate::remote::new_key(); | |
| 170 | + tokens.0.insert(token.clone(), thread.to_string()); | |
| 171 | + tokens.1.insert(thread.to_string(), token.clone()); | |
| 172 | + token | |
| 173 | + } | |
| 174 | + }; | |
| 175 | + Some((server.url.clone(), token)) | |
| 176 | + } | |
| 177 | + | |
| 178 | + fn thread_for(token: &str) -> Option<String> { | |
| 179 | + let server = server()?; | |
| 180 | + let tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 181 | + tokens.0.get(token).cloned() | |
| 182 | + } | |
| 183 | + | |
| 184 | + /// Claude's `--mcp-config` for a thread. | |
| 185 | + pub fn claude_config(thread: &str) -> Option<String> { | |
| 186 | + let (url, token) = credentials(thread)?; | |
| 187 | + let config = json!({ | |
| 188 | + "mcpServers": { | |
| 189 | + SERVER: { | |
| 190 | + "type": "http", | |
| 191 | + "url": url, | |
| 192 | + "headers": {"Authorization": format!("Bearer {token}")}, | |
| 193 | + // Long enough for a `mode: "wait"` call. | |
| 194 | + "timeout": 3_900_000, | |
| 195 | + } | |
| 196 | + } | |
| 197 | + }); | |
| 198 | + Some(config.to_string()) | |
| 199 | + } | |
| 200 | + | |
| 201 | + /// Answers one connection's requests, one at a time, for as long as it stays | |
| 202 | + /// open. Plain HTTP/1.1 with JSON replies: all of MCP's streamable HTTP | |
| 203 | + /// transport a tools-only server needs. | |
| 204 | + fn serve(stream: TcpStream) { | |
| 205 | + let _ = stream.set_nodelay(true); | |
| 206 | + let Ok(mut writer) = stream.try_clone() else { return }; | |
| 207 | + let mut reader = BufReader::new(stream); | |
| 208 | + loop { | |
| 209 | + let mut request_line = String::new(); | |
| 210 | + match reader.read_line(&mut request_line) { | |
| 211 | + Ok(0) | Err(_) => return, | |
| 212 | + Ok(_) => {} | |
| 213 | + } | |
| 214 | + let method = request_line.split_whitespace().next().unwrap_or_default().to_string(); | |
| 215 | + let mut length = 0usize; | |
| 216 | + let mut token = None; | |
| 217 | + loop { | |
| 218 | + let mut line = String::new(); | |
| 219 | + if reader.read_line(&mut line).unwrap_or(0) == 0 { | |
| 220 | + return; | |
| 221 | + } | |
| 222 | + let line = line.trim_end(); | |
| 223 | + if line.is_empty() { | |
| 224 | + break; | |
| 225 | + } | |
| 226 | + let Some((name, value)) = line.split_once(':') else { continue }; | |
| 227 | + let value = value.trim(); | |
| 228 | + match name.trim().to_ascii_lowercase().as_str() { | |
| 229 | + "content-length" => length = value.parse().unwrap_or(0), | |
| 230 | + "authorization" => token = value.strip_prefix("Bearer ").map(str::to_string), | |
| 231 | + _ => {} | |
| 232 | + } | |
| 233 | + } | |
| 234 | + let mut body = vec![0; length.min(16 << 20)]; | |
| 235 | + if reader.read_exact(&mut body).is_err() { | |
| 236 | + return; | |
| 237 | + } | |
| 238 | + let (status, reply) = if method != "POST" { | |
| 239 | + // No server-sent event stream: everything's answered in the reply. | |
| 240 | + ("405 Method Not Allowed", None) | |
| 241 | + } else { | |
| 242 | + match token.as_deref().and_then(thread_for) { | |
| 243 | + None => ("401 Unauthorized", Some(json!({"error": "unknown token"}))), | |
| 244 | + Some(thread) => match serde_json::from_slice::<Value>(&body) { | |
| 245 | + Ok(message) => match handle(&thread, &message) { | |
| 246 | + Some(reply) => ("200 OK", Some(reply)), | |
| 247 | + // A notification. | |
| 248 | + None => ("202 Accepted", None), | |
| 249 | + }, | |
| 250 | + Err(err) => ("400 Bad Request", Some(json!({"error": err.to_string()}))), | |
| 251 | + }, | |
| 252 | + } | |
| 253 | + }; | |
| 254 | + let body = reply.map(|reply| reply.to_string()).unwrap_or_default(); | |
| 255 | + let head = format!( | |
| 256 | + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", | |
| 257 | + body.len() | |
| 258 | + ); | |
| 259 | + if writer.write_all(head.as_bytes()).and_then(|_| writer.write_all(body.as_bytes())).is_err() { | |
| 260 | + return; | |
| 261 | + } | |
| 262 | + let _ = writer.flush(); | |
| 263 | + } | |
| 264 | + } | |
| 265 | + | |
| 266 | + /// Answers one JSON-RPC message; `None` for a notification. | |
| 267 | + fn handle(thread: &str, message: &Value) -> Option<Value> { | |
| 268 | + let id = message.get("id").cloned()?; | |
| 269 | + let params = message.get("params").cloned().unwrap_or(Value::Null); | |
| 270 | + let result = match message.get("method").and_then(Value::as_str).unwrap_or_default() { | |
| 271 | + "initialize" => Ok(json!({ | |
| 272 | + "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"), | |
| 273 | + "capabilities": {"tools": {"listChanged": false}}, | |
| 274 | + "serverInfo": {"name": SERVER, "version": env!("CARGO_PKG_VERSION")}, | |
| 275 | + "instructions": INSTRUCTIONS, | |
| 276 | + })), | |
| 277 | + "ping" => Ok(json!({})), | |
| 278 | + "tools/list" => Ok(json!({"tools": tool_definitions()})), | |
| 279 | + "tools/call" => { | |
| 280 | + let tool = params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); | |
| 281 | + let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); | |
| 282 | + Ok(tool_result(call(thread, tool, args))) | |
| 283 | + } | |
| 284 | + method => Err(json!({"code": -32601, "message": format!("unknown method {method}")})), | |
| 285 | + }; | |
| 286 | + Some(match result { | |
| 287 | + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), | |
| 288 | + Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}), | |
| 289 | + }) | |
| 290 | + } | |
| 291 | + | |
| 292 | + /// Hands a call to the workspace and waits for its answer. | |
| 293 | + fn call(thread: &str, tool: String, args: Value) -> Result<Value, String> { | |
| 294 | + let (reply, answer) = smol::channel::bounded(1); | |
| 295 | + let request = ToolRequest { thread: thread.to_string(), tool, args, reply }; | |
| 296 | + channel().0.send_blocking(request).map_err(|_| "Vectrum Code is shutting down.".to_string())?; | |
| 297 | + answer.recv_blocking().unwrap_or_else(|_| Err("Vectrum Code dropped the call.".into())) | |
| 298 | + } | |
| 299 | + | |
| 300 | + /// An answer as an MCP tool result: the data as text for the model, and | |
| 301 | + /// structured for scripts. | |
| 302 | + pub fn tool_result(outcome: Result<Value, String>) -> Value { | |
| 303 | + match outcome { | |
| 304 | + Ok(value) => json!({ | |
| 305 | + "content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}], | |
| 306 | + "structuredContent": value, | |
| 307 | + }), | |
| 308 | + Err(error) => json!({"content": [{"type": "text", "text": error}], "isError": true}), | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + /// The Pi extension: it bridges the app's tools into Pi (when the thread | |
| 313 | + /// may delegate), adds the instructions to the system prompt, and switches | |
| 314 | + /// off other subagent tools before every run. | |
| 315 | + const PI_EXTENSION: &str = r#"// Written by Vectrum Code, which loads it into the Pi agents it runs. | |
| 316 | + // Changes are overwritten. | |
| 317 | + const URL = process.env.__URL_ENV__; | |
| 318 | + const TOKEN = process.env.__TOKEN_ENV__; | |
| 319 | + const TOOLS = __TOOLS__; | |
| 320 | + const INSTRUCTIONS = __INSTRUCTIONS__; | |
| 321 | + const OWN = new Set(TOOLS.map((tool) => tool.name)); | |
| 322 | + // Tools that start agents of their own: Pi's subagent extension and the like. | |
| 323 | + const OTHER_SUBAGENTS = /subagent|^(task|tasks|agent|agents|swarm|spawn_agent|spawn_agents|delegate)$/i; | |
| 324 | + | |
| 325 | + async function schema(inputSchema) { | |
| 326 | + for (const name of ["typebox", "@sinclair/typebox"]) { | |
| 327 | + try { | |
| 328 | + const { Type } = await import(name); | |
| 329 | + if (typeof Type?.Unsafe === "function") return Type.Unsafe(inputSchema); | |
| 330 | + } catch {} | |
| 331 | + } | |
| 332 | + return inputSchema; | |
| 333 | + } | |
| 334 | + | |
| 335 | + let nextId = 1; | |
| 336 | + async function callTool(name, args, signal) { | |
| 337 | + const response = await fetch(URL, { | |
| 338 | + method: "POST", | |
| 339 | + headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${TOKEN}` }, | |
| 340 | + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } }), | |
| 341 | + signal, | |
| 342 | + }); | |
| 343 | + const body = await response.text(); | |
| 344 | + if (!response.ok) throw new Error(`Vectrum Code answered ${response.status}: ${body.slice(0, 400)}`); | |
| 345 | + const message = JSON.parse(body); | |
| 346 | + if (message.error) throw new Error(message.error.message ?? "Vectrum Code returned an error"); | |
| 347 | + return message.result; | |
| 348 | + } | |
| 349 | + | |
| 350 | + export default async function vectrumCode(pi) { | |
| 351 | + // Subagents go through the app, so other subagent tools stay off. Checked | |
| 352 | + // before every run, since extensions can turn tools back on. | |
| 353 | + const switchOffOthers = () => { | |
| 354 | + if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return; | |
| 355 | + const active = pi.getActiveTools(); | |
| 356 | + const kept = active.filter((name) => OWN.has(name) || !OTHER_SUBAGENTS.test(name)); | |
| 357 | + if (kept.length !== active.length) pi.setActiveTools(kept); | |
| 358 | + }; | |
| 359 | + pi.on("session_start", switchOffOthers); | |
| 360 | + pi.on("before_agent_start", (event) => { | |
| 361 | + switchOffOthers(); | |
| 362 | + if (URL && TOKEN) return { systemPrompt: event.systemPrompt + "\n\n" + INSTRUCTIONS }; | |
| 363 | + }); | |
| 364 | + if (!URL || !TOKEN) return; | |
| 365 | + for (const tool of TOOLS) { | |
| 366 | + pi.registerTool({ | |
| 367 | + name: tool.name, | |
| 368 | + label: tool.annotations?.title ?? tool.name, | |
| 369 | + description: tool.description, | |
| 370 | + parameters: await schema(tool.inputSchema), | |
| 371 | + async execute(_toolCallId, params, signal) { | |
| 372 | + const result = await callTool(tool.name, params, signal); | |
| 373 | + const text = (result?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text).join("\n"); | |
| 374 | + if (result?.isError) throw new Error(text || `${tool.name} failed`); | |
| 375 | + return { content: [{ type: "text", text }], details: result?.structuredContent }; | |
| 376 | + }, | |
| 377 | + }); | |
| 378 | + } | |
| 379 | + } | |
| 380 | + "#; | |
| 381 | + | |
| 382 | + /// Writes the Pi extension where Pi can load it, and returns its path. | |
| 383 | + fn pi_extension() -> Option<PathBuf> { | |
| 384 | + static PATH: OnceLock<Option<PathBuf>> = OnceLock::new(); | |
| 385 | + PATH.get_or_init(|| { | |
| 386 | + let source = PI_EXTENSION | |
| 387 | + .replace("__URL_ENV__", URL_ENV) | |
| 388 | + .replace("__TOKEN_ENV__", TOKEN_ENV) | |
| 389 | + .replace("__TOOLS__", &tool_definitions().to_string()) | |
| 390 | + .replace("__INSTRUCTIONS__", &Value::String(INSTRUCTIONS.into()).to_string()); | |
| 391 | + let path = crate::settings::data_dir()?.join("pi").join("vectrum-code.js"); | |
| 392 | + std::fs::create_dir_all(path.parent()?).ok()?; | |
| 393 | + if std::fs::read_to_string(&path).ok().as_deref() != Some(source.as_str()) { | |
| 394 | + std::fs::write(&path, source).ok()?; | |
| 395 | + } | |
| 396 | + Some(path) | |
| 397 | + }) | |
| 398 | + .clone() | |
| 399 | + } | |
| 400 | + | |
| 401 | + /// Loads the extension into a Pi command. `thread` gets the tools; without | |
| 402 | + /// it (a subagent's own thread) Pi only has its other subagent tools turned off. | |
| 403 | + pub fn prepare_pi(command: &mut std::process::Command, thread: Option<&str>) { | |
| 404 | + let Some(extension) = pi_extension() else { return }; | |
| 405 | + command.arg("--extension").arg(extension); | |
| 406 | + if let Some((url, token)) = thread.and_then(credentials) { | |
| 407 | + command.env(URL_ENV, url).env(TOKEN_ENV, token); | |
| 408 | + } else { | |
| 409 | + command.env_remove(URL_ENV).env_remove(TOKEN_ENV); | |
| 410 | + } | |
| 411 | + } | |
| 412 | + | |
| 413 | + /// jcode's session tool configuration: the app's tools (when the thread may | |
| 414 | + /// delegate) and its own agent-spawning tools off. | |
| 415 | + pub fn jcode_tools(delegate: bool) -> Value { | |
| 416 | + let custom: Vec<Value> = if delegate { | |
| 417 | + tool_definitions() | |
| 418 | + .as_array() | |
| 419 | + .into_iter() | |
| 420 | + .flatten() | |
| 421 | + .map(|tool| json!({"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]})) | |
| 422 | + .collect() | |
| 423 | + } else { | |
| 424 | + Vec::new() | |
| 425 | + }; | |
| 426 | + // `schedule` can spawn a session too. | |
| 427 | + json!({"disabled": ["swarm", "schedule"], "custom": custom}) | |
| 428 | + } | |
| 429 | + | |
| 430 | + /// How long a call's `mode: "wait"` may block before the agent's own client | |
| 431 | + /// gives up on it: jcode allows a session tool two minutes, and Node's | |
| 432 | + /// fetch (Pi) waits five for a reply's headers. | |
| 433 | + pub fn wait_limit(provider: crate::models::Provider) -> Duration { | |
| 434 | + match provider { | |
| 435 | + crate::models::Provider::Claude => Duration::from_secs(30 * 60), | |
| 436 | + crate::models::Provider::Pi => Duration::from_secs(270), | |
| 437 | + crate::models::Provider::Jcode => Duration::from_secs(100), | |
| 438 | + } | |
| 439 | + } | |
| 440 | + | |
| 441 | + #[cfg(test)] | |
| 442 | + mod tests { | |
| 443 | + use serde_json::{Value, json}; | |
| 444 | + | |
| 445 | + use super::{DELEGATE, credentials, own_tool, requests}; | |
| 446 | + | |
| 447 | + fn post(url: &str, token: &str, body: Value) -> (u16, String) { | |
| 448 | + let response = ureq::post(url).set("Authorization", &format!("Bearer {token}")).set("Content-Type", "application/json").send_string(&body.to_string()); | |
| 449 | + match response { | |
| 450 | + Ok(response) => (response.status(), response.into_string().unwrap_or_default()), | |
| 451 | + Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()), | |
| 452 | + Err(err) => panic!("{err}"), | |
| 453 | + } | |
| 454 | + } | |
| 455 | + | |
| 456 | + #[::core::prelude::v1::test] | |
| 457 | + fn serves_mcp_to_the_threads_it_knows() { | |
| 458 | + let (url, token) = credentials("thread-a").expect("listens"); | |
| 459 | + assert_eq!(credentials("thread-a").unwrap().1, token, "a thread keeps its token"); | |
| 460 | + assert_ne!(credentials("thread-b").unwrap().1, token); | |
| 461 | + | |
| 462 | + let (status, _) = post(&url, "nope", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})); | |
| 463 | + assert_eq!(status, 401); | |
| 464 | + | |
| 465 | + let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}})); | |
| 466 | + assert_eq!(status, 200); | |
| 467 | + let reply: Value = serde_json::from_str(&body).unwrap(); | |
| 468 | + assert_eq!(reply["result"]["protocolVersion"], "2025-03-26"); | |
| 469 | + assert!(reply["result"]["instructions"].as_str().unwrap().contains("delegate_task")); | |
| 470 | + | |
| 471 | + let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); | |
| 472 | + assert_eq!((status, body.as_str()), (202, "")); | |
| 473 | + | |
| 474 | + let (_, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})); | |
| 475 | + let reply: Value = serde_json::from_str(&body).unwrap(); | |
| 476 | + assert_eq!(reply["result"]["tools"][0]["name"], DELEGATE); | |
| 477 | + | |
| 478 | + // The workspace's side, answering with who called and what. | |
| 479 | + let incoming = requests(); | |
| 480 | + std::thread::spawn(move || { | |
| 481 | + while let Ok(request) = incoming.recv_blocking() { | |
| 482 | + let outcome = match request.tool.as_str() { | |
| 483 | + "task_status" => Ok(json!({"thread": request.thread, "args": request.args})), | |
| 484 | + _ => Err("no such task".to_string()), | |
| 485 | + }; | |
| 486 | + let _ = request.reply.send_blocking(outcome); | |
| 487 | + } | |
| 488 | + }); | |
| 489 | + let call = |name: &str| { | |
| 490 | + let body = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": name, "arguments": {"taskId": "t1"}}}); | |
| 491 | + let (_, body) = post(&url, &token, body); | |
| 492 | + serde_json::from_str::<Value>(&body).unwrap()["result"].clone() | |
| 493 | + }; | |
| 494 | + let result = call("task_status"); | |
| 495 | + assert_eq!(result["structuredContent"], json!({"thread": "thread-a", "args": {"taskId": "t1"}})); | |
| 496 | + assert!(result.get("isError").is_none()); | |
| 497 | + let result = call("task_cancel"); | |
| 498 | + assert_eq!(result["isError"], true); | |
| 499 | + assert_eq!(result["content"][0]["text"], "no such task"); | |
| 500 | + } | |
| 501 | + | |
| 502 | + #[::core::prelude::v1::test] | |
| 503 | + fn recognizes_its_tools_under_any_prefix() { | |
| 504 | + assert_eq!(own_tool("delegate_task"), Some(DELEGATE)); | |
| 505 | + assert_eq!(own_tool("mcp__vectrum-code__delegate_task"), Some(DELEGATE)); | |
| 506 | + assert_eq!(own_tool("mcp__vectrum_code__task_status"), Some("task_status")); | |
| 507 | + assert_eq!(own_tool("mcp__other__delegate_task"), None); | |
| 508 | + assert_eq!(own_tool("Task"), None); | |
| 509 | + } | |
| 510 | + } | |
| 511 | + | |
| @@ -19,6 +19,7 @@ use crate::browser; | ||
| 19 | 19 | use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent}; |
| 20 | 20 | use crate::git::{self, TurnChanges}; |
| 21 | 21 | use crate::models::{Catalog, Provider}; |
| 22 | + use crate::orchestrator; | |
| 22 | 23 | use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient}; |
| 23 | 24 | use crate::settings::{self, BranchNaming, Streaming}; |
| 24 | 25 | use crate::worktree::{self, PullRequest, SetupStage, StageStatus, Worktree, WorktreePlan}; |
| @@ -125,9 +126,9 @@ impl ToolCall { | ||
| 125 | 126 | .iter() |
| 126 | 127 | .map(|member| { |
| 127 | 128 | let mut status = TaskStatus::from_cli(&member.status); |
| 128 | - // A Pi subagent runs inside its call; a swarm agent | |
| 129 | - // outlives the call that spawned it. | |
| 130 | - if status == TaskStatus::Running && !call_running && self.name != SWARM_TOOL { | |
| 129 | + // A Pi subagent runs inside its call; a swarm agent or | |
| 130 | + // a delegated task outlives the call that started it. | |
| 131 | + if status == TaskStatus::Running && !call_running && !self.outlives_call() { | |
| 131 | 132 | status = TaskStatus::Stopped; |
| 132 | 133 | } |
| 133 | 134 | let running = status == TaskStatus::Running; |
| @@ -141,6 +142,7 @@ impl ToolCall { | ||
| 141 | 142 | elapsed: elapsed_between(member.started, member.finished, running), |
| 142 | 143 | tool: member.tool.clone().filter(|_| running), |
| 143 | 144 | summary: member.summary.clone(), |
| 145 | + thread: self.is_delegation().then(|| member.id.clone()), | |
| 144 | 146 | } |
| 145 | 147 | }) |
| 146 | 148 | .collect(); |
| @@ -156,7 +158,7 @@ impl ToolCall { | ||
| 156 | 158 | (None, ToolState::Done) => TaskStatus::Completed, |
| 157 | 159 | }; |
| 158 | 160 | let running = status == TaskStatus::Running; |
| 159 | - let label = tool_field(&self.input, &["description", "label", "task"]) | |
| 161 | + let label = tool_field(&self.input, &["description", "label", "title", "task"]) | |
| 160 | 162 | .filter(|label| !label.trim().is_empty()) |
| 161 | 163 | .or_else(|| { |
| 162 | 164 | let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len(); |
| @@ -173,8 +175,19 @@ impl ToolCall { | ||
| 173 | 175 | elapsed: elapsed_between(progress.started, progress.finished, running), |
| 174 | 176 | tool: progress.tool.clone().filter(|_| running), |
| 175 | 177 | summary: progress.summary.clone(), |
| 178 | + thread: None, | |
| 176 | 179 | }] |
| 177 | 180 | } |
| 181 | + | |
| 182 | + /// A call to the app's `delegate_task`, whose task runs in a thread of its own. | |
| 183 | + pub fn is_delegation(&self) -> bool { | |
| 184 | + orchestrator::own_tool(&self.name) == Some(orchestrator::DELEGATE) | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// Whether the agents it starts carry on after the call returns. | |
| 188 | + fn outlives_call(&self) -> bool { | |
| 189 | + self.name == SWARM_TOOL || self.is_delegation() | |
| 190 | + } | |
| 178 | 191 | } |
| 179 | 192 | |
| 180 | 193 | /// A subagent as the subagents panel shows it. |
| @@ -191,6 +204,8 @@ pub struct SubagentEntry { | ||
| 191 | 204 | /// Its latest tool call, while it works. |
| 192 | 205 | pub tool: Option<(String, Value)>, |
| 193 | 206 | pub summary: Option<String>, |
| 207 | + /// The thread it runs in, for a task delegated to the app. | |
| 208 | + pub thread: Option<String>, | |
| 194 | 209 | } |
| 195 | 210 | |
| 196 | 211 | /// jcode's tool for running a swarm of agents, each in a session of its own. |
| @@ -200,6 +215,8 @@ const SWARM_POLL: Duration = Duration::from_secs(3); | ||
| 200 | 215 | /// The jcode requests that check on swarm agents, by what they're for. |
| 201 | 216 | const LIST_AGENTS: &str = "list the swarm's agents"; |
| 202 | 217 | const PEEK_AGENT: &str = "read a swarm agent's progress"; |
| 218 | + /// The jcode request that registers the app's tools. | |
| 219 | + const CONFIGURE_TOOLS: &str = "set up the app's tools"; | |
| 203 | 220 | |
| 204 | 221 | /// One agent among several a call started: a task of Pi's `subagent` call, |
| 205 | 222 | /// or an agent a jcode `swarm` call spawned. |
| @@ -282,7 +299,7 @@ impl SubagentProgress { | ||
| 282 | 299 | /// `subagent` extension (and jcode's old tool of that name), and jcode's |
| 283 | 300 | /// swarm, whose spawns do. |
| 284 | 301 | pub fn is_subagent_tool(name: &str) -> bool { |
| 285 | - matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) | |
| 302 | + matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) || orchestrator::own_tool(name) == Some(orchestrator::DELEGATE) | |
| 286 | 303 | } |
| 287 | 304 | |
| 288 | 305 | /// The tool call a model's message ends on, from Pi's or jcode's message |
| @@ -371,7 +388,7 @@ fn last_line(text: &str) -> Option<String> { | ||
| 371 | 388 | |
| 372 | 389 | /// A tool input cut down to its short text fields: enough to say what the |
| 373 | 390 | /// call does, without a file's contents. |
| 374 | - fn brief_input(input: &Value) -> Value { | |
| 391 | + pub(crate) fn brief_input(input: &Value) -> Value { | |
| 375 | 392 | let Some(fields) = input.as_object() else { return Value::Null }; |
| 376 | 393 | fields |
| 377 | 394 | .iter() |
| @@ -394,7 +411,7 @@ pub enum TaskStatus { | ||
| 394 | 411 | } |
| 395 | 412 | |
| 396 | 413 | impl TaskStatus { |
| 397 | - fn key(self) -> &'static str { | |
| 414 | + pub fn key(self) -> &'static str { | |
| 398 | 415 | match self { |
| 399 | 416 | TaskStatus::Running => "running", |
| 400 | 417 | TaskStatus::Completed => "completed", |
| @@ -634,6 +651,29 @@ pub struct RemoteLink { | ||
| 634 | 651 | pub enum SessionEvent { |
| 635 | 652 | /// A finished turn's changes on disk are known, by its `TurnEnd` index. |
| 636 | 653 | TurnChanges(usize), |
| 654 | + /// jcode called one of the app's tools, to be answered with | |
| 655 | + /// [`Session::answer_app_tool`]. | |
| 656 | + AppTool { call_id: String, tool: String, input: Value }, | |
| 657 | + } | |
| 658 | + | |
| 659 | + /// Where a subagent's thread starts: its parent thread's id, folder and branch. | |
| 660 | + pub struct SubagentParent { | |
| 661 | + pub id: String, | |
| 662 | + pub cwd: PathBuf, | |
| 663 | + pub branch: Option<String>, | |
| 664 | + } | |
| 665 | + | |
| 666 | + /// What makes a thread a subagent: the thread that delegated its task. The | |
| 667 | + /// task goes by the subagent thread's id. | |
| 668 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 669 | + pub struct Delegation { | |
| 670 | + pub parent: String, | |
| 671 | + /// Its result reached the parent, in a reply or a message. | |
| 672 | + #[serde(default)] | |
| 673 | + pub delivered: bool, | |
| 674 | + /// Stopped by `task_cancel` or by the user. | |
| 675 | + #[serde(default)] | |
| 676 | + pub cancelled: bool, | |
| 637 | 677 | } |
| 638 | 678 | |
| 639 | 679 | /// A message waiting for the running turn to end. |
| @@ -748,6 +788,11 @@ struct JcodeLink { | ||
| 748 | 788 | browser_note: Option<String>, |
| 749 | 789 | /// Checks on the swarm agents the thread spawned, while any work. |
| 750 | 790 | agent_poll: Option<Task<()>>, |
| 791 | + /// The app's tools are to be registered again once the turn ends: | |
| 792 | + /// jcode turns down tool changes mid-turn. | |
| 793 | + tools_pending: bool, | |
| 794 | + /// The app's instructions went out with a message. | |
| 795 | + instructions_sent: bool, | |
| 751 | 796 | } |
| 752 | 797 | |
| 753 | 798 | pub struct Session { |
| @@ -772,6 +817,8 @@ pub struct Session { | ||
| 772 | 817 | pub branch: Option<String>, |
| 773 | 818 | /// Set for a thread mirrored from another device. |
| 774 | 819 | pub remote: Option<RemoteLink>, |
| 820 | + /// Set for a subagent's thread: a task another thread delegated. | |
| 821 | + pub delegation: Option<Delegation>, | |
| 775 | 822 | pub items: Vec<ChatItem>, |
| 776 | 823 | pub status: SessionStatus, |
| 777 | 824 | pub thinking: bool, |
| @@ -895,6 +942,41 @@ impl Session { | ||
| 895 | 942 | session |
| 896 | 943 | } |
| 897 | 944 | |
| 945 | + /// A subagent's thread: it runs a task `parent` delegated, in the | |
| 946 | + /// parent's working folder, and starts on the task right away. | |
| 947 | + pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self { | |
| 948 | + let now = SystemTime::now(); | |
| 949 | + let id = format!( | |
| 950 | + "{:x}-{:x}-{}", | |
| 951 | + now.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(), | |
| 952 | + std::process::id(), | |
| 953 | + NEXT_SESSION.load(Ordering::Relaxed) | |
| 954 | + ); | |
| 955 | + let mut session = Self::empty(id, new, now); | |
| 956 | + session.cwd = parent.cwd.clone(); | |
| 957 | + session.branch = parent.branch.clone(); | |
| 958 | + session.delegation = Some(Delegation { parent: parent.id.clone(), delivered: false, cancelled: false }); | |
| 959 | + if let Some(title) = title.filter(|title| !title.trim().is_empty()) { | |
| 960 | + session.title = title.trim().to_string().into(); | |
| 961 | + session.title_custom = true; | |
| 962 | + } | |
| 963 | + session.send_message(task, Vec::new(), cx); | |
| 964 | + session | |
| 965 | + } | |
| 966 | + | |
| 967 | + /// A subagent's thread, which the sidebar leaves out. | |
| 968 | + pub fn is_subagent(&self) -> bool { | |
| 969 | + self.delegation.is_some() | |
| 970 | + } | |
| 971 | + | |
| 972 | + /// What the thread's agent last said: the end of its latest answer. | |
| 973 | + pub fn last_answer(&self) -> Option<String> { | |
| 974 | + self.items.iter().rev().find_map(|item| match item { | |
| 975 | + ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(answer.text.clone()), | |
| 976 | + _ => None, | |
| 977 | + }) | |
| 978 | + } | |
| 979 | + | |
| 898 | 980 | fn empty(id: String, new: NewSession, now: SystemTime) -> Self { |
| 899 | 981 | let snapshot_index = std::env::temp_dir().join(format!( |
| 900 | 982 | "vectrum-code-{}-{}.index", |
| @@ -915,6 +997,7 @@ impl Session { | ||
| 915 | 997 | pull_request: None, |
| 916 | 998 | branch: None, |
| 917 | 999 | remote: None, |
| 1000 | + delegation: None, | |
| 918 | 1001 | items: Vec::new(), |
| 919 | 1002 | status: SessionStatus::Starting, |
| 920 | 1003 | thinking: false, |
| @@ -998,6 +1081,7 @@ impl Session { | ||
| 998 | 1081 | resume: resume.clone(), |
| 999 | 1082 | browser_session: settings::get_for(&self.project, |settings| settings.agent_browser) |
| 1000 | 1083 | .then(|| browser::session_name(&self.id)), |
| 1084 | + delegating_thread: self.delegation.is_none().then(|| self.id.clone()), | |
| 1001 | 1085 | }; |
| 1002 | 1086 | let process = match AgentProcess::spawn(&config, tx) { |
| 1003 | 1087 | Ok(process) => process, |
| @@ -1401,8 +1485,14 @@ impl Session { | ||
| 1401 | 1485 | self.jcode_request("steer the turn", request); |
| 1402 | 1486 | } else { |
| 1403 | 1487 | let mut request = json!({"req": "send_message", "content": prompt, "images": images}); |
| 1404 | - if let Some(note) = self.jcode.browser_note.take() { | |
| 1405 | - request["system_reminder"] = note.into(); | |
| 1488 | + // jcode's system prompt can't be added to, so the app's | |
| 1489 | + // instructions go along with the first message. | |
| 1490 | + let instructions = (self.delegation.is_none() && !self.jcode.instructions_sent) | |
| 1491 | + .then_some(orchestrator::INSTRUCTIONS.to_string()); | |
| 1492 | + self.jcode.instructions_sent = true; | |
| 1493 | + let reminder: Vec<String> = instructions.into_iter().chain(self.jcode.browser_note.take()).collect(); | |
| 1494 | + if !reminder.is_empty() { | |
| 1495 | + request["system_reminder"] = reminder.join("\n\n").into(); | |
| 1406 | 1496 | } |
| 1407 | 1497 | self.jcode_request("send the message", request); |
| 1408 | 1498 | } |
| @@ -3171,6 +3261,7 @@ impl Session { | ||
| 3171 | 3261 | for (what, request) in std::mem::take(&mut self.jcode.held) { |
| 3172 | 3262 | self.jcode_request(what, request); |
| 3173 | 3263 | } |
| 3264 | + self.configure_jcode_tools(); | |
| 3174 | 3265 | // Swarm agents carry on in jcode while the app is away. |
| 3175 | 3266 | self.watch_swarm(cx); |
| 3176 | 3267 | } |
| @@ -3194,6 +3285,9 @@ impl Session { | ||
| 3194 | 3285 | match request { |
| 3195 | 3286 | // Checking on swarm agents is best effort, and repeats. |
| 3196 | 3287 | Some(LIST_AGENTS | PEEK_AGENT) => {} |
| 3288 | + // Mid-turn, it waits for the turn to end. Daemons too old | |
| 3289 | + // for session tools go without. | |
| 3290 | + Some(CONFIGURE_TOOLS) => self.jcode.tools_pending = error.contains("busy"), | |
| 3197 | 3291 | // The session is gone from jcode's store; carry on in a new one. |
| 3198 | 3292 | Some("attach") => { |
| 3199 | 3293 | self.push_notice( |
| @@ -3221,6 +3315,11 @@ impl Session { | ||
| 3221 | 3315 | self.jcode.provider = message.get("provider").and_then(Value::as_str).map(str::to_string); |
| 3222 | 3316 | } |
| 3223 | 3317 | "message_accepted" => self.begin_turn(cx), |
| 3318 | + "tool_call" => cx.emit(SessionEvent::AppTool { | |
| 3319 | + call_id: text("call_id").to_string(), | |
| 3320 | + tool: text("name").to_string(), | |
| 3321 | + input: message.get("input").cloned().unwrap_or(Value::Null), | |
| 3322 | + }), | |
| 3224 | 3323 | "soft_interrupt_injected" if text("point") == "turn_start" => self.begin_turn(cx), |
| 3225 | 3324 | "text_delta" => { |
| 3226 | 3325 | self.begin_turn(cx); |
| @@ -3349,12 +3448,74 @@ impl Session { | ||
| 3349 | 3448 | self.finish_open_tools("The turn ended before the tool finished."); |
| 3350 | 3449 | let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64); |
| 3351 | 3450 | self.finish_turn(duration_ms, None, cx); |
| 3451 | + if std::mem::take(&mut self.jcode.tools_pending) { | |
| 3452 | + self.configure_jcode_tools(); | |
| 3453 | + } | |
| 3352 | 3454 | self.send_next_queued(cx); |
| 3353 | 3455 | } |
| 3354 | 3456 | _ => {} |
| 3355 | 3457 | } |
| 3356 | 3458 | } |
| 3357 | 3459 | |
| 3460 | + /// Registers the app's tools with the thread's jcode session (a | |
| 3461 | + /// subagent's thread gets none) and switches off jcode's own ways of | |
| 3462 | + /// spawning agents. jcode keeps the setup in memory, so it's sent on | |
| 3463 | + /// every attach. | |
| 3464 | + fn configure_jcode_tools(&mut self) { | |
| 3465 | + let tools = orchestrator::jcode_tools(self.delegation.is_none()); | |
| 3466 | + self.jcode_request(CONFIGURE_TOOLS, json!({"req": "configure_tools", "tools": tools})); | |
| 3467 | + } | |
| 3468 | + | |
| 3469 | + /// Answers a call jcode made to one of the app's tools. | |
| 3470 | + pub fn answer_app_tool(&mut self, call_id: &str, outcome: Result<Value, String>) { | |
| 3471 | + let (output, error) = match outcome { | |
| 3472 | + Ok(value) => (serde_json::to_string_pretty(&value).unwrap_or_default(), None), | |
| 3473 | + Err(error) => (String::new(), Some(error)), | |
| 3474 | + }; | |
| 3475 | + let request = json!({"req": "tool_result", "call_id": call_id, "output": output, "error": error}); | |
| 3476 | + self.jcode_request("answer a tool call", request); | |
| 3477 | + } | |
| 3478 | + | |
| 3479 | + /// Ties a task the app started for this thread to the `delegate_task` | |
| 3480 | + /// call that asked for it: the latest one with that task and no | |
| 3481 | + /// subagent yet. | |
| 3482 | + pub fn link_delegation(&mut self, args: &Value, member: SubagentMember, cx: &mut Context<Self>) { | |
| 3483 | + let task = args.get("task").and_then(Value::as_str); | |
| 3484 | + let unlinked = |tool: &ToolCall| { | |
| 3485 | + tool.is_delegation() && tool.subagent.as_ref().is_some_and(|progress| progress.members.is_empty()) | |
| 3486 | + }; | |
| 3487 | + let ix = self | |
| 3488 | + .items | |
| 3489 | + .iter() | |
| 3490 | + .rposition(|item| { | |
| 3491 | + matches!(item, ChatItem::Tool(tool) if unlinked(tool) && tool.input.get("task").and_then(Value::as_str) == task) | |
| 3492 | + }) | |
| 3493 | + .or_else(|| self.items.iter().rposition(|item| matches!(item, ChatItem::Tool(tool) if unlinked(tool)))); | |
| 3494 | + if let Some(ChatItem::Tool(tool)) = ix.and_then(|ix| self.items.get_mut(ix)) | |
| 3495 | + && let Some(progress) = tool.subagent.as_mut() | |
| 3496 | + { | |
| 3497 | + progress.members.push(member); | |
| 3498 | + cx.notify(); | |
| 3499 | + } | |
| 3500 | + } | |
| 3501 | + | |
| 3502 | + /// Updates the subagent row of a task this thread delegated. | |
| 3503 | + pub fn update_delegation(&mut self, task: &str, update: impl FnOnce(&mut SubagentMember), cx: &mut Context<Self>) { | |
| 3504 | + let member = self.items.iter_mut().find_map(|item| match item { | |
| 3505 | + ChatItem::Tool(tool) if tool.is_delegation() => { | |
| 3506 | + tool.subagent.as_mut()?.members.iter_mut().find(|member| member.id == task) | |
| 3507 | + } | |
| 3508 | + _ => None, | |
| 3509 | + }); | |
| 3510 | + if let Some(member) = member { | |
| 3511 | + let before = member.clone(); | |
| 3512 | + update(member); | |
| 3513 | + if *member != before { | |
| 3514 | + cx.notify(); | |
| 3515 | + } | |
| 3516 | + } | |
| 3517 | + } | |
| 3518 | + | |
| 3358 | 3519 | /// The chat item a streamed jcode message renders into, made on its first delta. |
| 3359 | 3520 | fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize { |
| 3360 | 3521 | if let Some(&ix) = self.jcode.texts.get(message_id) { |
| @@ -3697,6 +3858,8 @@ pub struct SavedThread { | ||
| 3697 | 3858 | pub items: Vec<SavedItem>, |
| 3698 | 3859 | /// What each finished turn changed, by its `TurnEnd` index. |
| 3699 | 3860 | pub turn_changes: Vec<(usize, TurnChanges)>, |
| 3861 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 3862 | + pub delegation: Option<Delegation>, | |
| 3700 | 3863 | } |
| 3701 | 3864 | |
| 3702 | 3865 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| @@ -3865,6 +4028,7 @@ impl Session { | ||
| 3865 | 4028 | working: self.is_working(), |
| 3866 | 4029 | items, |
| 3867 | 4030 | turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(), |
| 4031 | + delegation: self.delegation.clone(), | |
| 3868 | 4032 | } |
| 3869 | 4033 | } |
| 3870 | 4034 | |
| @@ -3909,6 +4073,7 @@ impl Session { | ||
| 3909 | 4073 | session.queue = saved.queue; |
| 3910 | 4074 | session.interrupted = saved.working; |
| 3911 | 4075 | session.turn_changes = saved.turn_changes.into_iter().collect(); |
| 4076 | + session.delegation = saved.delegation; | |
| 3912 | 4077 | for item in saved.items { |
| 3913 | 4078 | let item = session.item_from_saved(item, false, cx); |
| 3914 | 4079 | session.items.push(item); |
| @@ -265,7 +265,7 @@ impl ChatView { | ||
| 265 | 265 | _ => {} |
| 266 | 266 | }), |
| 267 | 267 | cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| { |
| 268 | - let SessionEvent::TurnChanges(end) = event; | |
| 268 | + let SessionEvent::TurnChanges(end) = event else { return }; | |
| 269 | 269 | let big = session.read(cx).turn_changes(*end).is_some_and(|changes| { |
| 270 | 270 | changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES |
| 271 | 271 | }); |
| @@ -114,6 +114,17 @@ pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, Str | ||
| 114 | 114 | fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { |
| 115 | 115 | let field = |keys: &[&str]| tool_field(input, keys); |
| 116 | 116 | let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>(); |
| 117 | + // The app's own subagent tools, under whatever prefix the agent gives them. | |
| 118 | + if let Some(tool) = crate::orchestrator::own_tool(name) { | |
| 119 | + use crate::orchestrator::{CAPABILITIES, DELEGATE, TASK_CANCEL}; | |
| 120 | + let label = match tool { | |
| 121 | + DELEGATE => field(&["title"]).or_else(|| field(&["task"])).map(first_line).unwrap_or_else(|| "Subagent".into()), | |
| 122 | + TASK_CANCEL => "Stopped a subagent".into(), | |
| 123 | + CAPABILITIES => "Looked up agents and models".into(), | |
| 124 | + _ => "Checked on a subagent".into(), | |
| 125 | + }; | |
| 126 | + return Presentation { icon: Icon::new(IconName::Bot), label, action: Action::Other }; | |
| 127 | + } | |
| 117 | 128 | let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 118 | 129 | "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), |
| 119 | 130 | "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| @@ -46,6 +46,7 @@ use crate::{ | ||
| 46 | 46 | }; |
| 47 | 47 | |
| 48 | 48 | mod actions; |
| 49 | + mod delegation; | |
| 49 | 50 | mod git_actions; |
| 50 | 51 | mod header; |
| 51 | 52 | mod palette; |
| @@ -291,6 +292,8 @@ pub struct Workspace { | ||
| 291 | 292 | last_pr_check: Option<Instant>, |
| 292 | 293 | last_repaint: Instant, |
| 293 | 294 | remote: RemoteState, |
| 295 | + /// Tasks agents delegated to the app's subagents. | |
| 296 | + delegations: delegation::Delegations, | |
| 294 | 297 | focus_handle: FocusHandle, |
| 295 | 298 | observers: HashMap<EntityId, Vec<Subscription>>, |
| 296 | 299 | _subscriptions: Vec<Subscription>, |
| @@ -421,12 +424,14 @@ impl Workspace { | ||
| 421 | 424 | last_pr_check: None, |
| 422 | 425 | last_repaint: now, |
| 423 | 426 | remote: RemoteState::default(), |
| 427 | + delegations: Default::default(), | |
| 424 | 428 | focus_handle: cx.focus_handle(), |
| 425 | 429 | observers: HashMap::new(), |
| 426 | 430 | _subscriptions: subscriptions, |
| 427 | 431 | _ticker: ticker, |
| 428 | 432 | _remote_flush: remote_flush, |
| 429 | 433 | }; |
| 434 | + workspace.listen_for_tools(window, cx); | |
| 430 | 435 | let active = workspace.restore(window, cx); |
| 431 | 436 | match (initial_project, active) { |
| 432 | 437 | (Some(path), _) => workspace.add_project(path, window, cx), |
| @@ -1007,6 +1012,7 @@ impl Workspace { | ||
| 1007 | 1012 | session.entity_id(), |
| 1008 | 1013 | vec![ |
| 1009 | 1014 | cx.observe_in(&session, window, Self::on_session_changed), |
| 1015 | + cx.subscribe_in(&session, window, Self::on_session_event), | |
| 1010 | 1016 | cx.subscribe_in(&chat, window, Self::on_chat_event), |
| 1011 | 1017 | ], |
| 1012 | 1018 | ); |
| @@ -1042,6 +1048,7 @@ impl Workspace { | ||
| 1042 | 1048 | cx.notify(); |
| 1043 | 1049 | self.dirty.insert(session.entity_id()); |
| 1044 | 1050 | self.remote_thread_changed(&session, cx); |
| 1051 | + self.track_delegation(&session, cx); | |
| 1045 | 1052 | let state = session.read(cx); |
| 1046 | 1053 | let now = Activity { working: state.is_working(), attention: state.needs_attention() }; |
| 1047 | 1054 | let before = self.activity.insert(session.entity_id(), now).unwrap_or_default(); |
| @@ -1587,6 +1594,7 @@ impl Workspace { | ||
| 1587 | 1594 | .iter() |
| 1588 | 1595 | .enumerate() |
| 1589 | 1596 | .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone()))) |
| 1597 | + .filter(|(_, session)| !session.read(cx).is_subagent()) | |
| 1590 | 1598 | .filter(|(ix, session)| { |
| 1591 | 1599 | query.is_empty() |
| 1592 | 1600 | || session.read(cx).title.to_lowercase().contains(&query) |
| @@ -1715,7 +1723,7 @@ impl Workspace { | ||
| 1715 | 1723 | .projects |
| 1716 | 1724 | .iter() |
| 1717 | 1725 | .flat_map(|project| project.sessions.iter().map(move |session| (project, session))) |
| 1718 | - .filter(|(_, session)| session.read(cx).archived) | |
| 1726 | + .filter(|(_, session)| session.read(cx).archived && !session.read(cx).is_subagent()) | |
| 1719 | 1727 | .collect(); |
| 1720 | 1728 | threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated)); |
| 1721 | 1729 | threads |
| @@ -0,0 +1,452 @@ | ||
| 1 | + //! Tasks agents delegate to the app (see [`crate::orchestrator`]). Each runs | |
| 2 | + //! in a subagent thread: a thread of the parent's project, in its folder, | |
| 3 | + //! that the sidebar leaves out and the subagents panel opens. The parent's | |
| 4 | + //! `delegate_task` call shows it working. When it finishes, its result goes | |
| 5 | + //! to the call waiting on it (`mode: "wait"`), or else to the parent thread | |
| 6 | + //! as a message, which wakes the agent there. | |
| 7 | + | |
| 8 | + use std::time::{Duration, SystemTime}; | |
| 9 | + | |
| 10 | + use gpui_kit::*; | |
| 11 | + use serde_json::{Value, json}; | |
| 12 | + | |
| 13 | + use super::Workspace; | |
| 14 | + use crate::models::{Catalog, ProbeStatus}; | |
| 15 | + use crate::orchestrator::{self, CAPABILITIES, DELEGATE, TASK_CANCEL, TASK_STATUS, ToolRequest}; | |
| 16 | + use crate::session::{ | |
| 17 | + ChatItem, NewSession, Reasoning, Session, SessionEvent, SessionStatus, SubagentMember, SubagentParent, TaskStatus, | |
| 18 | + brief_input, to_millis, | |
| 19 | + }; | |
| 20 | + use crate::settings; | |
| 21 | + | |
| 22 | + /// How long `mode: "wait"` waits when the call doesn't say. | |
| 23 | + const DEFAULT_WAIT: Duration = Duration::from_secs(10 * 60); | |
| 24 | + /// The most of a subagent's answer that goes back in a message. | |
| 25 | + const RESULT_LIMIT: usize = 40_000; | |
| 26 | + | |
| 27 | + /// Whoever is waiting for a tool call's answer. | |
| 28 | + pub(super) enum Answer { | |
| 29 | + /// Claude or Pi, through the MCP server. | |
| 30 | + Mcp(smol::channel::Sender<Result<Value, String>>), | |
| 31 | + /// jcode, whose session tool call its thread answers. | |
| 32 | + Jcode { session: WeakEntity<Session>, call_id: String }, | |
| 33 | + } | |
| 34 | + | |
| 35 | + impl Answer { | |
| 36 | + fn send(self, outcome: Result<Value, String>, cx: &mut App) { | |
| 37 | + match self { | |
| 38 | + Answer::Mcp(reply) => { | |
| 39 | + let _ = reply.try_send(outcome); | |
| 40 | + } | |
| 41 | + Answer::Jcode { session, call_id } => { | |
| 42 | + let _ = session.update(cx, |session, _| session.answer_app_tool(&call_id, outcome)); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// A `delegate_task` call waiting for its task to finish. | |
| 49 | + struct Waiter { | |
| 50 | + id: u64, | |
| 51 | + task: String, | |
| 52 | + answer: Answer, | |
| 53 | + _timeout: Task<()>, | |
| 54 | + } | |
| 55 | + | |
| 56 | + #[derive(Default)] | |
| 57 | + pub(super) struct Delegations { | |
| 58 | + waiting: Vec<Waiter>, | |
| 59 | + next_waiter: u64, | |
| 60 | + _requests: Option<Task<()>>, | |
| 61 | + } | |
| 62 | + | |
| 63 | + /// Where a task stands, as the tools report it. | |
| 64 | + #[derive(Clone, Copy, PartialEq, Eq)] | |
| 65 | + enum TaskState { | |
| 66 | + Running, | |
| 67 | + /// Its agent waits for the user: a permission prompt or a question. | |
| 68 | + WaitingForUser, | |
| 69 | + Completed, | |
| 70 | + Failed, | |
| 71 | + Cancelled, | |
| 72 | + /// The app stopped while it worked. | |
| 73 | + Interrupted, | |
| 74 | + } | |
| 75 | + | |
| 76 | + impl TaskState { | |
| 77 | + fn of(session: &Session) -> Self { | |
| 78 | + let cancelled = session.delegation.as_ref().is_some_and(|delegation| delegation.cancelled); | |
| 79 | + let finished = session.items.iter().any(|item| matches!(item, ChatItem::TurnEnd { .. })); | |
| 80 | + if cancelled && !session.is_working() { | |
| 81 | + TaskState::Cancelled | |
| 82 | + } else if session.needs_attention() { | |
| 83 | + TaskState::WaitingForUser | |
| 84 | + } else if session.is_working() || session.preparing { | |
| 85 | + TaskState::Running | |
| 86 | + } else if session.status == SessionStatus::Exited || (finished && session.ended_with_error()) { | |
| 87 | + TaskState::Failed | |
| 88 | + } else if finished { | |
| 89 | + TaskState::Completed | |
| 90 | + } else if session.interrupted { | |
| 91 | + TaskState::Interrupted | |
| 92 | + } else { | |
| 93 | + // Starting up. | |
| 94 | + TaskState::Running | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + fn key(self) -> &'static str { | |
| 99 | + match self { | |
| 100 | + TaskState::Running => "running", | |
| 101 | + TaskState::WaitingForUser => "waiting_for_user", | |
| 102 | + TaskState::Completed => "completed", | |
| 103 | + TaskState::Failed => "failed", | |
| 104 | + TaskState::Cancelled => "cancelled", | |
| 105 | + TaskState::Interrupted => "interrupted", | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + fn is_terminal(self) -> bool { | |
| 110 | + !matches!(self, TaskState::Running | TaskState::WaitingForUser) | |
| 111 | + } | |
| 112 | + | |
| 113 | + fn panel_status(self) -> TaskStatus { | |
| 114 | + match self { | |
| 115 | + TaskState::Running | TaskState::WaitingForUser => TaskStatus::Running, | |
| 116 | + TaskState::Completed => TaskStatus::Completed, | |
| 117 | + TaskState::Failed => TaskStatus::Failed, | |
| 118 | + TaskState::Cancelled | TaskState::Interrupted => TaskStatus::Stopped, | |
| 119 | + } | |
| 120 | + } | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// A task as `delegate_task` and `task_status` report it. | |
| 124 | + fn report(session: &Session, wait_timed_out: bool) -> Value { | |
| 125 | + let state = TaskState::of(session); | |
| 126 | + let summary = state.is_terminal().then(|| session.last_answer()).flatten().map(|answer| truncate(&answer)); | |
| 127 | + json!({ | |
| 128 | + "taskId": session.id, | |
| 129 | + "childThreadId": session.id, | |
| 130 | + "title": session.title.to_string(), | |
| 131 | + "status": state.key(), | |
| 132 | + "providerInstanceId": session.instance, | |
| 133 | + "model": session.active_model.clone().or_else(|| session.model.clone()), | |
| 134 | + "summary": summary, | |
| 135 | + "waitTimedOut": wait_timed_out, | |
| 136 | + }) | |
| 137 | + } | |
| 138 | + | |
| 139 | + fn truncate(text: &str) -> String { | |
| 140 | + if text.len() <= RESULT_LIMIT { | |
| 141 | + return text.to_string(); | |
| 142 | + } | |
| 143 | + let mut end = RESULT_LIMIT; | |
| 144 | + while !text.is_char_boundary(end) { | |
| 145 | + end -= 1; | |
| 146 | + } | |
| 147 | + format!("{}\n\n[Cut short; task_status has the same text. The subagent's thread has the rest.]", &text[..end]) | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// The last line of what an agent said. | |
| 151 | + fn last_line(text: &str) -> Option<String> { | |
| 152 | + let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?; | |
| 153 | + Some(line.chars().take(200).collect()) | |
| 154 | + } | |
| 155 | + | |
| 156 | + impl Workspace { | |
| 157 | + /// Starts answering the calls agents make to the app's tools. | |
| 158 | + pub(super) fn listen_for_tools(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 159 | + let requests = orchestrator::requests(); | |
| 160 | + self.delegations._requests = Some(cx.spawn_in(window, async move |this, cx| { | |
| 161 | + while let Ok(request) = requests.recv().await { | |
| 162 | + let ToolRequest { thread, tool, args, reply } = request; | |
| 163 | + let answered = this.update_in(cx, |this, window, cx| { | |
| 164 | + this.answer_tool(&thread, &tool, args, Answer::Mcp(reply), window, cx) | |
| 165 | + }); | |
| 166 | + if answered.is_err() { | |
| 167 | + break; | |
| 168 | + } | |
| 169 | + } | |
| 170 | + })); | |
| 171 | + } | |
| 172 | + | |
| 173 | + /// jcode asks for the app's tools through its thread. | |
| 174 | + pub(super) fn on_session_event( | |
| 175 | + &mut self, | |
| 176 | + session: &Entity<Session>, | |
| 177 | + event: &SessionEvent, | |
| 178 | + window: &mut Window, | |
| 179 | + cx: &mut Context<Self>, | |
| 180 | + ) { | |
| 181 | + if let SessionEvent::AppTool { call_id, tool, input } = event { | |
| 182 | + let thread = session.read(cx).id.clone(); | |
| 183 | + let answer = Answer::Jcode { session: session.downgrade(), call_id: call_id.clone() }; | |
| 184 | + self.answer_tool(&thread, tool, input.clone(), answer, window, cx); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + /// A thread on this device, by id. | |
| 189 | + fn thread_by_id(&self, id: &str, cx: &App) -> Option<Entity<Session>> { | |
| 190 | + self.local_thread(id, cx).map(|(_, session)| session) | |
| 191 | + } | |
| 192 | + | |
| 193 | + fn answer_tool( | |
| 194 | + &mut self, | |
| 195 | + thread: &str, | |
| 196 | + tool: &str, | |
| 197 | + args: Value, | |
| 198 | + answer: Answer, | |
| 199 | + window: &mut Window, | |
| 200 | + cx: &mut Context<Self>, | |
| 201 | + ) { | |
| 202 | + let Some(parent) = self.thread_by_id(thread, cx) else { | |
| 203 | + answer.send(Err("The thread calling this is gone from Vectrum Code.".into()), cx); | |
| 204 | + return; | |
| 205 | + }; | |
| 206 | + if parent.read(cx).is_subagent() { | |
| 207 | + answer.send(Err("A subagent can't delegate tasks of its own; do the work yourself.".into()), cx); | |
| 208 | + return; | |
| 209 | + } | |
| 210 | + let outcome = match orchestrator::own_tool(tool) { | |
| 211 | + Some(CAPABILITIES) => Ok(capabilities(parent.read(cx), cx)), | |
| 212 | + Some(DELEGATE) => match self.delegate(&parent, &args, window, cx) { | |
| 213 | + Ok(child) if args.get("mode").and_then(Value::as_str) == Some("wait") => { | |
| 214 | + self.wait_for(&parent, child, &args, answer, cx); | |
| 215 | + return; | |
| 216 | + } | |
| 217 | + Ok(child) => Ok(report(child.read(cx), false)), | |
| 218 | + Err(err) => Err(err), | |
| 219 | + }, | |
| 220 | + Some(TASK_STATUS) => self.task_of(&parent, &args, cx).map(|child| { | |
| 221 | + // Reading a finished task's result counts as hearing of it. | |
| 222 | + if TaskState::of(child.read(cx)).is_terminal() { | |
| 223 | + self.mark_delivered(&child, cx); | |
| 224 | + } | |
| 225 | + report(child.read(cx), false) | |
| 226 | + }), | |
| 227 | + Some(TASK_CANCEL) => self.task_of(&parent, &args, cx).map(|child| { | |
| 228 | + if !TaskState::of(child.read(cx)).is_terminal() { | |
| 229 | + // The parent knows: no message about it. | |
| 230 | + child.update(cx, |child, cx| { | |
| 231 | + if let Some(delegation) = child.delegation.as_mut() { | |
| 232 | + delegation.cancelled = true; | |
| 233 | + delegation.delivered = true; | |
| 234 | + } | |
| 235 | + child.interrupt(cx); | |
| 236 | + }); | |
| 237 | + self.dirty.insert(child.entity_id()); | |
| 238 | + } | |
| 239 | + let mut report = report(child.read(cx), false); | |
| 240 | + if !TaskState::of(child.read(cx)).is_terminal() { | |
| 241 | + report["status"] = "cancel_requested".into(); | |
| 242 | + } | |
| 243 | + report | |
| 244 | + }), | |
| 245 | + _ => Err(format!("Vectrum Code has no tool named {tool}.")), | |
| 246 | + }; | |
| 247 | + answer.send(outcome, cx); | |
| 248 | + } | |
| 249 | + | |
| 250 | + /// Starts a subagent thread on a task `parent` delegated. | |
| 251 | + fn delegate( | |
| 252 | + &mut self, | |
| 253 | + parent: &Entity<Session>, | |
| 254 | + args: &Value, | |
| 255 | + window: &mut Window, | |
| 256 | + cx: &mut Context<Self>, | |
| 257 | + ) -> Result<Entity<Session>, String> { | |
| 258 | + fn text(value: Option<&Value>) -> Option<&str> { | |
| 259 | + value.and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()) | |
| 260 | + } | |
| 261 | + let task = text(args.get("task")).ok_or("delegate_task needs a task.")?.to_string(); | |
| 262 | + let title = text(args.get("title")).map(str::to_string); | |
| 263 | + let target = args.get("target"); | |
| 264 | + let state = parent.read(cx); | |
| 265 | + let instance_id = text(target.and_then(|target| target.get("providerInstanceId"))).unwrap_or(&state.instance); | |
| 266 | + let instance = settings::instances() | |
| 267 | + .into_iter() | |
| 268 | + .find(|instance| instance.id == instance_id) | |
| 269 | + .ok_or_else(|| format!("There's no provider instance \"{instance_id}\"; orchestrator_capabilities lists them."))?; | |
| 270 | + if !instance.enabled { | |
| 271 | + return Err(format!("The provider instance \"{instance_id}\" is turned off in Vectrum Code's settings.")); | |
| 272 | + } | |
| 273 | + // What the parent runs carries over to a child on the same agent. | |
| 274 | + let same = instance.id == state.instance; | |
| 275 | + let model = text(target.and_then(|target| target.get("model"))) | |
| 276 | + .map(str::to_string) | |
| 277 | + .or_else(|| state.model.clone().filter(|_| same)); | |
| 278 | + let new = NewSession { | |
| 279 | + instance: instance.id.clone(), | |
| 280 | + provider: instance.kind, | |
| 281 | + project: state.project.clone(), | |
| 282 | + model: model.clone(), | |
| 283 | + permission_mode: if instance.kind == state.provider { state.permission_mode } else { Default::default() }, | |
| 284 | + reasoning: if same { state.reasoning } else { Reasoning::Default }, | |
| 285 | + fast_mode: same && state.fast_mode, | |
| 286 | + worktree: None, | |
| 287 | + }; | |
| 288 | + let origin = SubagentParent { id: state.id.clone(), cwd: state.cwd.clone(), branch: state.branch.clone() }; | |
| 289 | + // A different agent or model is worth naming in the panel. | |
| 290 | + let kind = (!same || model != state.model).then(|| { | |
| 291 | + let agent = if instance.name.trim().is_empty() { instance.kind.label().to_string() } else { instance.name.clone() }; | |
| 292 | + match &model { | |
| 293 | + Some(model) => format!("{agent} · {model}"), | |
| 294 | + None => agent, | |
| 295 | + } | |
| 296 | + }); | |
| 297 | + let project_ix = self.project_of(parent).ok_or("The thread's project is gone.")?; | |
| 298 | + let label = title.clone().unwrap_or_else(|| { | |
| 299 | + task.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().chars().take(200).collect() | |
| 300 | + }); | |
| 301 | + let child = cx.new(|cx| Session::new_subagent(new, &origin, title, task, cx)); | |
| 302 | + self.attach(project_ix, child.clone(), window, cx); | |
| 303 | + let member = SubagentMember { | |
| 304 | + id: child.read(cx).id.clone(), | |
| 305 | + label, | |
| 306 | + kind, | |
| 307 | + status: "running".into(), | |
| 308 | + started: to_millis(SystemTime::now()), | |
| 309 | + ..Default::default() | |
| 310 | + }; | |
| 311 | + parent.update(cx, |parent, cx| parent.link_delegation(args, member, cx)); | |
| 312 | + self.dirty.insert(child.entity_id()); | |
| 313 | + cx.notify(); | |
| 314 | + Ok(child) | |
| 315 | + } | |
| 316 | + | |
| 317 | + /// Holds a `mode: "wait"` call until its task finishes, or until it's | |
| 318 | + /// waited as long as it may. | |
| 319 | + fn wait_for(&mut self, parent: &Entity<Session>, child: Entity<Session>, args: &Value, answer: Answer, cx: &mut Context<Self>) { | |
| 320 | + let asked = args.get("timeoutMs").and_then(Value::as_f64).filter(|ms| *ms > 0.).map(|ms| Duration::from_millis(ms as u64)); | |
| 321 | + let timeout = asked.unwrap_or(DEFAULT_WAIT).min(orchestrator::wait_limit(parent.read(cx).provider)); | |
| 322 | + self.delegations.next_waiter += 1; | |
| 323 | + let id = self.delegations.next_waiter; | |
| 324 | + let task_id = child.read(cx).id.clone(); | |
| 325 | + let timer = cx.spawn(async move |this, cx| { | |
| 326 | + cx.background_executor().timer(timeout).await; | |
| 327 | + let _ = this.update(cx, |this, cx| { | |
| 328 | + let Some(ix) = this.delegations.waiting.iter().position(|waiter| waiter.id == id) else { return }; | |
| 329 | + let waiter = this.delegations.waiting.remove(ix); | |
| 330 | + let report = report(child.read(cx), true); | |
| 331 | + waiter.answer.send(Ok(report), cx); | |
| 332 | + }); | |
| 333 | + }); | |
| 334 | + let task = task_id; | |
| 335 | + self.delegations.waiting.push(Waiter { id, task, answer, _timeout: timer }); | |
| 336 | + } | |
| 337 | + | |
| 338 | + /// A task `parent` delegated, by the `taskId` in a call. | |
| 339 | + fn task_of(&self, parent: &Entity<Session>, args: &Value, cx: &App) -> Result<Entity<Session>, String> { | |
| 340 | + let task = args.get("taskId").and_then(Value::as_str).ok_or("Name the task with taskId.")?; | |
| 341 | + let parent_id = &parent.read(cx).id; | |
| 342 | + self.thread_by_id(task, cx) | |
| 343 | + .filter(|child| child.read(cx).delegation.as_ref().is_some_and(|delegation| &delegation.parent == parent_id)) | |
| 344 | + .ok_or_else(|| format!("This thread delegated no task {task}.")) | |
| 345 | + } | |
| 346 | + | |
| 347 | + fn mark_delivered(&mut self, child: &Entity<Session>, cx: &mut Context<Self>) { | |
| 348 | + child.update(cx, |child, _| { | |
| 349 | + if let Some(delegation) = child.delegation.as_mut() { | |
| 350 | + delegation.delivered = true; | |
| 351 | + } | |
| 352 | + }); | |
| 353 | + self.dirty.insert(child.entity_id()); | |
| 354 | + } | |
| 355 | + | |
| 356 | + /// Keeps a subagent's row in its parent current, and once its task is | |
| 357 | + /// done, hands the result over. | |
| 358 | + pub(super) fn track_delegation(&mut self, child: &Entity<Session>, cx: &mut Context<Self>) { | |
| 359 | + let state = child.read(cx); | |
| 360 | + let Some(delegation) = state.delegation.clone() else { return }; | |
| 361 | + let task_state = TaskState::of(state); | |
| 362 | + let task = state.id.clone(); | |
| 363 | + let tools: Vec<_> = state | |
| 364 | + .items | |
| 365 | + .iter() | |
| 366 | + .filter_map(|item| match item { | |
| 367 | + ChatItem::Tool(tool) => Some(tool), | |
| 368 | + _ => None, | |
| 369 | + }) | |
| 370 | + .collect(); | |
| 371 | + let tool_uses = tools.len(); | |
| 372 | + let latest_tool = tools.last().map(|tool| (tool.name.clone(), brief_input(&tool.input))); | |
| 373 | + let summary = state.last_answer().as_deref().and_then(last_line); | |
| 374 | + let tokens = state.context_tokens; | |
| 375 | + let Some(parent) = self.thread_by_id(&delegation.parent, cx) else { return }; | |
| 376 | + parent.update(cx, |parent, cx| { | |
| 377 | + parent.update_delegation( | |
| 378 | + &task, | |
| 379 | + |member| { | |
| 380 | + let status = task_state.panel_status(); | |
| 381 | + if member.status != status.key() { | |
| 382 | + member.status = status.key().to_string(); | |
| 383 | + member.finished = (status != TaskStatus::Running).then(|| to_millis(SystemTime::now())); | |
| 384 | + } | |
| 385 | + member.tool_uses = tool_uses; | |
| 386 | + member.tool = latest_tool; | |
| 387 | + member.summary = summary.or(member.summary.take()); | |
| 388 | + member.tokens = tokens.or(member.tokens); | |
| 389 | + }, | |
| 390 | + cx, | |
| 391 | + ) | |
| 392 | + }); | |
| 393 | + if !task_state.is_terminal() || delegation.delivered { | |
| 394 | + return; | |
| 395 | + } | |
| 396 | + self.mark_delivered(child, cx); | |
| 397 | + let (waiting, rest): (Vec<Waiter>, Vec<Waiter>) = | |
| 398 | + std::mem::take(&mut self.delegations.waiting).into_iter().partition(|waiter| waiter.task == task); | |
| 399 | + self.delegations.waiting = rest; | |
| 400 | + let result = report(child.read(cx), false); | |
| 401 | + if !waiting.is_empty() { | |
| 402 | + for waiter in waiting { | |
| 403 | + waiter.answer.send(Ok(result.clone()), cx); | |
| 404 | + } | |
| 405 | + return; | |
| 406 | + } | |
| 407 | + // Nobody's waiting: tell the parent, which wakes its agent. | |
| 408 | + let title = child.read(cx).title.to_string(); | |
| 409 | + let mut message = format!( | |
| 410 | + "[Vectrum Code] Delegated task {task} (\"{title}\") {}.", | |
| 411 | + match task_state { | |
| 412 | + TaskState::Completed => "completed", | |
| 413 | + TaskState::Failed => "failed", | |
| 414 | + TaskState::Cancelled => "was cancelled", | |
| 415 | + _ => "stopped", | |
| 416 | + } | |
| 417 | + ); | |
| 418 | + match result.get("summary").and_then(Value::as_str) { | |
| 419 | + Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")), | |
| 420 | + None => message.push_str(" It gave no answer."), | |
| 421 | + } | |
| 422 | + parent.update(cx, |parent, cx| parent.send_message(message, Vec::new(), cx)); | |
| 423 | + } | |
| 424 | + } | |
| 425 | + | |
| 426 | + /// What `orchestrator_capabilities` reports: every agent that's on, with | |
| 427 | + /// its models, and what the calling thread runs. | |
| 428 | + fn capabilities(thread: &Session, cx: &App) -> Value { | |
| 429 | + let instances: Vec<Value> = Catalog::global(cx) | |
| 430 | + .instances | |
| 431 | + .iter() | |
| 432 | + .filter(|state| state.config.enabled && !matches!(state.status, ProbeStatus::Disabled)) | |
| 433 | + .map(|state| { | |
| 434 | + let models: Vec<Value> = state | |
| 435 | + .models | |
| 436 | + .iter() | |
| 437 | + .map(|model| json!({"id": model.id, "name": model.name, "detail": model.detail})) | |
| 438 | + .collect(); | |
| 439 | + json!({ | |
| 440 | + "providerInstanceId": state.config.id, | |
| 441 | + "kind": state.config.kind.key(), | |
| 442 | + "name": if state.config.name.trim().is_empty() { state.config.kind.label().to_string() } else { state.config.name.clone() }, | |
| 443 | + "models": models, | |
| 444 | + }) | |
| 445 | + }) | |
| 446 | + .collect(); | |
| 447 | + json!({ | |
| 448 | + "current": {"providerInstanceId": thread.instance, "model": thread.model, "activeModel": thread.active_model}, | |
| 449 | + "providerInstances": instances, | |
| 450 | + "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES], | |
| 451 | + }) | |
| 452 | + } | |
| @@ -114,6 +114,28 @@ impl Workspace { | ||
| 114 | 114 | .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone())) |
| 115 | 115 | }); |
| 116 | 116 | |
| 117 | + // A subagent's thread leads back to the thread that delegated its task. | |
| 118 | + let parent = active | |
| 119 | + .and_then(|session| session.delegation.as_ref()) | |
| 120 | + .and_then(|delegation| self.local_thread(&delegation.parent, cx)) | |
| 121 | + .map(|(parent_ix, parent)| { | |
| 122 | + let parent_title = parent.read(cx).title.clone(); | |
| 123 | + let tip = SharedString::from(format!("Subagent of {parent_title}")); | |
| 124 | + h_flex() | |
| 125 | + .id("parent-thread") | |
| 126 | + .flex_none() | |
| 127 | + .gap(px(6.)) | |
| 128 | + .cursor_pointer() | |
| 129 | + .text_size(px(14.)) | |
| 130 | + .text_color(p.ink3) | |
| 131 | + .hover(|style| style.text_color(p.ink)) | |
| 132 | + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) | |
| 133 | + .on_click(cx.listener(move |this, _, window, cx| this.select(parent_ix, parent.clone(), window, cx))) | |
| 134 | + .child(div().max_w(px(200.)).truncate().child(parent_title)) | |
| 135 | + .child(div().text_color(p.icon).child("/")) | |
| 136 | + .child(Icon::new(Lucide::Bot).size(px(14.))) | |
| 137 | + }); | |
| 138 | + | |
| 117 | 139 | let title_element = title.map(|title| -> AnyElement { |
| 118 | 140 | if let Some(rename) = renaming { |
| 119 | 141 | return div() |
| @@ -202,6 +224,7 @@ impl Workspace { | ||
| 202 | 224 | .when_some(breadcrumb, |this, breadcrumb| { |
| 203 | 225 | this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/")) |
| 204 | 226 | }) |
| 227 | + .when_some(parent, |this, parent| this.child(parent)) | |
| 205 | 228 | .when_some(title_element, |this, title| this.child(title)) |
| 206 | 229 | .child(div().flex_1()) |
| 207 | 230 | .child(self.render_layout_controls(panel_visible, cx)), |
| @@ -441,7 +441,7 @@ impl Workspace { | ||
| 441 | 441 | .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone()))) |
| 442 | 442 | .filter(|(_, session)| { |
| 443 | 443 | let state = session.read(cx); |
| 444 | - !state.archived && !state.is_blank() | |
| 444 | + !state.archived && !state.is_blank() && !state.is_subagent() | |
| 445 | 445 | }) |
| 446 | 446 | .collect(); |
| 447 | 447 | threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated)); |
| @@ -366,7 +366,7 @@ impl Workspace { | ||
| 366 | 366 | } |
| 367 | 367 | |
| 368 | 368 | /// The local thread with a given id. |
| 369 | - fn local_thread(&self, id: &str, cx: &App) -> Option<(usize, Entity<Session>)> { | |
| 369 | + pub(super) fn local_thread(&self, id: &str, cx: &App) -> Option<(usize, Entity<Session>)> { | |
| 370 | 370 | self.projects.iter().enumerate().filter(|(_, project)| project.environment.is_none()).find_map(|(ix, project)| { |
| 371 | 371 | project.sessions.iter().find(|session| session.read(cx).id == id).map(|session| (ix, session.clone())) |
| 372 | 372 | }) |
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | //! |
| 7 | 7 | //! Claude's Task and Agent calls are one subagent each. Pi's `subagent` |
| 8 | 8 | //! extension and jcode's swarm spawns can start several, each its own row. |
| 9 | + //! A task delegated to the app runs in a thread of its own, which a click | |
| 10 | + //! opens. | |
| 9 | 11 | |
| 10 | 12 | use std::time::Duration; |
| 11 | 13 | |
| @@ -37,6 +39,8 @@ struct Subagent { | ||
| 37 | 39 | /// What it's doing: its latest tool call while it works. |
| 38 | 40 | activity: Option<(Icon, String)>, |
| 39 | 41 | summary: Option<SharedString>, |
| 42 | + /// The thread a task delegated to the app runs in, by id. | |
| 43 | + thread: Option<String>, | |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | /// A thread's subagents, in the order they started. |
| @@ -61,6 +65,7 @@ fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> { | ||
| 61 | 65 | elapsed: entry.elapsed, |
| 62 | 66 | activity: entry.tool.map(|(name, input)| describe_call(&name, &input, &state.cwd)), |
| 63 | 67 | summary: entry.summary.map(Into::into), |
| 68 | + thread: entry.thread, | |
| 64 | 69 | }); |
| 65 | 70 | } |
| 66 | 71 | } |
| @@ -95,8 +100,20 @@ impl Workspace { | ||
| 95 | 100 | cx.notify(); |
| 96 | 101 | } |
| 97 | 102 | |
| 98 | - /// Opens a subagent's thread, if it isn't open, and shows its call. | |
| 99 | - fn reveal_subagent(&mut self, session: Entity<Session>, ix: usize, window: &mut Window, cx: &mut Context<Self>) { | |
| 103 | + /// Opens a subagent's thread, if it isn't open, and shows its call; or | |
| 104 | + /// for a task delegated to the app, the thread it runs in. | |
| 105 | + fn reveal_subagent( | |
| 106 | + &mut self, | |
| 107 | + session: Entity<Session>, | |
| 108 | + ix: usize, | |
| 109 | + thread: Option<String>, | |
| 110 | + window: &mut Window, | |
| 111 | + cx: &mut Context<Self>, | |
| 112 | + ) { | |
| 113 | + if let Some((project, child)) = thread.and_then(|thread| self.local_thread(&thread, cx)) { | |
| 114 | + self.select(project, child, window, cx); | |
| 115 | + return; | |
| 116 | + } | |
| 100 | 117 | if self.active.as_ref() != Some(&session) { |
| 101 | 118 | let Some(project) = self.project_of(&session) else { return }; |
| 102 | 119 | self.select(project, session.clone(), window, cx); |
| @@ -209,6 +226,14 @@ impl Workspace { | ||
| 209 | 226 | }; |
| 210 | 227 | let id = subagent.id; |
| 211 | 228 | let thread = other.then(|| subagent.session.read(cx).title.clone()); |
| 229 | + // A delegated task's agent can stop to ask the user something. | |
| 230 | + let waiting = running | |
| 231 | + && subagent | |
| 232 | + .thread | |
| 233 | + .as_ref() | |
| 234 | + .and_then(|thread| self.local_thread(thread, cx)) | |
| 235 | + .is_some_and(|(_, child)| child.read(cx).needs_attention()); | |
| 236 | + let status = if waiting { Some("Waiting for you") } else { status }; | |
| 212 | 237 | let meta: Vec<String> = [ |
| 213 | 238 | subagent.kind.as_ref().map(ToString::to_string), |
| 214 | 239 | (subagent.tool_uses > 0) |
| @@ -219,7 +244,7 @@ impl Workspace { | ||
| 219 | 244 | .into_iter() |
| 220 | 245 | .flatten() |
| 221 | 246 | .collect(); |
| 222 | - let (session, ix) = (subagent.session.clone(), subagent.ix); | |
| 247 | + let (session, ix, child) = (subagent.session.clone(), subagent.ix, subagent.thread.clone()); | |
| 223 | 248 | h_flex() |
| 224 | 249 | .id(("subagent", id)) |
| 225 | 250 | .items_start() |
| @@ -229,7 +254,7 @@ impl Workspace { | ||
| 229 | 254 | .rounded(px(12.)) |
| 230 | 255 | .cursor_pointer() |
| 231 | 256 | .hover(|style| style.bg(p.row_hover)) |
| 232 | - .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, window, cx))) | |
| 257 | + .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, child.clone(), window, cx))) | |
| 233 | 258 | .child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p))) |
| 234 | 259 | .child( |
| 235 | 260 | v_flex() |