Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 17 changed files with 2638 additions and 70 deletions.
| @@ -104,6 +104,31 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 104 | 104 | Sublime Text, Kate or the file manager, whichever are installed), the branch with a |
| 105 | 105 | switcher, **Commit** (asks the agent to commit, or commit and push), the size of the |
| 106 | 106 | uncommitted changes, and the thread's cost. |
| 107 | + - A right panel, as T3 Code has it: its button in the top bar (Ctrl+Alt+B) opens "Open a | |
| 108 | + surface", which lists the subagents, the browser and a terminal, keyed S, B and T. A | |
| 109 | + terminal there holds the thread's terminals instead of the drawer under the conversation. | |
| 110 | + - A subagents panel (Ctrl+Alt+S, or S in the right panel), in the project panel's | |
| 111 | + place: every subagent the thread started, the working ones first with what each is | |
| 112 | + doing right now (its latest tool call and progress summary), how long it has run, its | |
| 113 | + kind, tool uses and tokens, then the finished and failed ones. Every working one has a Stop button. Click one to jump to its call in the conversation. While the | |
| 114 | + panel is closed, a light on the right panel's button says subagents are working. It covers Claude's | |
| 115 | + Task and Agent calls; Pi's [`subagent` extension](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent) | |
| 116 | + (a row per task, single, parallel or chained); and jcode's swarm, whose agents keep | |
| 117 | + working in sessions of their own after the turn ends: the app checks on them every few | |
| 118 | + seconds through jcode's session list, and reads what each last said. | |
| 119 | + - App-owned subagents, as T3 Code has them: every agent gets `delegate_task`, | |
| 120 | + `task_status`, `task_cancel` and `orchestrator_capabilities`, with instructions for | |
| 121 | + them. A delegated task runs in a subagent thread in the parent's folder, on the | |
| 122 | + parent's agent and model or any other agent and model the app has (Claude, Pi or | |
| 123 | + jcode). The subagents panel shows it working and opens its thread. The thread is | |
| 124 | + left out of the sidebar, and its header leads back to the parent. When the task | |
| 125 | + finishes, its answer goes to the call waiting on it (`mode: "wait"`) or reaches the | |
| 126 | + parent's agent as a message the transcript doesn't show, which wakes it. Nothing to install: Claude reaches the | |
| 127 | + tools through an MCP server the app runs on loopback, with a token per thread. | |
| 128 | + Pi reaches them through an extension the app writes and loads with `--extension`; | |
| 129 | + before every run, that extension also switches off Pi's other subagent tools. | |
| 130 | + jcode gets them as session tools over its API, with its `swarm` and `schedule` | |
| 131 | + tools turned off. Subagents can't delegate tasks of their own. | |
| 107 | 132 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 108 | 133 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 109 | 134 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -252,8 +277,9 @@ The shortcuts follow T3 Code's default keymap. | ||
| 252 | 277 | | Ctrl+D / Ctrl+Shift+D | Split the terminal side by side / stacked (terminal focused) | |
| 253 | 278 | | Ctrl+N / Ctrl+W | New terminal / close the terminal (terminal focused) | |
| 254 | 279 | | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal | |
| 255 | - | Ctrl+Alt+B | Show or hide the right panel (the browser) | | |
| 280 | + | Ctrl+Alt+B | Show or hide the right panel: Open a surface | | |
| 256 | 281 | | Ctrl+Shift+B | Show or hide the thread details (project panel) | |
| 282 | + | Ctrl+Alt+S | Show or hide the subagents panel | | |
| 257 | 283 | | Ctrl+O | Add a project folder | |
| 258 | 284 | | Ctrl+, | Open or close settings | |
| 259 | 285 | | Ctrl+Q | Quit | |
| @@ -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; |
| @@ -39,6 +40,7 @@ gpui_kit::actions!( | ||
| 39 | 40 | Interrupt, |
| 40 | 41 | ToggleSidebar, |
| 41 | 42 | ToggleProjectPanel, |
| 43 | + ToggleSubagents, | |
| 42 | 44 | ToggleBrowser, |
| 43 | 45 | FocusSearch, |
| 44 | 46 | OpenSettings, |
| @@ -127,6 +129,7 @@ fn main() { | ||
| 127 | 129 | // The app's own. |
| 128 | 130 | KeyBinding::new("ctrl-o", OpenInEditor, None), |
| 129 | 131 | KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None), |
| 132 | + KeyBinding::new("ctrl-alt-s", ToggleSubagents, None), | |
| 130 | 133 | KeyBinding::new("ctrl-shift-j", ToggleBrowser, None), |
| 131 | 134 | KeyBinding::new("ctrl-,", OpenSettings, None), |
| 132 | 135 | KeyBinding::new("ctrl-q", Quit, None), |
| @@ -0,0 +1,514 @@ | ||
| 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 | + /// How the message that hands a subagent's result to its parent starts. | |
| 35 | + pub const RESULT_PREFIX: &str = "[Vectrum Code] Delegated task"; | |
| 36 | + | |
| 37 | + /// The environment that tells the Pi extension where the server is. | |
| 38 | + const URL_ENV: &str = "VECTRUM_CODE_MCP_URL"; | |
| 39 | + const TOKEN_ENV: &str = "VECTRUM_CODE_MCP_TOKEN"; | |
| 40 | + | |
| 41 | + /// What the tools are for, for agents whose instructions the app adds to: | |
| 42 | + /// the MCP server's instructions (Claude), the system prompt (Pi) and a | |
| 43 | + /// reminder with the first message (jcode). | |
| 44 | + pub const INSTRUCTIONS: &str = "\ | |
| 45 | + ## Vectrum Code subagents | |
| 46 | + | |
| 47 | + 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. | |
| 48 | + | |
| 49 | + - `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. | |
| 50 | + - 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. | |
| 51 | + - 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. | |
| 52 | + - `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. | |
| 53 | + - `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."; | |
| 54 | + | |
| 55 | + /// What the tools take, as MCP describes tools. | |
| 56 | + pub fn tool_definitions() -> Value { | |
| 57 | + json!([ | |
| 58 | + { | |
| 59 | + "name": DELEGATE, | |
| 60 | + "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).", | |
| 61 | + "inputSchema": { | |
| 62 | + "type": "object", | |
| 63 | + "properties": { | |
| 64 | + "task": {"type": "string", "description": "The complete, self-contained task for the child agent."}, | |
| 65 | + "title": {"type": "string", "description": "A short title for the subagent, shown to the user."}, | |
| 66 | + "target": { | |
| 67 | + "type": "object", | |
| 68 | + "description": "Which agent and model run the task. Omitted fields inherit this thread's.", | |
| 69 | + "properties": { | |
| 70 | + "providerInstanceId": {"type": "string", "description": "A provider instance id from orchestrator_capabilities."}, | |
| 71 | + "model": {"type": "string", "description": "A model id that instance offers."} | |
| 72 | + } | |
| 73 | + }, | |
| 74 | + "mode": {"type": "string", "enum": ["async", "wait"], "description": "async (default) returns at once; wait blocks for the result."}, | |
| 75 | + "timeoutMs": {"type": "number", "description": "How long mode 'wait' waits. The child isn't stopped when it runs out."} | |
| 76 | + }, | |
| 77 | + "required": ["task"] | |
| 78 | + }, | |
| 79 | + "annotations": {"title": "Delegate a task", "destructiveHint": true, "openWorldHint": true} | |
| 80 | + }, | |
| 81 | + { | |
| 82 | + "name": TASK_STATUS, | |
| 83 | + "description": "Where a delegated task stands: running, completed, failed or cancelled, and once it's done, its result.", | |
| 84 | + "inputSchema": { | |
| 85 | + "type": "object", | |
| 86 | + "properties": {"taskId": {"type": "string"}}, | |
| 87 | + "required": ["taskId"] | |
| 88 | + }, | |
| 89 | + "annotations": {"title": "Get a task's status", "readOnlyHint": true} | |
| 90 | + }, | |
| 91 | + { | |
| 92 | + "name": TASK_CANCEL, | |
| 93 | + "description": "Stop a delegated task that's still running. Its result so far stays readable with task_status.", | |
| 94 | + "inputSchema": { | |
| 95 | + "type": "object", | |
| 96 | + "properties": {"taskId": {"type": "string"}, "reason": {"type": "string"}}, | |
| 97 | + "required": ["taskId"] | |
| 98 | + }, | |
| 99 | + "annotations": {"title": "Cancel a task", "destructiveHint": true} | |
| 100 | + }, | |
| 101 | + { | |
| 102 | + "name": CAPABILITIES, | |
| 103 | + "description": "The agents (provider instances) and models Vectrum Code can run a delegated task with, and which ones this thread uses.", | |
| 104 | + "inputSchema": {"type": "object", "properties": {}}, | |
| 105 | + "annotations": {"title": "List agents and models", "readOnlyHint": true} | |
| 106 | + } | |
| 107 | + ]) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// Whether a tool call, by name, is one of the app's: plain for Pi and | |
| 111 | + /// jcode, prefixed for Claude (`mcp__vectrum-code__delegate_task`). | |
| 112 | + pub fn own_tool(name: &str) -> Option<&'static str> { | |
| 113 | + let bare = name.strip_prefix("mcp__vectrum-code__").or_else(|| name.strip_prefix("mcp__vectrum_code__")).unwrap_or(name); | |
| 114 | + [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES].into_iter().find(|tool| *tool == bare) | |
| 115 | + } | |
| 116 | + | |
| 117 | + /// A call from an agent, for the workspace to answer. | |
| 118 | + pub struct ToolRequest { | |
| 119 | + /// The calling thread's id. | |
| 120 | + pub thread: String, | |
| 121 | + pub tool: String, | |
| 122 | + pub args: Value, | |
| 123 | + /// Takes the answer: the result, or what went wrong. | |
| 124 | + pub reply: smol::channel::Sender<Result<Value, String>>, | |
| 125 | + } | |
| 126 | + | |
| 127 | + struct Server { | |
| 128 | + url: String, | |
| 129 | + /// Thread id by token, and token by thread id. | |
| 130 | + tokens: Mutex<(HashMap<String, String>, HashMap<String, String>)>, | |
| 131 | + } | |
| 132 | + | |
| 133 | + static LISTENER: OnceLock<Option<Server>> = OnceLock::new(); | |
| 134 | + static REQUESTS: OnceLock<(smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>)> = OnceLock::new(); | |
| 135 | + | |
| 136 | + fn channel() -> &'static (smol::channel::Sender<ToolRequest>, smol::channel::Receiver<ToolRequest>) { | |
| 137 | + REQUESTS.get_or_init(smol::channel::unbounded) | |
| 138 | + } | |
| 139 | + | |
| 140 | + /// The calls agents make, for the workspace to answer. | |
| 141 | + pub fn requests() -> smol::channel::Receiver<ToolRequest> { | |
| 142 | + channel().1.clone() | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Starts the server the first time it's needed. `None` if it can't listen. | |
| 146 | + fn server() -> Option<&'static Server> { | |
| 147 | + LISTENER | |
| 148 | + .get_or_init(|| { | |
| 149 | + let listener = TcpListener::bind(("127.0.0.1", 0)).ok()?; | |
| 150 | + let port = listener.local_addr().ok()?.port(); | |
| 151 | + thread::Builder::new() | |
| 152 | + .name("mcp-listener".into()) | |
| 153 | + .spawn(move || { | |
| 154 | + for stream in listener.incoming() { | |
| 155 | + let Ok(stream) = stream else { continue }; | |
| 156 | + let _ = thread::Builder::new().name("mcp-connection".into()).spawn(move || serve(stream)); | |
| 157 | + } | |
| 158 | + }) | |
| 159 | + .ok()?; | |
| 160 | + Some(Server { url: format!("http://127.0.0.1:{port}/mcp"), tokens: Mutex::default() }) | |
| 161 | + }) | |
| 162 | + .as_ref() | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// The server's address and the token a thread's agent calls it with. | |
| 166 | + pub fn credentials(thread: &str) -> Option<(String, String)> { | |
| 167 | + let server = server()?; | |
| 168 | + let mut tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 169 | + let token = match tokens.1.get(thread) { | |
| 170 | + Some(token) => token.clone(), | |
| 171 | + None => { | |
| 172 | + let token = crate::remote::new_key(); | |
| 173 | + tokens.0.insert(token.clone(), thread.to_string()); | |
| 174 | + tokens.1.insert(thread.to_string(), token.clone()); | |
| 175 | + token | |
| 176 | + } | |
| 177 | + }; | |
| 178 | + Some((server.url.clone(), token)) | |
| 179 | + } | |
| 180 | + | |
| 181 | + fn thread_for(token: &str) -> Option<String> { | |
| 182 | + let server = server()?; | |
| 183 | + let tokens = server.tokens.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 184 | + tokens.0.get(token).cloned() | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// Claude's `--mcp-config` for a thread. | |
| 188 | + pub fn claude_config(thread: &str) -> Option<String> { | |
| 189 | + let (url, token) = credentials(thread)?; | |
| 190 | + let config = json!({ | |
| 191 | + "mcpServers": { | |
| 192 | + SERVER: { | |
| 193 | + "type": "http", | |
| 194 | + "url": url, | |
| 195 | + "headers": {"Authorization": format!("Bearer {token}")}, | |
| 196 | + // Long enough for a `mode: "wait"` call. | |
| 197 | + "timeout": 3_900_000, | |
| 198 | + } | |
| 199 | + } | |
| 200 | + }); | |
| 201 | + Some(config.to_string()) | |
| 202 | + } | |
| 203 | + | |
| 204 | + /// Answers one connection's requests, one at a time, for as long as it stays | |
| 205 | + /// open. Plain HTTP/1.1 with JSON replies: all of MCP's streamable HTTP | |
| 206 | + /// transport a tools-only server needs. | |
| 207 | + fn serve(stream: TcpStream) { | |
| 208 | + let _ = stream.set_nodelay(true); | |
| 209 | + let Ok(mut writer) = stream.try_clone() else { return }; | |
| 210 | + let mut reader = BufReader::new(stream); | |
| 211 | + loop { | |
| 212 | + let mut request_line = String::new(); | |
| 213 | + match reader.read_line(&mut request_line) { | |
| 214 | + Ok(0) | Err(_) => return, | |
| 215 | + Ok(_) => {} | |
| 216 | + } | |
| 217 | + let method = request_line.split_whitespace().next().unwrap_or_default().to_string(); | |
| 218 | + let mut length = 0usize; | |
| 219 | + let mut token = None; | |
| 220 | + loop { | |
| 221 | + let mut line = String::new(); | |
| 222 | + if reader.read_line(&mut line).unwrap_or(0) == 0 { | |
| 223 | + return; | |
| 224 | + } | |
| 225 | + let line = line.trim_end(); | |
| 226 | + if line.is_empty() { | |
| 227 | + break; | |
| 228 | + } | |
| 229 | + let Some((name, value)) = line.split_once(':') else { continue }; | |
| 230 | + let value = value.trim(); | |
| 231 | + match name.trim().to_ascii_lowercase().as_str() { | |
| 232 | + "content-length" => length = value.parse().unwrap_or(0), | |
| 233 | + "authorization" => token = value.strip_prefix("Bearer ").map(str::to_string), | |
| 234 | + _ => {} | |
| 235 | + } | |
| 236 | + } | |
| 237 | + let mut body = vec![0; length.min(16 << 20)]; | |
| 238 | + if reader.read_exact(&mut body).is_err() { | |
| 239 | + return; | |
| 240 | + } | |
| 241 | + let (status, reply) = if method != "POST" { | |
| 242 | + // No server-sent event stream: everything's answered in the reply. | |
| 243 | + ("405 Method Not Allowed", None) | |
| 244 | + } else { | |
| 245 | + match token.as_deref().and_then(thread_for) { | |
| 246 | + None => ("401 Unauthorized", Some(json!({"error": "unknown token"}))), | |
| 247 | + Some(thread) => match serde_json::from_slice::<Value>(&body) { | |
| 248 | + Ok(message) => match handle(&thread, &message) { | |
| 249 | + Some(reply) => ("200 OK", Some(reply)), | |
| 250 | + // A notification. | |
| 251 | + None => ("202 Accepted", None), | |
| 252 | + }, | |
| 253 | + Err(err) => ("400 Bad Request", Some(json!({"error": err.to_string()}))), | |
| 254 | + }, | |
| 255 | + } | |
| 256 | + }; | |
| 257 | + let body = reply.map(|reply| reply.to_string()).unwrap_or_default(); | |
| 258 | + let head = format!( | |
| 259 | + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", | |
| 260 | + body.len() | |
| 261 | + ); | |
| 262 | + if writer.write_all(head.as_bytes()).and_then(|_| writer.write_all(body.as_bytes())).is_err() { | |
| 263 | + return; | |
| 264 | + } | |
| 265 | + let _ = writer.flush(); | |
| 266 | + } | |
| 267 | + } | |
| 268 | + | |
| 269 | + /// Answers one JSON-RPC message; `None` for a notification. | |
| 270 | + fn handle(thread: &str, message: &Value) -> Option<Value> { | |
| 271 | + let id = message.get("id").cloned()?; | |
| 272 | + let params = message.get("params").cloned().unwrap_or(Value::Null); | |
| 273 | + let result = match message.get("method").and_then(Value::as_str).unwrap_or_default() { | |
| 274 | + "initialize" => Ok(json!({ | |
| 275 | + "protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"), | |
| 276 | + "capabilities": {"tools": {"listChanged": false}}, | |
| 277 | + "serverInfo": {"name": SERVER, "version": env!("CARGO_PKG_VERSION")}, | |
| 278 | + "instructions": INSTRUCTIONS, | |
| 279 | + })), | |
| 280 | + "ping" => Ok(json!({})), | |
| 281 | + "tools/list" => Ok(json!({"tools": tool_definitions()})), | |
| 282 | + "tools/call" => { | |
| 283 | + let tool = params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); | |
| 284 | + let args = params.get("arguments").cloned().unwrap_or_else(|| json!({})); | |
| 285 | + Ok(tool_result(call(thread, tool, args))) | |
| 286 | + } | |
| 287 | + method => Err(json!({"code": -32601, "message": format!("unknown method {method}")})), | |
| 288 | + }; | |
| 289 | + Some(match result { | |
| 290 | + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), | |
| 291 | + Err(error) => json!({"jsonrpc": "2.0", "id": id, "error": error}), | |
| 292 | + }) | |
| 293 | + } | |
| 294 | + | |
| 295 | + /// Hands a call to the workspace and waits for its answer. | |
| 296 | + fn call(thread: &str, tool: String, args: Value) -> Result<Value, String> { | |
| 297 | + let (reply, answer) = smol::channel::bounded(1); | |
| 298 | + let request = ToolRequest { thread: thread.to_string(), tool, args, reply }; | |
| 299 | + channel().0.send_blocking(request).map_err(|_| "Vectrum Code is shutting down.".to_string())?; | |
| 300 | + answer.recv_blocking().unwrap_or_else(|_| Err("Vectrum Code dropped the call.".into())) | |
| 301 | + } | |
| 302 | + | |
| 303 | + /// An answer as an MCP tool result: the data as text for the model, and | |
| 304 | + /// structured for scripts. | |
| 305 | + pub fn tool_result(outcome: Result<Value, String>) -> Value { | |
| 306 | + match outcome { | |
| 307 | + Ok(value) => json!({ | |
| 308 | + "content": [{"type": "text", "text": serde_json::to_string_pretty(&value).unwrap_or_default()}], | |
| 309 | + "structuredContent": value, | |
| 310 | + }), | |
| 311 | + Err(error) => json!({"content": [{"type": "text", "text": error}], "isError": true}), | |
| 312 | + } | |
| 313 | + } | |
| 314 | + | |
| 315 | + /// The Pi extension: it bridges the app's tools into Pi (when the thread | |
| 316 | + /// may delegate), adds the instructions to the system prompt, and switches | |
| 317 | + /// off other subagent tools before every run. | |
| 318 | + const PI_EXTENSION: &str = r#"// Written by Vectrum Code, which loads it into the Pi agents it runs. | |
| 319 | + // Changes are overwritten. | |
| 320 | + const URL = process.env.__URL_ENV__; | |
| 321 | + const TOKEN = process.env.__TOKEN_ENV__; | |
| 322 | + const TOOLS = __TOOLS__; | |
| 323 | + const INSTRUCTIONS = __INSTRUCTIONS__; | |
| 324 | + const OWN = new Set(TOOLS.map((tool) => tool.name)); | |
| 325 | + // Tools that start agents of their own: Pi's subagent extension and the like. | |
| 326 | + const OTHER_SUBAGENTS = /subagent|^(task|tasks|agent|agents|swarm|spawn_agent|spawn_agents|delegate)$/i; | |
| 327 | + | |
| 328 | + async function schema(inputSchema) { | |
| 329 | + for (const name of ["typebox", "@sinclair/typebox"]) { | |
| 330 | + try { | |
| 331 | + const { Type } = await import(name); | |
| 332 | + if (typeof Type?.Unsafe === "function") return Type.Unsafe(inputSchema); | |
| 333 | + } catch {} | |
| 334 | + } | |
| 335 | + return inputSchema; | |
| 336 | + } | |
| 337 | + | |
| 338 | + let nextId = 1; | |
| 339 | + async function callTool(name, args, signal) { | |
| 340 | + const response = await fetch(URL, { | |
| 341 | + method: "POST", | |
| 342 | + headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${TOKEN}` }, | |
| 343 | + body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method: "tools/call", params: { name, arguments: args ?? {} } }), | |
| 344 | + signal, | |
| 345 | + }); | |
| 346 | + const body = await response.text(); | |
| 347 | + if (!response.ok) throw new Error(`Vectrum Code answered ${response.status}: ${body.slice(0, 400)}`); | |
| 348 | + const message = JSON.parse(body); | |
| 349 | + if (message.error) throw new Error(message.error.message ?? "Vectrum Code returned an error"); | |
| 350 | + return message.result; | |
| 351 | + } | |
| 352 | + | |
| 353 | + export default async function vectrumCode(pi) { | |
| 354 | + // Subagents go through the app, so other subagent tools stay off. Checked | |
| 355 | + // before every run, since extensions can turn tools back on. | |
| 356 | + const switchOffOthers = () => { | |
| 357 | + if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return; | |
| 358 | + const active = pi.getActiveTools(); | |
| 359 | + const kept = active.filter((name) => OWN.has(name) || !OTHER_SUBAGENTS.test(name)); | |
| 360 | + if (kept.length !== active.length) pi.setActiveTools(kept); | |
| 361 | + }; | |
| 362 | + pi.on("session_start", switchOffOthers); | |
| 363 | + pi.on("before_agent_start", (event) => { | |
| 364 | + switchOffOthers(); | |
| 365 | + if (URL && TOKEN) return { systemPrompt: event.systemPrompt + "\n\n" + INSTRUCTIONS }; | |
| 366 | + }); | |
| 367 | + if (!URL || !TOKEN) return; | |
| 368 | + for (const tool of TOOLS) { | |
| 369 | + pi.registerTool({ | |
| 370 | + name: tool.name, | |
| 371 | + label: tool.annotations?.title ?? tool.name, | |
| 372 | + description: tool.description, | |
| 373 | + parameters: await schema(tool.inputSchema), | |
| 374 | + async execute(_toolCallId, params, signal) { | |
| 375 | + const result = await callTool(tool.name, params, signal); | |
| 376 | + const text = (result?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text).join("\n"); | |
| 377 | + if (result?.isError) throw new Error(text || `${tool.name} failed`); | |
| 378 | + return { content: [{ type: "text", text }], details: result?.structuredContent }; | |
| 379 | + }, | |
| 380 | + }); | |
| 381 | + } | |
| 382 | + } | |
| 383 | + "#; | |
| 384 | + | |
| 385 | + /// Writes the Pi extension where Pi can load it, and returns its path. | |
| 386 | + fn pi_extension() -> Option<PathBuf> { | |
| 387 | + static PATH: OnceLock<Option<PathBuf>> = OnceLock::new(); | |
| 388 | + PATH.get_or_init(|| { | |
| 389 | + let source = PI_EXTENSION | |
| 390 | + .replace("__URL_ENV__", URL_ENV) | |
| 391 | + .replace("__TOKEN_ENV__", TOKEN_ENV) | |
| 392 | + .replace("__TOOLS__", &tool_definitions().to_string()) | |
| 393 | + .replace("__INSTRUCTIONS__", &Value::String(INSTRUCTIONS.into()).to_string()); | |
| 394 | + let path = crate::settings::data_dir()?.join("pi").join("vectrum-code.js"); | |
| 395 | + std::fs::create_dir_all(path.parent()?).ok()?; | |
| 396 | + if std::fs::read_to_string(&path).ok().as_deref() != Some(source.as_str()) { | |
| 397 | + std::fs::write(&path, source).ok()?; | |
| 398 | + } | |
| 399 | + Some(path) | |
| 400 | + }) | |
| 401 | + .clone() | |
| 402 | + } | |
| 403 | + | |
| 404 | + /// Loads the extension into a Pi command. `thread` gets the tools; without | |
| 405 | + /// it (a subagent's own thread) Pi only has its other subagent tools turned off. | |
| 406 | + pub fn prepare_pi(command: &mut std::process::Command, thread: Option<&str>) { | |
| 407 | + let Some(extension) = pi_extension() else { return }; | |
| 408 | + command.arg("--extension").arg(extension); | |
| 409 | + if let Some((url, token)) = thread.and_then(credentials) { | |
| 410 | + command.env(URL_ENV, url).env(TOKEN_ENV, token); | |
| 411 | + } else { | |
| 412 | + command.env_remove(URL_ENV).env_remove(TOKEN_ENV); | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + /// jcode's session tool configuration: the app's tools (when the thread may | |
| 417 | + /// delegate) and its own agent-spawning tools off. | |
| 418 | + pub fn jcode_tools(delegate: bool) -> Value { | |
| 419 | + let custom: Vec<Value> = if delegate { | |
| 420 | + tool_definitions() | |
| 421 | + .as_array() | |
| 422 | + .into_iter() | |
| 423 | + .flatten() | |
| 424 | + .map(|tool| json!({"name": tool["name"], "description": tool["description"], "parameters": tool["inputSchema"]})) | |
| 425 | + .collect() | |
| 426 | + } else { | |
| 427 | + Vec::new() | |
| 428 | + }; | |
| 429 | + // `schedule` can spawn a session too. | |
| 430 | + json!({"disabled": ["swarm", "schedule"], "custom": custom}) | |
| 431 | + } | |
| 432 | + | |
| 433 | + /// How long a call's `mode: "wait"` may block before the agent's own client | |
| 434 | + /// gives up on it: jcode allows a session tool two minutes, and Node's | |
| 435 | + /// fetch (Pi) waits five for a reply's headers. | |
| 436 | + pub fn wait_limit(provider: crate::models::Provider) -> Duration { | |
| 437 | + match provider { | |
| 438 | + crate::models::Provider::Claude => Duration::from_secs(30 * 60), | |
| 439 | + crate::models::Provider::Pi => Duration::from_secs(270), | |
| 440 | + crate::models::Provider::Jcode => Duration::from_secs(100), | |
| 441 | + } | |
| 442 | + } | |
| 443 | + | |
| 444 | + #[cfg(test)] | |
| 445 | + mod tests { | |
| 446 | + use serde_json::{Value, json}; | |
| 447 | + | |
| 448 | + use super::{DELEGATE, credentials, own_tool, requests}; | |
| 449 | + | |
| 450 | + fn post(url: &str, token: &str, body: Value) -> (u16, String) { | |
| 451 | + let response = ureq::post(url).set("Authorization", &format!("Bearer {token}")).set("Content-Type", "application/json").send_string(&body.to_string()); | |
| 452 | + match response { | |
| 453 | + Ok(response) => (response.status(), response.into_string().unwrap_or_default()), | |
| 454 | + Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()), | |
| 455 | + Err(err) => panic!("{err}"), | |
| 456 | + } | |
| 457 | + } | |
| 458 | + | |
| 459 | + #[::core::prelude::v1::test] | |
| 460 | + fn serves_mcp_to_the_threads_it_knows() { | |
| 461 | + let (url, token) = credentials("thread-a").expect("listens"); | |
| 462 | + assert_eq!(credentials("thread-a").unwrap().1, token, "a thread keeps its token"); | |
| 463 | + assert_ne!(credentials("thread-b").unwrap().1, token); | |
| 464 | + | |
| 465 | + let (status, _) = post(&url, "nope", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})); | |
| 466 | + assert_eq!(status, 401); | |
| 467 | + | |
| 468 | + let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26"}})); | |
| 469 | + assert_eq!(status, 200); | |
| 470 | + let reply: Value = serde_json::from_str(&body).unwrap(); | |
| 471 | + assert_eq!(reply["result"]["protocolVersion"], "2025-03-26"); | |
| 472 | + assert!(reply["result"]["instructions"].as_str().unwrap().contains("delegate_task")); | |
| 473 | + | |
| 474 | + let (status, body) = post(&url, &token, json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); | |
| 475 | + assert_eq!((status, body.as_str()), (202, "")); | |
| 476 | + | |
| 477 | + let (_, body) = post(&url, &token, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})); | |
| 478 | + let reply: Value = serde_json::from_str(&body).unwrap(); | |
| 479 | + assert_eq!(reply["result"]["tools"][0]["name"], DELEGATE); | |
| 480 | + | |
| 481 | + // The workspace's side, answering with who called and what. | |
| 482 | + let incoming = requests(); | |
| 483 | + std::thread::spawn(move || { | |
| 484 | + while let Ok(request) = incoming.recv_blocking() { | |
| 485 | + let outcome = match request.tool.as_str() { | |
| 486 | + "task_status" => Ok(json!({"thread": request.thread, "args": request.args})), | |
| 487 | + _ => Err("no such task".to_string()), | |
| 488 | + }; | |
| 489 | + let _ = request.reply.send_blocking(outcome); | |
| 490 | + } | |
| 491 | + }); | |
| 492 | + let call = |name: &str| { | |
| 493 | + let body = json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": name, "arguments": {"taskId": "t1"}}}); | |
| 494 | + let (_, body) = post(&url, &token, body); | |
| 495 | + serde_json::from_str::<Value>(&body).unwrap()["result"].clone() | |
| 496 | + }; | |
| 497 | + let result = call("task_status"); | |
| 498 | + assert_eq!(result["structuredContent"], json!({"thread": "thread-a", "args": {"taskId": "t1"}})); | |
| 499 | + assert!(result.get("isError").is_none()); | |
| 500 | + let result = call("task_cancel"); | |
| 501 | + assert_eq!(result["isError"], true); | |
| 502 | + assert_eq!(result["content"][0]["text"], "no such task"); | |
| 503 | + } | |
| 504 | + | |
| 505 | + #[::core::prelude::v1::test] | |
| 506 | + fn recognizes_its_tools_under_any_prefix() { | |
| 507 | + assert_eq!(own_tool("delegate_task"), Some(DELEGATE)); | |
| 508 | + assert_eq!(own_tool("mcp__vectrum-code__delegate_task"), Some(DELEGATE)); | |
| 509 | + assert_eq!(own_tool("mcp__vectrum_code__task_status"), Some("task_status")); | |
| 510 | + assert_eq!(own_tool("mcp__other__delegate_task"), None); | |
| 511 | + assert_eq!(own_tool("Task"), None); | |
| 512 | + } | |
| 513 | + } | |
| 514 | + | |
| @@ -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}; |
| @@ -108,6 +109,324 @@ pub struct ToolCall { | ||
| 108 | 109 | pub task_id: Option<String>, |
| 109 | 110 | /// Set once the tool runs as a background task. |
| 110 | 111 | pub background: Option<TaskStatus>, |
| 112 | + /// What a subagent (Task or Agent tool) is up to; `None` for other tools. | |
| 113 | + pub subagent: Option<SubagentProgress>, | |
| 114 | + } | |
| 115 | + | |
| 116 | + impl ToolCall { | |
| 117 | + /// The subagents this call started, as the subagents panel lists them: | |
| 118 | + /// one for Claude's Task or Agent call, one per task for Pi's `subagent` | |
| 119 | + /// extension, and the agents a jcode `swarm` spawn started. | |
| 120 | + pub fn subagents(&self) -> Vec<SubagentEntry> { | |
| 121 | + let Some(progress) = &self.subagent else { return Vec::new() }; | |
| 122 | + let call_running = self.state == ToolState::Running; | |
| 123 | + if !progress.members.is_empty() { | |
| 124 | + return progress | |
| 125 | + .members | |
| 126 | + .iter() | |
| 127 | + .map(|member| { | |
| 128 | + let mut status = TaskStatus::from_cli(&member.status); | |
| 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() { | |
| 132 | + status = TaskStatus::Stopped; | |
| 133 | + } | |
| 134 | + let running = status == TaskStatus::Running; | |
| 135 | + SubagentEntry { | |
| 136 | + label: member.label.clone(), | |
| 137 | + kind: member.kind.clone(), | |
| 138 | + status, | |
| 139 | + background: false, | |
| 140 | + tool_uses: member.tool_uses, | |
| 141 | + tokens: member.tokens, | |
| 142 | + elapsed: elapsed_between(member.started, member.finished, running), | |
| 143 | + tool: member.tool.clone().filter(|_| running), | |
| 144 | + summary: member.summary.clone(), | |
| 145 | + thread: self.is_delegation().then(|| member.id.clone()), | |
| 146 | + stop: running.then(|| { | |
| 147 | + if self.is_delegation() { | |
| 148 | + SubagentStop::Thread(member.id.clone()) | |
| 149 | + } else if self.name == SWARM_TOOL { | |
| 150 | + SubagentStop::SwarmAgent(member.id.clone()) | |
| 151 | + } else { | |
| 152 | + SubagentStop::Turn | |
| 153 | + } | |
| 154 | + }), | |
| 155 | + } | |
| 156 | + }) | |
| 157 | + .collect(); | |
| 158 | + } | |
| 159 | + // Only a swarm call that spawns an agent starts one. | |
| 160 | + if self.name == SWARM_TOOL && tool_field(&self.input, &["action"]).as_deref() != Some("spawn") { | |
| 161 | + return Vec::new(); | |
| 162 | + } | |
| 163 | + let status = match (self.background, self.state) { | |
| 164 | + (Some(status), ToolState::Running | ToolState::Done) => status, | |
| 165 | + (_, ToolState::Error) => TaskStatus::Failed, | |
| 166 | + (None, ToolState::Running) => TaskStatus::Running, | |
| 167 | + (None, ToolState::Done) => TaskStatus::Completed, | |
| 168 | + }; | |
| 169 | + let running = status == TaskStatus::Running; | |
| 170 | + let label = tool_field(&self.input, &["description", "label", "title", "task"]) | |
| 171 | + .filter(|label| !label.trim().is_empty()) | |
| 172 | + .or_else(|| { | |
| 173 | + let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len(); | |
| 174 | + Some(format!("{tasks} subagents")) | |
| 175 | + }) | |
| 176 | + .unwrap_or_else(|| "Subagent".into()); | |
| 177 | + vec![SubagentEntry { | |
| 178 | + label, | |
| 179 | + kind: tool_field(&self.input, &["subagent_type", "agent"]), | |
| 180 | + status, | |
| 181 | + background: self.background.is_some(), | |
| 182 | + tool_uses: self.subagent_tool_count, | |
| 183 | + tokens: progress.tokens, | |
| 184 | + elapsed: elapsed_between(progress.started, progress.finished, running), | |
| 185 | + tool: progress.tool.clone().filter(|_| running), | |
| 186 | + summary: progress.summary.clone(), | |
| 187 | + thread: None, | |
| 188 | + // Claude's subagents are tasks; a delegation that isn't tied to | |
| 189 | + // its thread yet has nothing to stop. | |
| 190 | + stop: self.task_id.clone().filter(|_| running).map(SubagentStop::Task), | |
| 191 | + }] | |
| 192 | + } | |
| 193 | + | |
| 194 | + /// A call to the app's `delegate_task`, whose task runs in a thread of its own. | |
| 195 | + pub fn is_delegation(&self) -> bool { | |
| 196 | + orchestrator::own_tool(&self.name) == Some(orchestrator::DELEGATE) | |
| 197 | + } | |
| 198 | + | |
| 199 | + /// Whether the agents it starts carry on after the call returns. | |
| 200 | + fn outlives_call(&self) -> bool { | |
| 201 | + self.name == SWARM_TOOL || self.is_delegation() | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + /// A subagent as the subagents panel shows it. | |
| 206 | + pub struct SubagentEntry { | |
| 207 | + /// What it was asked to do. | |
| 208 | + pub label: String, | |
| 209 | + /// The kind of agent, e.g. `Explore`. | |
| 210 | + pub kind: Option<String>, | |
| 211 | + pub status: TaskStatus, | |
| 212 | + pub background: bool, | |
| 213 | + pub tool_uses: usize, | |
| 214 | + pub tokens: Option<u64>, | |
| 215 | + pub elapsed: Option<Duration>, | |
| 216 | + /// Its latest tool call, while it works. | |
| 217 | + pub tool: Option<(String, Value)>, | |
| 218 | + pub summary: Option<String>, | |
| 219 | + /// The thread it runs in, for a task delegated to the app. | |
| 220 | + pub thread: Option<String>, | |
| 221 | + /// How to stop it, while it runs. | |
| 222 | + pub stop: Option<SubagentStop>, | |
| 223 | + } | |
| 224 | + | |
| 225 | + /// How the subagents panel stops a subagent. | |
| 226 | + #[derive(Clone, Debug, PartialEq)] | |
| 227 | + pub enum SubagentStop { | |
| 228 | + /// Claude's background-task control, by task id. | |
| 229 | + Task(String), | |
| 230 | + /// A task delegated to the app: its thread, by id. | |
| 231 | + Thread(String), | |
| 232 | + /// A jcode swarm agent, by session id. | |
| 233 | + SwarmAgent(String), | |
| 234 | + /// Nothing smaller than the whole turn can stop it (Pi's `subagent` | |
| 235 | + /// extension runs its tasks inside one tool call). | |
| 236 | + Turn, | |
| 237 | + } | |
| 238 | + | |
| 239 | + /// jcode's tool for running a swarm of agents, each in a session of its own. | |
| 240 | + const SWARM_TOOL: &str = "swarm"; | |
| 241 | + /// How often the app checks on a thread's working swarm agents. | |
| 242 | + const SWARM_POLL: Duration = Duration::from_secs(3); | |
| 243 | + /// The jcode requests that check on swarm agents, by what they're for. | |
| 244 | + const LIST_AGENTS: &str = "list the swarm's agents"; | |
| 245 | + const PEEK_AGENT: &str = "read a swarm agent's progress"; | |
| 246 | + /// The jcode request that registers the app's tools. | |
| 247 | + const CONFIGURE_TOOLS: &str = "set up the app's tools"; | |
| 248 | + | |
| 249 | + /// One agent among several a call started: a task of Pi's `subagent` call, | |
| 250 | + /// or an agent a jcode `swarm` call spawned. | |
| 251 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 252 | + pub struct SubagentMember { | |
| 253 | + /// The task's place in a Pi call, or the jcode agent's session id. | |
| 254 | + pub id: String, | |
| 255 | + pub label: String, | |
| 256 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 257 | + pub kind: Option<String>, | |
| 258 | + /// `running`, `completed`, `failed` or `stopped`. | |
| 259 | + pub status: String, | |
| 260 | + pub started: u64, | |
| 261 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 262 | + pub finished: Option<u64>, | |
| 263 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 264 | + pub tokens: Option<u64>, | |
| 265 | + #[serde(default)] | |
| 266 | + pub tool_uses: usize, | |
| 267 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 268 | + pub tool: Option<(String, Value)>, | |
| 269 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 270 | + pub summary: Option<String>, | |
| 271 | + } | |
| 272 | + | |
| 273 | + impl SubagentMember { | |
| 274 | + /// Moves it to `status`, noting when it stopped running (or started | |
| 275 | + /// again: a swarm agent can be handed more work). | |
| 276 | + fn set_status(&mut self, status: TaskStatus) { | |
| 277 | + self.status = status.key().to_string(); | |
| 278 | + if status == TaskStatus::Running { | |
| 279 | + self.finished = None; | |
| 280 | + } else { | |
| 281 | + self.finished.get_or_insert_with(|| to_millis(SystemTime::now())); | |
| 282 | + } | |
| 283 | + } | |
| 284 | + } | |
| 285 | + | |
| 286 | + /// How long something ran from `started` to `finished` (or now, while it | |
| 287 | + /// runs), in milliseconds since the epoch; `None` when that isn't known. | |
| 288 | + fn elapsed_between(started: u64, finished: Option<u64>, running: bool) -> Option<Duration> { | |
| 289 | + let end = match finished { | |
| 290 | + Some(finished) => finished, | |
| 291 | + None if running => to_millis(SystemTime::now()), | |
| 292 | + None => return None, | |
| 293 | + }; | |
| 294 | + (started > 0).then(|| Duration::from_millis(end.saturating_sub(started))) | |
| 295 | + } | |
| 296 | + | |
| 297 | + /// A subagent's progress, for the subagents panel. It's saved with the | |
| 298 | + /// thread, so it reaches remote clients too. | |
| 299 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 300 | + pub struct SubagentProgress { | |
| 301 | + /// When it started and finished, in milliseconds since the epoch. | |
| 302 | + pub started: u64, | |
| 303 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 304 | + pub finished: Option<u64>, | |
| 305 | + /// The tokens it has used, as the CLI reports them. | |
| 306 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 307 | + pub tokens: Option<u64>, | |
| 308 | + /// Its latest tool call: the tool and the input's short text fields. | |
| 309 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 310 | + pub tool: Option<(String, Value)>, | |
| 311 | + /// The CLI's running summary of what it's doing. | |
| 312 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 313 | + pub summary: Option<String>, | |
| 314 | + /// The agents it started, when it starts several or they live apart | |
| 315 | + /// from it (Pi's `subagent` tasks, jcode's swarm agents). | |
| 316 | + #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 317 | + pub members: Vec<SubagentMember>, | |
| 318 | + } | |
| 319 | + | |
| 320 | + impl SubagentProgress { | |
| 321 | + fn finish(&mut self) { | |
| 322 | + self.finished.get_or_insert_with(|| to_millis(SystemTime::now())); | |
| 323 | + } | |
| 324 | + } | |
| 325 | + | |
| 326 | + /// Whether a tool starts subagents: Claude's Task and Agent, Pi's | |
| 327 | + /// `subagent` extension (and jcode's old tool of that name), and jcode's | |
| 328 | + /// swarm, whose spawns do. | |
| 329 | + pub fn is_subagent_tool(name: &str) -> bool { | |
| 330 | + matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) || orchestrator::own_tool(name) == Some(orchestrator::DELEGATE) | |
| 331 | + } | |
| 332 | + | |
| 333 | + /// The tool call a model's message ends on, from Pi's or jcode's message | |
| 334 | + /// content: its name and its input's short text fields. | |
| 335 | + fn last_tool_call(content: &Value) -> Option<(String, Value)> { | |
| 336 | + content.as_array()?.iter().rev().find_map(|block| { | |
| 337 | + (block.get("type").and_then(Value::as_str) == Some("toolCall")).then(|| { | |
| 338 | + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool").to_string(); | |
| 339 | + (name, brief_input(&block["arguments"])) | |
| 340 | + }) | |
| 341 | + }) | |
| 342 | + } | |
| 343 | + | |
| 344 | + /// Folds the results Pi's `subagent` extension reports (one per task, in | |
| 345 | + /// `details.results`) into the call's members. | |
| 346 | + fn pi_subagent_results(tool: &mut ToolCall, details: Option<&Value>) { | |
| 347 | + let (Some(progress), Some(results)) = | |
| 348 | + (tool.subagent.as_mut(), details.and_then(|details| details.get("results")).and_then(Value::as_array)) | |
| 349 | + else { | |
| 350 | + return; | |
| 351 | + }; | |
| 352 | + let now = to_millis(SystemTime::now()); | |
| 353 | + for (ix, result) in results.iter().enumerate() { | |
| 354 | + let id = ix.to_string(); | |
| 355 | + if !progress.members.iter().any(|member| member.id == id) { | |
| 356 | + progress.members.push(SubagentMember { id: id.clone(), started: now, ..Default::default() }); | |
| 357 | + } | |
| 358 | + let Some(member) = progress.members.iter_mut().find(|member| member.id == id) else { continue }; | |
| 359 | + let text = |key: &str| result.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()); | |
| 360 | + member.label = text("task") | |
| 361 | + .and_then(|task| task.lines().map(str::trim).find(|line| !line.is_empty())) | |
| 362 | + .map(|line| line.chars().take(200).collect()) | |
| 363 | + .unwrap_or_else(|| "Subagent".into()); | |
| 364 | + member.kind = text("agent").map(str::to_string); | |
| 365 | + let messages: Vec<&Value> = result.get("messages").and_then(Value::as_array).into_iter().flatten().collect(); | |
| 366 | + let content = |message: &&Value| message.get("content").cloned().unwrap_or(Value::Null); | |
| 367 | + member.tool_uses = messages | |
| 368 | + .iter() | |
| 369 | + .filter_map(|message| message.get("content")?.as_array()) | |
| 370 | + .flatten() | |
| 371 | + .filter(|block| block.get("type").and_then(Value::as_str) == Some("toolCall")) | |
| 372 | + .count(); | |
| 373 | + member.tool = messages.iter().rev().find_map(|message| last_tool_call(&content(message))); | |
| 374 | + member.summary = messages | |
| 375 | + .iter() | |
| 376 | + .rev() | |
| 377 | + .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) | |
| 378 | + .find_map(|message| last_line(&tool_result_text(&content(message)))); | |
| 379 | + let usage = &result["usage"]; | |
| 380 | + let tokens: u64 = ["input", "output", "cacheRead", "cacheWrite"] | |
| 381 | + .iter() | |
| 382 | + .filter_map(|key| usage.get(*key).and_then(Value::as_u64)) | |
| 383 | + .sum(); | |
| 384 | + member.tokens = (tokens > 0).then_some(tokens); | |
| 385 | + let exit = result.get("exitCode").and_then(Value::as_i64).unwrap_or(-1); | |
| 386 | + let status = match text("stopReason") { | |
| 387 | + _ if exit < 0 => TaskStatus::Running, | |
| 388 | + Some("aborted") => TaskStatus::Stopped, | |
| 389 | + Some("error") => TaskStatus::Failed, | |
| 390 | + _ if exit != 0 => TaskStatus::Failed, | |
| 391 | + _ => TaskStatus::Completed, | |
| 392 | + }; | |
| 393 | + member.set_status(status); | |
| 394 | + } | |
| 395 | + } | |
| 396 | + | |
| 397 | + /// A swarm agent's state from jcode's session list: its swarm lifecycle | |
| 398 | + /// status, or failing that whether its session is mid-turn. `ready` is an | |
| 399 | + /// agent done with its work and waiting for more. | |
| 400 | + fn jcode_agent_status(swarm: Option<&str>, session: Option<&str>) -> TaskStatus { | |
| 401 | + match swarm { | |
| 402 | + Some("completed" | "done" | "ready") => TaskStatus::Completed, | |
| 403 | + Some("failed" | "crashed") => TaskStatus::Failed, | |
| 404 | + Some("stopped" | "closed" | "disconnected") => TaskStatus::Stopped, | |
| 405 | + Some(_) => TaskStatus::Running, | |
| 406 | + None if session == Some("running") => TaskStatus::Running, | |
| 407 | + None => TaskStatus::Completed, | |
| 408 | + } | |
| 409 | + } | |
| 410 | + | |
| 411 | + /// The last line of what an agent said, for its summary. | |
| 412 | + fn last_line(text: &str) -> Option<String> { | |
| 413 | + let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?; | |
| 414 | + Some(line.chars().take(200).collect()) | |
| 415 | + } | |
| 416 | + | |
| 417 | + /// A tool input cut down to its short text fields: enough to say what the | |
| 418 | + /// call does, without a file's contents. | |
| 419 | + pub(crate) fn brief_input(input: &Value) -> Value { | |
| 420 | + let Some(fields) = input.as_object() else { return Value::Null }; | |
| 421 | + fields | |
| 422 | + .iter() | |
| 423 | + .filter_map(|(key, value)| { | |
| 424 | + let text = value.as_str()?; | |
| 425 | + let first = text.lines().next().unwrap_or_default(); | |
| 426 | + Some((key.clone(), Value::String(first.chars().take(200).collect()))) | |
| 427 | + }) | |
| 428 | + .collect::<serde_json::Map<_, _>>() | |
| 429 | + .into() | |
| 111 | 430 | } |
| 112 | 431 | |
| 113 | 432 | /// Where a background task stands. |
| @@ -120,7 +439,7 @@ pub enum TaskStatus { | ||
| 120 | 439 | } |
| 121 | 440 | |
| 122 | 441 | impl TaskStatus { |
| 123 | - fn key(self) -> &'static str { | |
| 442 | + pub fn key(self) -> &'static str { | |
| 124 | 443 | match self { |
| 125 | 444 | TaskStatus::Running => "running", |
| 126 | 445 | TaskStatus::Completed => "completed", |
| @@ -360,6 +679,29 @@ pub struct RemoteLink { | ||
| 360 | 679 | pub enum SessionEvent { |
| 361 | 680 | /// A finished turn's changes on disk are known, by its `TurnEnd` index. |
| 362 | 681 | TurnChanges(usize), |
| 682 | + /// jcode called one of the app's tools, to be answered with | |
| 683 | + /// [`Session::answer_app_tool`]. | |
| 684 | + AppTool { call_id: String, tool: String, input: Value }, | |
| 685 | + } | |
| 686 | + | |
| 687 | + /// Where a subagent's thread starts: its parent thread's id, folder and branch. | |
| 688 | + pub struct SubagentParent { | |
| 689 | + pub id: String, | |
| 690 | + pub cwd: PathBuf, | |
| 691 | + pub branch: Option<String>, | |
| 692 | + } | |
| 693 | + | |
| 694 | + /// What makes a thread a subagent: the thread that delegated its task. The | |
| 695 | + /// task goes by the subagent thread's id. | |
| 696 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 697 | + pub struct Delegation { | |
| 698 | + pub parent: String, | |
| 699 | + /// Its result reached the parent, in a reply or a message. | |
| 700 | + #[serde(default)] | |
| 701 | + pub delivered: bool, | |
| 702 | + /// Stopped by `task_cancel` or by the user. | |
| 703 | + #[serde(default)] | |
| 704 | + pub cancelled: bool, | |
| 363 | 705 | } |
| 364 | 706 | |
| 365 | 707 | /// A message waiting for the running turn to end. |
| @@ -472,6 +814,13 @@ struct JcodeLink { | ||
| 472 | 814 | provider: Option<String>, |
| 473 | 815 | /// The browser panel's instructions, sent along with the next message. |
| 474 | 816 | browser_note: Option<String>, |
| 817 | + /// Checks on the swarm agents the thread spawned, while any work. | |
| 818 | + agent_poll: Option<Task<()>>, | |
| 819 | + /// The app's tools are to be registered again once the turn ends: | |
| 820 | + /// jcode turns down tool changes mid-turn. | |
| 821 | + tools_pending: bool, | |
| 822 | + /// The app's instructions went out with a message. | |
| 823 | + instructions_sent: bool, | |
| 475 | 824 | } |
| 476 | 825 | |
| 477 | 826 | pub struct Session { |
| @@ -496,6 +845,8 @@ pub struct Session { | ||
| 496 | 845 | pub branch: Option<String>, |
| 497 | 846 | /// Set for a thread mirrored from another device. |
| 498 | 847 | pub remote: Option<RemoteLink>, |
| 848 | + /// Set for a subagent's thread: a task another thread delegated. | |
| 849 | + pub delegation: Option<Delegation>, | |
| 499 | 850 | pub items: Vec<ChatItem>, |
| 500 | 851 | pub status: SessionStatus, |
| 501 | 852 | pub thinking: bool, |
| @@ -619,6 +970,41 @@ impl Session { | ||
| 619 | 970 | session |
| 620 | 971 | } |
| 621 | 972 | |
| 973 | + /// A subagent's thread: it runs a task `parent` delegated, in the | |
| 974 | + /// parent's working folder, and starts on the task right away. | |
| 975 | + pub fn new_subagent(new: NewSession, parent: &SubagentParent, title: Option<String>, task: String, cx: &mut Context<Self>) -> Self { | |
| 976 | + let now = SystemTime::now(); | |
| 977 | + let id = format!( | |
| 978 | + "{:x}-{:x}-{}", | |
| 979 | + now.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(), | |
| 980 | + std::process::id(), | |
| 981 | + NEXT_SESSION.load(Ordering::Relaxed) | |
| 982 | + ); | |
| 983 | + let mut session = Self::empty(id, new, now); | |
| 984 | + session.cwd = parent.cwd.clone(); | |
| 985 | + session.branch = parent.branch.clone(); | |
| 986 | + session.delegation = Some(Delegation { parent: parent.id.clone(), delivered: false, cancelled: false }); | |
| 987 | + if let Some(title) = title.filter(|title| !title.trim().is_empty()) { | |
| 988 | + session.title = title.trim().to_string().into(); | |
| 989 | + session.title_custom = true; | |
| 990 | + } | |
| 991 | + session.send_message(task, Vec::new(), cx); | |
| 992 | + session | |
| 993 | + } | |
| 994 | + | |
| 995 | + /// A subagent's thread, which the sidebar leaves out. | |
| 996 | + pub fn is_subagent(&self) -> bool { | |
| 997 | + self.delegation.is_some() | |
| 998 | + } | |
| 999 | + | |
| 1000 | + /// What the thread's agent last said: the end of its latest answer. | |
| 1001 | + pub fn last_answer(&self) -> Option<String> { | |
| 1002 | + self.items.iter().rev().find_map(|item| match item { | |
| 1003 | + ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(answer.text.clone()), | |
| 1004 | + _ => None, | |
| 1005 | + }) | |
| 1006 | + } | |
| 1007 | + | |
| 622 | 1008 | fn empty(id: String, new: NewSession, now: SystemTime) -> Self { |
| 623 | 1009 | let snapshot_index = std::env::temp_dir().join(format!( |
| 624 | 1010 | "vectrum-code-{}-{}.index", |
| @@ -639,6 +1025,7 @@ impl Session { | ||
| 639 | 1025 | pull_request: None, |
| 640 | 1026 | branch: None, |
| 641 | 1027 | remote: None, |
| 1028 | + delegation: None, | |
| 642 | 1029 | items: Vec::new(), |
| 643 | 1030 | status: SessionStatus::Starting, |
| 644 | 1031 | thinking: false, |
| @@ -722,6 +1109,7 @@ impl Session { | ||
| 722 | 1109 | resume: resume.clone(), |
| 723 | 1110 | browser_session: settings::get_for(&self.project, |settings| settings.agent_browser) |
| 724 | 1111 | .then(|| browser::session_name(&self.id)), |
| 1112 | + delegating_thread: self.delegation.is_none().then(|| self.id.clone()), | |
| 725 | 1113 | }; |
| 726 | 1114 | let process = match AgentProcess::spawn(&config, tx) { |
| 727 | 1115 | Ok(process) => process, |
| @@ -968,6 +1356,90 @@ impl Session { | ||
| 968 | 1356 | cx.notify(); |
| 969 | 1357 | } |
| 970 | 1358 | |
| 1359 | + /// Tells the agent something the user doesn't need to see, like a | |
| 1360 | + /// subagent's result: it reaches the agent like a message (mid-turn, as | |
| 1361 | + /// soon as it can take it in; otherwise it starts a turn), but the | |
| 1362 | + /// transcript only shows the agent's reply. | |
| 1363 | + pub fn send_hidden(&mut self, text: String, cx: &mut Context<Self>) { | |
| 1364 | + if self.remote.is_some() || text.trim().is_empty() { | |
| 1365 | + return; | |
| 1366 | + } | |
| 1367 | + self.updated = SystemTime::now(); | |
| 1368 | + self.resume_at = None; | |
| 1369 | + self.interrupted = false; | |
| 1370 | + if !self.is_working() { | |
| 1371 | + self.turn_started = Some(Instant::now()); | |
| 1372 | + self.turn_cost = 0.; | |
| 1373 | + self.snapshot_base(cx); | |
| 1374 | + } | |
| 1375 | + self.deliver(text, Vec::new(), cx); | |
| 1376 | + cx.notify(); | |
| 1377 | + } | |
| 1378 | + | |
| 1379 | + /// Stops one subagent of the thread's, by the panel's Stop button. | |
| 1380 | + pub fn stop_subagent(&mut self, stop: &SubagentStop, cx: &mut Context<Self>) { | |
| 1381 | + match stop { | |
| 1382 | + SubagentStop::Task(task_id) => self.stop_task(task_id, cx), | |
| 1383 | + SubagentStop::Turn => self.interrupt(cx), | |
| 1384 | + SubagentStop::SwarmAgent(agent) => { | |
| 1385 | + self.stop_swarm_agent(agent); | |
| 1386 | + if let Some(member) = self.swarm_member_mut(agent) { | |
| 1387 | + member.set_status(TaskStatus::Stopped); | |
| 1388 | + } | |
| 1389 | + cx.notify(); | |
| 1390 | + } | |
| 1391 | + // The workspace stops a delegated task's thread. | |
| 1392 | + SubagentStop::Thread(_) => {} | |
| 1393 | + } | |
| 1394 | + } | |
| 1395 | + | |
| 1396 | + /// Cancels a swarm agent's turn. jcode's API cancels only the session a | |
| 1397 | + /// connection is attached to, so this attaches a short connection of its | |
| 1398 | + /// own to the agent's session. | |
| 1399 | + fn stop_swarm_agent(&self, agent: &str) { | |
| 1400 | + let instance = settings::instance(&self.instance, self.provider); | |
| 1401 | + let agent = agent.to_string(); | |
| 1402 | + let _ = std::thread::Builder::new().name("swarm-stop".into()).spawn(move || { | |
| 1403 | + use std::io::{BufRead as _, Write as _}; | |
| 1404 | + let Ok((_, mut command)) = crate::claude::base_command(&instance) else { return }; | |
| 1405 | + command.args(crate::claude::split_args(&instance.args)); | |
| 1406 | + command | |
| 1407 | + .args(["--no-update", "api", "--stdio"]) | |
| 1408 | + .stdin(std::process::Stdio::piped()) | |
| 1409 | + .stdout(std::process::Stdio::piped()) | |
| 1410 | + .stderr(std::process::Stdio::null()); | |
| 1411 | + let Ok(mut child) = command.spawn() else { return }; | |
| 1412 | + let pid = child.id() as i32; | |
| 1413 | + // Don't hang around if jcode doesn't answer. | |
| 1414 | + let _ = std::thread::spawn(move || { | |
| 1415 | + std::thread::sleep(Duration::from_secs(15)); | |
| 1416 | + unsafe { libc::kill(pid, libc::SIGTERM) }; | |
| 1417 | + }); | |
| 1418 | + if let Some(mut stdin) = child.stdin.take() { | |
| 1419 | + let requests = [ | |
| 1420 | + json!({"v": 1, "id": 1, "req": "hello", "min_version": 1, "max_version": 1, "client": JCODE_CLIENT}), | |
| 1421 | + json!({"v": 1, "id": 2, "req": "attach_session", "session_id": agent}), | |
| 1422 | + json!({"v": 1, "id": 3, "req": "cancel", "session_id": agent}), | |
| 1423 | + ]; | |
| 1424 | + for request in requests { | |
| 1425 | + let _ = writeln!(stdin, "{request}"); | |
| 1426 | + } | |
| 1427 | + let _ = stdin.flush(); | |
| 1428 | + // Kept open until the cancel is through. | |
| 1429 | + if let Some(stdout) = child.stdout.take() { | |
| 1430 | + for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) { | |
| 1431 | + let reply = serde_json::from_str::<Value>(&line).unwrap_or_default(); | |
| 1432 | + if reply.get("reply_to").and_then(Value::as_u64) == Some(3) { | |
| 1433 | + break; | |
| 1434 | + } | |
| 1435 | + } | |
| 1436 | + } | |
| 1437 | + } | |
| 1438 | + let _ = child.kill(); | |
| 1439 | + let _ = child.wait(); | |
| 1440 | + }); | |
| 1441 | + } | |
| 1442 | + | |
| 971 | 1443 | /// Holds a message until the running turn ends; sends it now if none runs. |
| 972 | 1444 | pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) { |
| 973 | 1445 | if self.remote.is_some() { |
| @@ -1125,8 +1597,14 @@ impl Session { | ||
| 1125 | 1597 | self.jcode_request("steer the turn", request); |
| 1126 | 1598 | } else { |
| 1127 | 1599 | let mut request = json!({"req": "send_message", "content": prompt, "images": images}); |
| 1128 | - if let Some(note) = self.jcode.browser_note.take() { | |
| 1129 | - request["system_reminder"] = note.into(); | |
| 1600 | + // jcode's system prompt can't be added to, so the app's | |
| 1601 | + // instructions go along with the first message. | |
| 1602 | + let instructions = (self.delegation.is_none() && !self.jcode.instructions_sent) | |
| 1603 | + .then_some(orchestrator::INSTRUCTIONS.to_string()); | |
| 1604 | + self.jcode.instructions_sent = true; | |
| 1605 | + let reminder: Vec<String> = instructions.into_iter().chain(self.jcode.browser_note.take()).collect(); | |
| 1606 | + if !reminder.is_empty() { | |
| 1607 | + request["system_reminder"] = reminder.join("\n\n").into(); | |
| 1130 | 1608 | } |
| 1131 | 1609 | self.jcode_request("send the message", request); |
| 1132 | 1610 | } |
| @@ -2188,6 +2666,27 @@ impl Session { | ||
| 2188 | 2666 | } |
| 2189 | 2667 | } |
| 2190 | 2668 | } |
| 2669 | + // A subagent's running totals, as it works. | |
| 2670 | + Some("task_progress") => { | |
| 2671 | + let Some(tool) = self.find_tool_mut(text("tool_use_id").unwrap_or_default()) else { return }; | |
| 2672 | + let uses = message.pointer("/usage/tool_uses").and_then(Value::as_u64); | |
| 2673 | + let tokens = message.pointer("/usage/total_tokens").and_then(Value::as_u64); | |
| 2674 | + if let Some(uses) = uses { | |
| 2675 | + tool.subagent_tool_count = tool.subagent_tool_count.max(uses as usize); | |
| 2676 | + } | |
| 2677 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 2678 | + progress.tokens = tokens.or(progress.tokens); | |
| 2679 | + // A background subagent's own messages don't come through. | |
| 2680 | + if let Some(name) = text("last_tool_name") | |
| 2681 | + && progress.tool.as_ref().is_none_or(|(tool, _)| tool != name) | |
| 2682 | + { | |
| 2683 | + progress.tool = Some((name.to_string(), Value::Null)); | |
| 2684 | + } | |
| 2685 | + if let Some(summary) = text("summary").filter(|summary| !summary.trim().is_empty()) { | |
| 2686 | + progress.summary = Some(summary.trim().to_string()); | |
| 2687 | + } | |
| 2688 | + } | |
| 2689 | + } | |
| 2191 | 2690 | Some("task_updated") => { |
| 2192 | 2691 | let backgrounded = message.pointer("/patch/is_backgrounded").and_then(Value::as_bool) == Some(true); |
| 2193 | 2692 | let tool_use_id = text("task_id") |
| @@ -2232,6 +2731,14 @@ impl Session { | ||
| 2232 | 2731 | if tool.background.is_some() { |
| 2233 | 2732 | tool.background = Some(status); |
| 2234 | 2733 | } |
| 2734 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 2735 | + if let Some(tokens) = message.pointer("/usage/total_tokens").and_then(Value::as_u64) { | |
| 2736 | + progress.tokens = Some(tokens); | |
| 2737 | + } | |
| 2738 | + if finished && tool.background.is_some() { | |
| 2739 | + progress.finish(); | |
| 2740 | + } | |
| 2741 | + } | |
| 2235 | 2742 | } |
| 2236 | 2743 | // Long foreground commands report finishing too; only |
| 2237 | 2744 | // background tasks wake Claude. |
| @@ -2353,19 +2860,21 @@ impl Session { | ||
| 2353 | 2860 | } |
| 2354 | 2861 | } |
| 2355 | 2862 | |
| 2863 | + /// A subagent's own message: counts its tool calls and notes the latest. | |
| 2356 | 2864 | fn count_subagent_tools(&mut self, parent: &str, message: &Value) { |
| 2357 | - let count = message | |
| 2865 | + let calls: Vec<&Value> = message | |
| 2358 | 2866 | .get("content") |
| 2359 | 2867 | .and_then(Value::as_array) |
| 2360 | - .map(|blocks| { | |
| 2361 | - blocks | |
| 2362 | - .iter() | |
| 2363 | - .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) | |
| 2364 | - .count() | |
| 2365 | - }) | |
| 2366 | - .unwrap_or(0); | |
| 2868 | + .into_iter() | |
| 2869 | + .flatten() | |
| 2870 | + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) | |
| 2871 | + .collect(); | |
| 2367 | 2872 | if let Some(tool) = self.find_tool_mut(parent) { |
| 2368 | - tool.subagent_tool_count += count; | |
| 2873 | + tool.subagent_tool_count += calls.len(); | |
| 2874 | + if let (Some(progress), Some(call)) = (tool.subagent.as_mut(), calls.last()) { | |
| 2875 | + let name = call.get("name").and_then(Value::as_str).unwrap_or("Tool").to_string(); | |
| 2876 | + progress.tool = Some((name, brief_input(&call["input"]))); | |
| 2877 | + } | |
| 2369 | 2878 | } |
| 2370 | 2879 | } |
| 2371 | 2880 | |
| @@ -2384,6 +2893,12 @@ impl Session { | ||
| 2384 | 2893 | if let Some(tool) = self.find_tool_mut(id) { |
| 2385 | 2894 | tool.result = Some(text); |
| 2386 | 2895 | tool.state = if is_error { ToolState::Error } else { ToolState::Done }; |
| 2896 | + // A background subagent's tool returns as it starts. | |
| 2897 | + if (is_error || tool.background.is_none()) | |
| 2898 | + && let Some(progress) = tool.subagent.as_mut() | |
| 2899 | + { | |
| 2900 | + progress.finish(); | |
| 2901 | + } | |
| 2387 | 2902 | if let Some(prompt) = tool.permission.as_mut() |
| 2388 | 2903 | && prompt.state == PermissionState::Pending |
| 2389 | 2904 | { |
| @@ -2609,13 +3124,24 @@ impl Session { | ||
| 2609 | 3124 | ); |
| 2610 | 3125 | } |
| 2611 | 3126 | } |
| 3127 | + // Pi's `subagent` extension reports its tasks as they go. | |
| 3128 | + Some("tool_execution_update") => { | |
| 3129 | + let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default(); | |
| 3130 | + if let Some(tool) = self.find_tool_mut(id) { | |
| 3131 | + pi_subagent_results(tool, message.pointer("/partialResult/details")); | |
| 3132 | + } | |
| 3133 | + } | |
| 2612 | 3134 | Some("tool_execution_end") => { |
| 2613 | 3135 | let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default(); |
| 2614 | 3136 | let is_error = message.get("isError").and_then(Value::as_bool).unwrap_or(false); |
| 2615 | 3137 | let text = tool_result_text(message.pointer("/result/content").unwrap_or(&Value::Null)); |
| 2616 | 3138 | if let Some(tool) = self.find_tool_mut(id) { |
| 3139 | + pi_subagent_results(tool, message.pointer("/result/details")); | |
| 2617 | 3140 | tool.result = Some(text); |
| 2618 | 3141 | tool.state = if is_error { ToolState::Error } else { ToolState::Done }; |
| 3142 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 3143 | + progress.finish(); | |
| 3144 | + } | |
| 2619 | 3145 | } |
| 2620 | 3146 | } |
| 2621 | 3147 | Some("agent_settled") => { |
| @@ -2847,10 +3373,33 @@ impl Session { | ||
| 2847 | 3373 | for (what, request) in std::mem::take(&mut self.jcode.held) { |
| 2848 | 3374 | self.jcode_request(what, request); |
| 2849 | 3375 | } |
| 3376 | + self.configure_jcode_tools(); | |
| 3377 | + // Swarm agents carry on in jcode while the app is away. | |
| 3378 | + self.watch_swarm(cx); | |
| 3379 | + } | |
| 3380 | + "sessions" if request == Some(LIST_AGENTS) => self.update_swarm(&message["sessions"]), | |
| 3381 | + "history" if request == Some(PEEK_AGENT) => { | |
| 3382 | + let agent = text("session_id"); | |
| 3383 | + let said = message | |
| 3384 | + .get("messages") | |
| 3385 | + .and_then(Value::as_array) | |
| 3386 | + .into_iter() | |
| 3387 | + .flatten() | |
| 3388 | + .rev() | |
| 3389 | + .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) | |
| 3390 | + .find_map(|message| last_line(message.get("content").and_then(Value::as_str).unwrap_or_default())); | |
| 3391 | + if let Some(member) = self.swarm_member_mut(agent) { | |
| 3392 | + member.summary = said.or(member.summary.take()); | |
| 3393 | + } | |
| 2850 | 3394 | } |
| 2851 | 3395 | "error" => { |
| 2852 | 3396 | let error = text("message"); |
| 2853 | 3397 | match request { |
| 3398 | + // Checking on swarm agents is best effort, and repeats. | |
| 3399 | + Some(LIST_AGENTS | PEEK_AGENT) => {} | |
| 3400 | + // Mid-turn, it waits for the turn to end. Daemons too old | |
| 3401 | + // for session tools go without. | |
| 3402 | + Some(CONFIGURE_TOOLS) => self.jcode.tools_pending = error.contains("busy"), | |
| 2854 | 3403 | // The session is gone from jcode's store; carry on in a new one. |
| 2855 | 3404 | Some("attach") => { |
| 2856 | 3405 | self.push_notice( |
| @@ -2878,6 +3427,11 @@ impl Session { | ||
| 2878 | 3427 | self.jcode.provider = message.get("provider").and_then(Value::as_str).map(str::to_string); |
| 2879 | 3428 | } |
| 2880 | 3429 | "message_accepted" => self.begin_turn(cx), |
| 3430 | + "tool_call" => cx.emit(SessionEvent::AppTool { | |
| 3431 | + call_id: text("call_id").to_string(), | |
| 3432 | + tool: text("name").to_string(), | |
| 3433 | + input: message.get("input").cloned().unwrap_or(Value::Null), | |
| 3434 | + }), | |
| 2881 | 3435 | "soft_interrupt_injected" if text("point") == "turn_start" => self.begin_turn(cx), |
| 2882 | 3436 | "text_delta" => { |
| 2883 | 3437 | self.begin_turn(cx); |
| @@ -2937,11 +3491,30 @@ impl Session { | ||
| 2937 | 3491 | }; |
| 2938 | 3492 | take_partial_input(tool); |
| 2939 | 3493 | tool.state = if error.is_some() { ToolState::Error } else { ToolState::Done }; |
| 3494 | + // A swarm spawn names the agent's session: watch it from here. | |
| 3495 | + let spawned = (tool.name == SWARM_TOOL && error.is_none()) | |
| 3496 | + .then(|| output.trim().strip_prefix("Spawned new agent:").map(|id| id.trim().to_string())) | |
| 3497 | + .flatten() | |
| 3498 | + .filter(|id| !id.is_empty()); | |
| 3499 | + if let (Some(agent), Some(progress)) = (spawned, tool.subagent.as_mut()) { | |
| 3500 | + let label = tool_field(&tool.input, &["label", "prompt"]) | |
| 3501 | + .and_then(|label| label.lines().next().map(|line| line.chars().take(200).collect())) | |
| 3502 | + .unwrap_or_else(|| "Agent".into()); | |
| 3503 | + progress.members.push(SubagentMember { | |
| 3504 | + id: agent, | |
| 3505 | + label, | |
| 3506 | + kind: tool_field(&tool.input, &["role"]).filter(|role| role != "agent"), | |
| 3507 | + status: TaskStatus::Running.key().to_string(), | |
| 3508 | + started: progress.started, | |
| 3509 | + ..Default::default() | |
| 3510 | + }); | |
| 3511 | + } | |
| 2940 | 3512 | tool.result = Some(match error { |
| 2941 | 3513 | Some(error) if output.trim().is_empty() => error, |
| 2942 | 3514 | Some(error) => format!("{output}\n\n{error}"), |
| 2943 | 3515 | None => output, |
| 2944 | 3516 | }); |
| 3517 | + self.watch_swarm(cx); | |
| 2945 | 3518 | } |
| 2946 | 3519 | "token_usage" => { |
| 2947 | 3520 | let count = |key: &str| message.get(key).and_then(Value::as_u64).unwrap_or(0); |
| @@ -2987,12 +3560,74 @@ impl Session { | ||
| 2987 | 3560 | self.finish_open_tools("The turn ended before the tool finished."); |
| 2988 | 3561 | let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64); |
| 2989 | 3562 | self.finish_turn(duration_ms, None, cx); |
| 3563 | + if std::mem::take(&mut self.jcode.tools_pending) { | |
| 3564 | + self.configure_jcode_tools(); | |
| 3565 | + } | |
| 2990 | 3566 | self.send_next_queued(cx); |
| 2991 | 3567 | } |
| 2992 | 3568 | _ => {} |
| 2993 | 3569 | } |
| 2994 | 3570 | } |
| 2995 | 3571 | |
| 3572 | + /// Registers the app's tools with the thread's jcode session (a | |
| 3573 | + /// subagent's thread gets none) and switches off jcode's own ways of | |
| 3574 | + /// spawning agents. jcode keeps the setup in memory, so it's sent on | |
| 3575 | + /// every attach. | |
| 3576 | + fn configure_jcode_tools(&mut self) { | |
| 3577 | + let tools = orchestrator::jcode_tools(self.delegation.is_none()); | |
| 3578 | + self.jcode_request(CONFIGURE_TOOLS, json!({"req": "configure_tools", "tools": tools})); | |
| 3579 | + } | |
| 3580 | + | |
| 3581 | + /// Answers a call jcode made to one of the app's tools. | |
| 3582 | + pub fn answer_app_tool(&mut self, call_id: &str, outcome: Result<Value, String>) { | |
| 3583 | + let (output, error) = match outcome { | |
| 3584 | + Ok(value) => (serde_json::to_string_pretty(&value).unwrap_or_default(), None), | |
| 3585 | + Err(error) => (String::new(), Some(error)), | |
| 3586 | + }; | |
| 3587 | + let request = json!({"req": "tool_result", "call_id": call_id, "output": output, "error": error}); | |
| 3588 | + self.jcode_request("answer a tool call", request); | |
| 3589 | + } | |
| 3590 | + | |
| 3591 | + /// Ties a task the app started for this thread to the `delegate_task` | |
| 3592 | + /// call that asked for it: the latest one with that task and no | |
| 3593 | + /// subagent yet. | |
| 3594 | + pub fn link_delegation(&mut self, args: &Value, member: SubagentMember, cx: &mut Context<Self>) { | |
| 3595 | + let task = args.get("task").and_then(Value::as_str); | |
| 3596 | + let unlinked = |tool: &ToolCall| { | |
| 3597 | + tool.is_delegation() && tool.subagent.as_ref().is_some_and(|progress| progress.members.is_empty()) | |
| 3598 | + }; | |
| 3599 | + let ix = self | |
| 3600 | + .items | |
| 3601 | + .iter() | |
| 3602 | + .rposition(|item| { | |
| 3603 | + matches!(item, ChatItem::Tool(tool) if unlinked(tool) && tool.input.get("task").and_then(Value::as_str) == task) | |
| 3604 | + }) | |
| 3605 | + .or_else(|| self.items.iter().rposition(|item| matches!(item, ChatItem::Tool(tool) if unlinked(tool)))); | |
| 3606 | + if let Some(ChatItem::Tool(tool)) = ix.and_then(|ix| self.items.get_mut(ix)) | |
| 3607 | + && let Some(progress) = tool.subagent.as_mut() | |
| 3608 | + { | |
| 3609 | + progress.members.push(member); | |
| 3610 | + cx.notify(); | |
| 3611 | + } | |
| 3612 | + } | |
| 3613 | + | |
| 3614 | + /// Updates the subagent row of a task this thread delegated. | |
| 3615 | + pub fn update_delegation(&mut self, task: &str, update: impl FnOnce(&mut SubagentMember), cx: &mut Context<Self>) { | |
| 3616 | + let member = self.items.iter_mut().find_map(|item| match item { | |
| 3617 | + ChatItem::Tool(tool) if tool.is_delegation() => { | |
| 3618 | + tool.subagent.as_mut()?.members.iter_mut().find(|member| member.id == task) | |
| 3619 | + } | |
| 3620 | + _ => None, | |
| 3621 | + }); | |
| 3622 | + if let Some(member) = member { | |
| 3623 | + let before = member.clone(); | |
| 3624 | + update(member); | |
| 3625 | + if *member != before { | |
| 3626 | + cx.notify(); | |
| 3627 | + } | |
| 3628 | + } | |
| 3629 | + } | |
| 3630 | + | |
| 2996 | 3631 | /// The chat item a streamed jcode message renders into, made on its first delta. |
| 2997 | 3632 | fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize { |
| 2998 | 3633 | if let Some(&ix) = self.jcode.texts.get(message_id) { |
| @@ -3015,6 +3650,130 @@ impl Session { | ||
| 3015 | 3650 | }) |
| 3016 | 3651 | } |
| 3017 | 3652 | |
| 3653 | + /// The swarm agents this thread spawned, across its swarm calls. | |
| 3654 | + fn swarm_members(&self) -> impl Iterator<Item = &SubagentMember> { | |
| 3655 | + self.items.iter().filter_map(|item| match item { | |
| 3656 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_ref(), | |
| 3657 | + _ => None, | |
| 3658 | + }) | |
| 3659 | + .flat_map(|progress| &progress.members) | |
| 3660 | + } | |
| 3661 | + | |
| 3662 | + fn swarm_member_mut(&mut self, agent: &str) -> Option<&mut SubagentMember> { | |
| 3663 | + self.items | |
| 3664 | + .iter_mut() | |
| 3665 | + .filter_map(|item| match item { | |
| 3666 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(), | |
| 3667 | + _ => None, | |
| 3668 | + }) | |
| 3669 | + .flat_map(|progress| progress.members.iter_mut()) | |
| 3670 | + .find(|member| member.id == agent) | |
| 3671 | + } | |
| 3672 | + | |
| 3673 | + /// Whether the thread has swarm agents to keep an eye on: ones that work, | |
| 3674 | + /// or a spawn that hasn't named its agent yet. | |
| 3675 | + fn swarm_busy(&self) -> bool { | |
| 3676 | + self.swarm_members().any(|member| member.status == TaskStatus::Running.key()) | |
| 3677 | + || self.items.iter().any(|item| { | |
| 3678 | + matches!(item, ChatItem::Tool(tool) if tool.name == SWARM_TOOL && tool.state == ToolState::Running) | |
| 3679 | + }) | |
| 3680 | + } | |
| 3681 | + | |
| 3682 | + /// Starts checking on the thread's swarm agents every few seconds, if | |
| 3683 | + /// any work and it isn't already. jcode's API doesn't stream another | |
| 3684 | + /// session's progress, so the app asks: the session list says where each | |
| 3685 | + /// agent stands, and a peek at its transcript what it last said. | |
| 3686 | + fn watch_swarm(&mut self, cx: &mut Context<Self>) { | |
| 3687 | + if self.jcode.agent_poll.is_some() || !self.swarm_busy() { | |
| 3688 | + return; | |
| 3689 | + } | |
| 3690 | + self.jcode.agent_poll = Some(cx.spawn(async move |this, cx| { | |
| 3691 | + loop { | |
| 3692 | + cx.background_executor().timer(SWARM_POLL).await; | |
| 3693 | + let busy = this.update(cx, |this, _| { | |
| 3694 | + let busy = this.process.is_some() && this.swarm_busy(); | |
| 3695 | + if busy { | |
| 3696 | + this.poll_swarm(); | |
| 3697 | + } else if let Some(poll) = this.jcode.agent_poll.take() { | |
| 3698 | + // This very loop: let it end on its own. | |
| 3699 | + poll.detach(); | |
| 3700 | + } | |
| 3701 | + busy | |
| 3702 | + }); | |
| 3703 | + if !matches!(busy, Ok(true)) { | |
| 3704 | + break; | |
| 3705 | + } | |
| 3706 | + } | |
| 3707 | + })); | |
| 3708 | + } | |
| 3709 | + | |
| 3710 | + fn poll_swarm(&mut self) { | |
| 3711 | + self.jcode_send(LIST_AGENTS, json!({"req": "list_sessions", "limit": 200})); | |
| 3712 | + let working: Vec<String> = self | |
| 3713 | + .swarm_members() | |
| 3714 | + .filter(|member| member.status == TaskStatus::Running.key()) | |
| 3715 | + .map(|member| member.id.clone()) | |
| 3716 | + .collect(); | |
| 3717 | + for agent in working { | |
| 3718 | + self.jcode_send(PEEK_AGENT, json!({"req": "peek_session", "session_id": agent, "limit": 4})); | |
| 3719 | + } | |
| 3720 | + } | |
| 3721 | + | |
| 3722 | + /// Takes in where the thread's swarm agents stand, from jcode's session | |
| 3723 | + /// list. Agents another agent of the thread spawned list too, under the | |
| 3724 | + /// latest swarm call. | |
| 3725 | + fn update_swarm(&mut self, sessions: &Value) { | |
| 3726 | + let Some(own) = self.jcode.session.clone() else { return }; | |
| 3727 | + let Some(sessions) = sessions.as_array() else { return }; | |
| 3728 | + let ours: HashSet<String> = self.swarm_members().map(|member| member.id.clone()).collect(); | |
| 3729 | + let mut family: HashSet<String> = ours.iter().cloned().chain([own.clone()]).collect(); | |
| 3730 | + // Spawned by this session, or by one of its agents. | |
| 3731 | + loop { | |
| 3732 | + let before = family.len(); | |
| 3733 | + for session in sessions { | |
| 3734 | + let parent = session.get("parent_session_id").and_then(Value::as_str).unwrap_or_default(); | |
| 3735 | + if let Some(id) = session.get("session_id").and_then(Value::as_str) | |
| 3736 | + && family.contains(parent) | |
| 3737 | + { | |
| 3738 | + family.insert(id.to_string()); | |
| 3739 | + } | |
| 3740 | + } | |
| 3741 | + if family.len() == before { | |
| 3742 | + break; | |
| 3743 | + } | |
| 3744 | + } | |
| 3745 | + for session in sessions { | |
| 3746 | + let Some(id) = session.get("session_id").and_then(Value::as_str) else { continue }; | |
| 3747 | + if id == own || !family.contains(id) { | |
| 3748 | + continue; | |
| 3749 | + } | |
| 3750 | + let text = |key: &str| session.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()); | |
| 3751 | + let status = jcode_agent_status(text("swarm_status"), text("status")); | |
| 3752 | + if self.swarm_member_mut(id).is_none() { | |
| 3753 | + // An agent the app didn't see spawned: file it under the latest swarm call. | |
| 3754 | + let Some(progress) = self.items.iter_mut().rev().find_map(|item| match item { | |
| 3755 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(), | |
| 3756 | + _ => None, | |
| 3757 | + }) else { | |
| 3758 | + return; | |
| 3759 | + }; | |
| 3760 | + progress.members.push(SubagentMember { | |
| 3761 | + id: id.to_string(), | |
| 3762 | + started: to_millis(SystemTime::now()), | |
| 3763 | + ..Default::default() | |
| 3764 | + }); | |
| 3765 | + } | |
| 3766 | + let Some(member) = self.swarm_member_mut(id) else { continue }; | |
| 3767 | + if let Some(label) = text("agent_label").or(text("title")) { | |
| 3768 | + member.label = label.to_string(); | |
| 3769 | + } | |
| 3770 | + if member.label.is_empty() { | |
| 3771 | + member.label = "Agent".into(); | |
| 3772 | + } | |
| 3773 | + member.set_status(status); | |
| 3774 | + } | |
| 3775 | + } | |
| 3776 | + | |
| 3018 | 3777 | // ---- helpers ----------------------------------------------------- |
| 3019 | 3778 | |
| 3020 | 3779 | fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize { |
| @@ -3073,6 +3832,8 @@ impl Session { | ||
| 3073 | 3832 | ask: None, |
| 3074 | 3833 | task_id: None, |
| 3075 | 3834 | background: None, |
| 3835 | + subagent: is_subagent_tool(name) | |
| 3836 | + .then(|| SubagentProgress { started: to_millis(SystemTime::now()), ..Default::default() }), | |
| 3076 | 3837 | })); |
| 3077 | 3838 | self.items.len() - 1 |
| 3078 | 3839 | } |
| @@ -3131,6 +3892,9 @@ impl Session { | ||
| 3131 | 3892 | && tool.background == Some(TaskStatus::Running) |
| 3132 | 3893 | { |
| 3133 | 3894 | tool.background = Some(TaskStatus::Stopped); |
| 3895 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 3896 | + progress.finish(); | |
| 3897 | + } | |
| 3134 | 3898 | } |
| 3135 | 3899 | } |
| 3136 | 3900 | } |
| @@ -3142,6 +3906,9 @@ impl Session { | ||
| 3142 | 3906 | { |
| 3143 | 3907 | tool.state = ToolState::Error; |
| 3144 | 3908 | tool.result.get_or_insert_with(|| reason.to_string()); |
| 3909 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 3910 | + progress.finish(); | |
| 3911 | + } | |
| 3145 | 3912 | } |
| 3146 | 3913 | } |
| 3147 | 3914 | } |
| @@ -3203,6 +3970,8 @@ pub struct SavedThread { | ||
| 3203 | 3970 | pub items: Vec<SavedItem>, |
| 3204 | 3971 | /// What each finished turn changed, by its `TurnEnd` index. |
| 3205 | 3972 | pub turn_changes: Vec<(usize, TurnChanges)>, |
| 3973 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 3974 | + pub delegation: Option<Delegation>, | |
| 3206 | 3975 | } |
| 3207 | 3976 | |
| 3208 | 3977 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| @@ -3232,6 +4001,8 @@ pub enum SavedItem { | ||
| 3232 | 4001 | /// background. |
| 3233 | 4002 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 3234 | 4003 | background: Option<String>, |
| 4004 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 4005 | + subagent: Option<SubagentProgress>, | |
| 3235 | 4006 | }, |
| 3236 | 4007 | Notice { error: bool, text: String }, |
| 3237 | 4008 | TaskEvent { summary: String, status: String }, |
| @@ -3308,6 +4079,7 @@ impl Session { | ||
| 3308 | 4079 | .as_ref() |
| 3309 | 4080 | .map(|ask| ask.answers.iter().map(|answer| answer.as_ref().map(ToString::to_string)).collect()), |
| 3310 | 4081 | background: tool.background.map(|status| status.key().to_string()), |
| 4082 | + subagent: tool.subagent.clone(), | |
| 3311 | 4083 | }, |
| 3312 | 4084 | ChatItem::Notice(kind, text) => { |
| 3313 | 4085 | SavedItem::Notice { error: *kind == NoticeKind::Error, text: text.to_string() } |
| @@ -3368,6 +4140,7 @@ impl Session { | ||
| 3368 | 4140 | working: self.is_working(), |
| 3369 | 4141 | items, |
| 3370 | 4142 | turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(), |
| 4143 | + delegation: self.delegation.clone(), | |
| 3371 | 4144 | } |
| 3372 | 4145 | } |
| 3373 | 4146 | |
| @@ -3412,6 +4185,7 @@ impl Session { | ||
| 3412 | 4185 | session.queue = saved.queue; |
| 3413 | 4186 | session.interrupted = saved.working; |
| 3414 | 4187 | session.turn_changes = saved.turn_changes.into_iter().collect(); |
| 4188 | + session.delegation = saved.delegation; | |
| 3415 | 4189 | for item in saved.items { |
| 3416 | 4190 | let item = session.item_from_saved(item, false, cx); |
| 3417 | 4191 | session.items.push(item); |
| @@ -3448,6 +4222,7 @@ impl Session { | ||
| 3448 | 4222 | subagent_tool_count, |
| 3449 | 4223 | answers, |
| 3450 | 4224 | background, |
| 4225 | + subagent, | |
| 3451 | 4226 | } => { |
| 3452 | 4227 | let pending = live && permission.as_deref() == Some("pending"); |
| 3453 | 4228 | let ask = answers.and_then(|answers| { |
| @@ -3458,6 +4233,8 @@ impl Session { | ||
| 3458 | 4233 | ask.current = ask.answers.iter().position(Option::is_none).unwrap_or(0); |
| 3459 | 4234 | Some(ask) |
| 3460 | 4235 | }); |
| 4236 | + // Threads saved before subagents were tracked still list them. | |
| 4237 | + let subagent = subagent.or_else(|| is_subagent_tool(&name).then(SubagentProgress::default)); | |
| 3461 | 4238 | let background = background.map(|status| match TaskStatus::from_cli(&status) { |
| 3462 | 4239 | // Background tasks die with the agent process. |
| 3463 | 4240 | TaskStatus::Running if !live => TaskStatus::Stopped, |
| @@ -3491,6 +4268,8 @@ impl Session { | ||
| 3491 | 4268 | ask, |
| 3492 | 4269 | task_id: None, |
| 3493 | 4270 | background, |
| 4271 | + // Threads saved before subagents were tracked still list them. | |
| 4272 | + subagent, | |
| 3494 | 4273 | }) |
| 3495 | 4274 | } |
| 3496 | 4275 | SavedItem::Notice { error, text } => { |
| @@ -4030,7 +4809,66 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String | ||
| 4030 | 4809 | |
| 4031 | 4810 | #[cfg(test)] |
| 4032 | 4811 | mod tests { |
| 4033 | - use super::finished_prefix; | |
| 4812 | + use super::{TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; | |
| 4813 | + use serde_json::{Value, json}; | |
| 4814 | + | |
| 4815 | + fn tool(name: &str, input: Value) -> ToolCall { | |
| 4816 | + ToolCall { | |
| 4817 | + id: "call".into(), | |
| 4818 | + name: name.into(), | |
| 4819 | + input, | |
| 4820 | + partial_input: String::new(), | |
| 4821 | + result: None, | |
| 4822 | + state: ToolState::Running, | |
| 4823 | + permission: None, | |
| 4824 | + expanded: false, | |
| 4825 | + subagent_tool_count: 0, | |
| 4826 | + detached: false, | |
| 4827 | + ask: None, | |
| 4828 | + task_id: None, | |
| 4829 | + background: None, | |
| 4830 | + subagent: super::is_subagent_tool(name).then(super::SubagentProgress::default), | |
| 4831 | + } | |
| 4832 | + } | |
| 4833 | + | |
| 4834 | + #[::core::prelude::v1::test] | |
| 4835 | + fn pi_parallel_subagents() { | |
| 4836 | + let mut call = tool("subagent", json!({"tasks": [{"agent": "scout", "task": "Find it"}, {"agent": "fixer", "task": "Fix it"}]})); | |
| 4837 | + let details = json!({"mode": "parallel", "results": [ | |
| 4838 | + {"agent": "scout", "task": "Find it\nthoroughly", "exitCode": -1, | |
| 4839 | + "usage": {"input": 1000, "output": 200}, | |
| 4840 | + "messages": [{"role": "assistant", "content": [ | |
| 4841 | + {"type": "text", "text": "Looking around"}, | |
| 4842 | + {"type": "toolCall", "id": "a", "name": "read", "arguments": {"path": "src/main.rs", "content": 7}}]}]}, | |
| 4843 | + {"agent": "fixer", "task": "Fix it", "exitCode": 1, "stopReason": "error", "messages": []}, | |
| 4844 | + ]}); | |
| 4845 | + pi_subagent_results(&mut call, Some(&details)); | |
| 4846 | + let entries = call.subagents(); | |
| 4847 | + assert_eq!(entries.len(), 2); | |
| 4848 | + assert_eq!(entries[0].label, "Find it"); | |
| 4849 | + assert_eq!(entries[0].kind.as_deref(), Some("scout")); | |
| 4850 | + assert_eq!(entries[0].status, TaskStatus::Running); | |
| 4851 | + assert_eq!(entries[0].tokens, Some(1200)); | |
| 4852 | + assert_eq!(entries[0].tool_uses, 1); | |
| 4853 | + assert_eq!(entries[0].tool, Some(("read".into(), json!({"path": "src/main.rs"})))); | |
| 4854 | + assert_eq!(entries[0].summary.as_deref(), Some("Looking around")); | |
| 4855 | + assert_eq!(entries[1].status, TaskStatus::Failed); | |
| 4856 | + // Once the call is over, a task still marked running was cut off. | |
| 4857 | + call.state = ToolState::Done; | |
| 4858 | + assert_eq!(call.subagents()[0].status, TaskStatus::Stopped); | |
| 4859 | + } | |
| 4860 | + | |
| 4861 | + #[::core::prelude::v1::test] | |
| 4862 | + fn swarm_calls() { | |
| 4863 | + // Only spawns start agents; talking to the swarm doesn't. | |
| 4864 | + assert!(tool("swarm", json!({"action": "message", "message": "hi"})).subagents().is_empty()); | |
| 4865 | + let spawn = tool("swarm", json!({"action": "spawn", "label": "Refactor"})); | |
| 4866 | + assert_eq!(spawn.subagents()[0].label, "Refactor"); | |
| 4867 | + assert_eq!(jcode_agent_status(Some("running"), None), TaskStatus::Running); | |
| 4868 | + assert_eq!(jcode_agent_status(Some("ready"), Some("idle")), TaskStatus::Completed); | |
| 4869 | + assert_eq!(jcode_agent_status(Some("crashed"), None), TaskStatus::Failed); | |
| 4870 | + assert_eq!(jcode_agent_status(None, Some("running")), TaskStatus::Running); | |
| 4871 | + } | |
| 4034 | 4872 | |
| 4035 | 4873 | #[::core::prelude::v1::test] |
| 4036 | 4874 | fn finished_paragraphs() { |
| @@ -9,6 +9,8 @@ mod footer; | ||
| 9 | 9 | mod strip; |
| 10 | 10 | mod timeline; |
| 11 | 11 | |
| 12 | + pub(crate) use timeline::{describe_call, format_duration}; | |
| 13 | + | |
| 12 | 14 | use std::{ |
| 13 | 15 | path::PathBuf, |
| 14 | 16 | time::{Duration, SystemTime}, |
| @@ -263,7 +265,7 @@ impl ChatView { | ||
| 263 | 265 | _ => {} |
| 264 | 266 | }), |
| 265 | 267 | cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| { |
| 266 | - let SessionEvent::TurnChanges(end) = event; | |
| 268 | + let SessionEvent::TurnChanges(end) = event else { return }; | |
| 267 | 269 | let big = session.read(cx).turn_changes(*end).is_some_and(|changes| { |
| 268 | 270 | changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES |
| 269 | 271 | }); |
| @@ -374,6 +376,39 @@ impl ChatView { | ||
| 374 | 376 | self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx)); |
| 375 | 377 | } |
| 376 | 378 | |
| 379 | + /// Shows a tool call in the conversation, unfolded: opens the finished | |
| 380 | + /// turn and the run of calls it's folded into, and scrolls to it. The | |
| 381 | + /// subagents panel's rows lead here. | |
| 382 | + pub fn reveal_tool(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 383 | + let session = self.session.read(cx); | |
| 384 | + if !matches!(session.items.get(ix), Some(ChatItem::Tool(_))) { | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + let fold = settings::get(|settings| settings.fold_turns); | |
| 388 | + let hidden = session | |
| 389 | + .finished_turn(ix) | |
| 390 | + .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold) | |
| 391 | + .map(|turn| turn.end); | |
| 392 | + let group = timeline::group_of(&session.items, ix); | |
| 393 | + if group.len() > 1 { | |
| 394 | + self.expanded_groups.insert(group[0]); | |
| 395 | + } | |
| 396 | + self.session.update(cx, |session, cx| { | |
| 397 | + if let Some(end) = hidden { | |
| 398 | + session.toggle_turn(end, cx); | |
| 399 | + } | |
| 400 | + if let Some(ChatItem::Tool(tool)) = session.items.get_mut(ix) { | |
| 401 | + tool.expanded = true; | |
| 402 | + } | |
| 403 | + cx.notify(); | |
| 404 | + }); | |
| 405 | + self.scroller.update(cx, |scroller, cx| { | |
| 406 | + scroller.remeasure(cx); | |
| 407 | + scroller.scroll_to_item(ix, cx); | |
| 408 | + }); | |
| 409 | + cx.notify(); | |
| 410 | + } | |
| 411 | + | |
| 377 | 412 | pub fn focus_composer(&self, window: &mut Window, cx: &mut App) { |
| 378 | 413 | self.composer.update(cx, |composer, cx| composer.focus(window, cx)); |
| 379 | 414 | } |
| @@ -9,6 +9,8 @@ use std::collections::HashSet; | ||
| 9 | 9 | use std::path::Path; |
| 10 | 10 | use std::time::SystemTime; |
| 11 | 11 | |
| 12 | + use serde_json::Value; | |
| 13 | + | |
| 12 | 14 | use gpui_kit::component::{ |
| 13 | 15 | Icon, IconName, h_flex, |
| 14 | 16 | menu::{DropdownMenu as _, PopupMenuItem}, |
| @@ -99,9 +101,30 @@ fn relative_path(path: &str, cwd: &Path) -> String { | ||
| 99 | 101 | } |
| 100 | 102 | |
| 101 | 103 | fn present(tool: &ToolCall, cwd: &Path) -> Presentation { |
| 102 | - let field = |keys: &[&str]| tool_field(&tool.input, keys); | |
| 104 | + present_call(&tool.name, &tool.input, cwd) | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// A tool call's icon and label as the timeline shows them, for a call | |
| 108 | + /// known only by its name and input (a subagent's). | |
| 109 | + pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, String) { | |
| 110 | + let presentation = present_call(name, input, cwd); | |
| 111 | + (presentation.icon, presentation.label) | |
| 112 | + } | |
| 113 | + | |
| 114 | + fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | |
| 115 | + let field = |keys: &[&str]| tool_field(input, keys); | |
| 103 | 116 | let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>(); |
| 104 | - let name = tool.name.as_str(); | |
| 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 | + } | |
| 105 | 128 | let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 106 | 129 | "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), |
| 107 | 130 | "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| @@ -121,6 +144,27 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation { | ||
| 121 | 144 | "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), |
| 122 | 145 | "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), |
| 123 | 146 | "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())), |
| 147 | + // Pi's `subagent` extension (one task, parallel tasks or a chain), | |
| 148 | + // and jcode's old subagent tool. | |
| 149 | + "subagent" => { | |
| 150 | + let several = input.get("tasks").or_else(|| input.get("chain")).and_then(Value::as_array).map(Vec::len); | |
| 151 | + let label = match several { | |
| 152 | + Some(count) => format!("{count} subagents"), | |
| 153 | + None => field(&["description", "task"]).map(first_line).unwrap_or_else(|| "Subagent".into()), | |
| 154 | + }; | |
| 155 | + (Action::Other, label) | |
| 156 | + } | |
| 157 | + // jcode's swarm: spawning an agent, or talking to the swarm. | |
| 158 | + "swarm" => { | |
| 159 | + let action = field(&["action"]).unwrap_or_default(); | |
| 160 | + let label = match field(&["label", "prompt"]).map(first_line) { | |
| 161 | + Some(label) if action == "spawn" => format!("Spawn agent: {label}"), | |
| 162 | + _ if action == "spawn" => "Spawn agent".into(), | |
| 163 | + _ if action.is_empty() => "Swarm".into(), | |
| 164 | + _ => format!("Swarm: {}", action.replace('_', " ")), | |
| 165 | + }; | |
| 166 | + (Action::Other, label) | |
| 167 | + } | |
| 124 | 168 | "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())), |
| 125 | 169 | _ if name.starts_with("mcp__") => { |
| 126 | 170 | let mut parts = name.trim_start_matches("mcp__").splitn(2, "__"); |
| @@ -129,12 +173,12 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation { | ||
| 129 | 173 | (Action::Other, format!("{server}: {tool_name}")) |
| 130 | 174 | } |
| 131 | 175 | _ => { |
| 132 | - let summary = crate::session::tool_summary(name, &tool.input, cwd); | |
| 176 | + let summary = crate::session::tool_summary(name, input, cwd); | |
| 133 | 177 | (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") }) |
| 134 | 178 | } |
| 135 | 179 | }; |
| 136 | 180 | let icon = match name.to_ascii_lowercase().as_str() { |
| 137 | - "task" | "agent" => Icon::new(IconName::Bot), | |
| 181 | + "task" | "agent" | "subagent" | "swarm" => Icon::new(IconName::Bot), | |
| 138 | 182 | "monitor" => Icon::new(IconName::Eye), |
| 139 | 183 | _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer), |
| 140 | 184 | _ => action.icon(), |
| @@ -188,7 +232,7 @@ fn group_summary(actions: &[Action]) -> String { | ||
| 188 | 232 | } |
| 189 | 233 | |
| 190 | 234 | /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations. |
| 191 | - pub(super) fn format_duration(ms: u64) -> String { | |
| 235 | + pub(crate) fn format_duration(ms: u64) -> String { | |
| 192 | 236 | if ms < 1_000 { |
| 193 | 237 | return format!("{}ms", ms.max(1)); |
| 194 | 238 | } |
| @@ -220,7 +264,7 @@ fn is_hidden(item: &ChatItem) -> bool { | ||
| 220 | 264 | } |
| 221 | 265 | |
| 222 | 266 | /// The run of consecutive tool calls `ix` belongs to: its work rows' indices. |
| 223 | - fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> { | |
| 267 | + pub(super) fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> { | |
| 224 | 268 | let mut start = ix; |
| 225 | 269 | while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) { |
| 226 | 270 | start -= 1; |
| @@ -340,6 +384,8 @@ pub(super) fn render_item( | ||
| 340 | 384 | .filter(|turn| turn.first == ix) |
| 341 | 385 | .map(|turn| render_turn_fold(session, scroller, turn, expanded, p)); |
| 342 | 386 | let visible = match (&turn, item) { |
| 387 | + // A subagent's result, which older threads kept as a message. | |
| 388 | + (_, ChatItem::User { text, .. }) if text.starts_with(crate::orchestrator::RESULT_PREFIX) => false, | |
| 343 | 389 | (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(), |
| 344 | 390 | (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true, |
| 345 | 391 | (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true, |
| @@ -350,7 +396,7 @@ pub(super) fn render_item( | ||
| 350 | 396 | let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. })); |
| 351 | 397 | let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention()) |
| 352 | 398 | .then(|| render_working(state, p)); |
| 353 | - if !visible && summary.is_none() { | |
| 399 | + if !visible && summary.is_none() && working.is_none() { | |
| 354 | 400 | return div().into_any_element(); |
| 355 | 401 | } |
| 356 | 402 | |
| @@ -27,6 +27,7 @@ const COMMANDS: &[Command] = &[ | ||
| 27 | 27 | Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] }, |
| 28 | 28 | Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] }, |
| 29 | 29 | Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] }, |
| 30 | + Command { id: "subagentsPanel.toggle", label: "Subagents Panel: Toggle", actions: &["vectrum_code::ToggleSubagents"] }, | |
| 30 | 31 | Command { |
| 31 | 32 | id: "commandPalette.toggle", |
| 32 | 33 | label: "Command Palette: Toggle", |
| @@ -99,7 +100,7 @@ const COMPOSER_COMMANDS: &[(&str, &str)] = &[ | ||
| 99 | 100 | /// T3 Code's groups, in page order; a command joins the first group |
| 100 | 101 | /// listing its id's prefix. |
| 101 | 102 | const GROUPS: &[(&str, &[&str])] = &[ |
| 102 | - ("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]), | |
| 103 | + ("Navigation", &["sidebar", "rightPanel", "threadPanel", "subagentsPanel", "commandPalette", "filePicker", "projectSearch", "editor"]), | |
| 103 | 104 | ("Threads", &["thread", "chat", "pullRequest"]), |
| 104 | 105 | ("Composer", &["composer", "modelPicker"]), |
| 105 | 106 | ("Terminal", &["terminal"]), |
| @@ -42,15 +42,18 @@ use crate::{ | ||
| 42 | 42 | CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread, |
| 43 | 43 | CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, |
| 44 | 44 | SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel, |
| 45 | - ToggleRightPanel, ToggleSidebar, ToggleTerminal, UndoThreadAction, | |
| 45 | + ToggleRightPanel, ToggleSidebar, ToggleSubagents, ToggleTerminal, UndoThreadAction, | |
| 46 | 46 | }; |
| 47 | 47 | |
| 48 | 48 | mod actions; |
| 49 | + mod delegation; | |
| 49 | 50 | mod git_actions; |
| 50 | 51 | mod header; |
| 51 | 52 | mod palette; |
| 52 | 53 | mod remote; |
| 53 | 54 | mod sidebar; |
| 55 | + mod subagents; | |
| 56 | + mod surfaces; | |
| 54 | 57 | mod terminal_drawer; |
| 55 | 58 | |
| 56 | 59 | use actions::UndoEntry; |
| @@ -217,8 +220,11 @@ pub struct Workspace { | ||
| 217 | 220 | search: Entity<InputState>, |
| 218 | 221 | sidebar_open: bool, |
| 219 | 222 | panel_open: bool, |
| 220 | - /// The browser takes the project panel's place while open. | |
| 221 | - browser_open: bool, | |
| 223 | + /// What the right panel shows, in the project panel's place: a surface | |
| 224 | + /// (subagents, browser, terminal) or the list to pick one from. | |
| 225 | + right_panel: Option<surfaces::Surface>, | |
| 226 | + /// The surface picker's focus, for its keys. | |
| 227 | + surface_focus: FocusHandle, | |
| 222 | 228 | settled_open: bool, |
| 223 | 229 | snoozed_open: bool, |
| 224 | 230 | working_open: bool, |
| @@ -288,6 +294,8 @@ pub struct Workspace { | ||
| 288 | 294 | last_pr_check: Option<Instant>, |
| 289 | 295 | last_repaint: Instant, |
| 290 | 296 | remote: RemoteState, |
| 297 | + /// Tasks agents delegated to the app's subagents. | |
| 298 | + delegations: delegation::Delegations, | |
| 291 | 299 | focus_handle: FocusHandle, |
| 292 | 300 | observers: HashMap<EntityId, Vec<Subscription>>, |
| 293 | 301 | _subscriptions: Vec<Subscription>, |
| @@ -370,7 +378,8 @@ impl Workspace { | ||
| 370 | 378 | search, |
| 371 | 379 | sidebar_open: true, |
| 372 | 380 | panel_open: true, |
| 373 | - browser_open: false, | |
| 381 | + right_panel: None, | |
| 382 | + surface_focus: cx.focus_handle(), | |
| 374 | 383 | settled_open: false, |
| 375 | 384 | snoozed_open: false, |
| 376 | 385 | working_open: false, |
| @@ -417,12 +426,14 @@ impl Workspace { | ||
| 417 | 426 | last_pr_check: None, |
| 418 | 427 | last_repaint: now, |
| 419 | 428 | remote: RemoteState::default(), |
| 429 | + delegations: Default::default(), | |
| 420 | 430 | focus_handle: cx.focus_handle(), |
| 421 | 431 | observers: HashMap::new(), |
| 422 | 432 | _subscriptions: subscriptions, |
| 423 | 433 | _ticker: ticker, |
| 424 | 434 | _remote_flush: remote_flush, |
| 425 | 435 | }; |
| 436 | + workspace.listen_for_tools(window, cx); | |
| 426 | 437 | let active = workspace.restore(window, cx); |
| 427 | 438 | match (initial_project, active) { |
| 428 | 439 | (Some(path), _) => workspace.add_project(path, window, cx), |
| @@ -595,7 +606,7 @@ impl Workspace { | ||
| 595 | 606 | } |
| 596 | 607 | // Working threads show how long they've worked. |
| 597 | 608 | let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working()); |
| 598 | - if working && self.sidebar_open { | |
| 609 | + if working && (self.sidebar_open || self.surface_open(surfaces::Surface::Subagents)) { | |
| 599 | 610 | cx.notify(); |
| 600 | 611 | } |
| 601 | 612 | // Repaint about once a minute so thread ages stay current. |
| @@ -1003,6 +1014,7 @@ impl Workspace { | ||
| 1003 | 1014 | session.entity_id(), |
| 1004 | 1015 | vec![ |
| 1005 | 1016 | cx.observe_in(&session, window, Self::on_session_changed), |
| 1017 | + cx.subscribe_in(&session, window, Self::on_session_event), | |
| 1006 | 1018 | cx.subscribe_in(&chat, window, Self::on_chat_event), |
| 1007 | 1019 | ], |
| 1008 | 1020 | ); |
| @@ -1038,6 +1050,7 @@ impl Workspace { | ||
| 1038 | 1050 | cx.notify(); |
| 1039 | 1051 | self.dirty.insert(session.entity_id()); |
| 1040 | 1052 | self.remote_thread_changed(&session, cx); |
| 1053 | + self.track_delegation(&session, cx); | |
| 1041 | 1054 | let state = session.read(cx); |
| 1042 | 1055 | let now = Activity { working: state.is_working(), attention: state.needs_attention() }; |
| 1043 | 1056 | let before = self.activity.insert(session.entity_id(), now).unwrap_or_default(); |
| @@ -1583,6 +1596,7 @@ impl Workspace { | ||
| 1583 | 1596 | .iter() |
| 1584 | 1597 | .enumerate() |
| 1585 | 1598 | .flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone()))) |
| 1599 | + .filter(|(_, session)| !session.read(cx).is_subagent()) | |
| 1586 | 1600 | .filter(|(ix, session)| { |
| 1587 | 1601 | query.is_empty() |
| 1588 | 1602 | || session.read(cx).title.to_lowercase().contains(&query) |
| @@ -1711,7 +1725,7 @@ impl Workspace { | ||
| 1711 | 1725 | .projects |
| 1712 | 1726 | .iter() |
| 1713 | 1727 | .flat_map(|project| project.sessions.iter().map(move |session| (project, session))) |
| 1714 | - .filter(|(_, session)| session.read(cx).archived) | |
| 1728 | + .filter(|(_, session)| session.read(cx).archived && !session.read(cx).is_subagent()) | |
| 1715 | 1729 | .collect(); |
| 1716 | 1730 | threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated)); |
| 1717 | 1731 | threads |
| @@ -1749,9 +1763,9 @@ impl Workspace { | ||
| 1749 | 1763 | } |
| 1750 | 1764 | |
| 1751 | 1765 | fn toggle_project_panel(&mut self, cx: &mut Context<Self>) { |
| 1752 | - // With the browser in its place, the project panel comes back. | |
| 1753 | - if self.browser_open { | |
| 1754 | - self.browser_open = false; | |
| 1766 | + // With a surface in its place, the project panel comes back. | |
| 1767 | + if self.right_panel.is_some() { | |
| 1768 | + self.right_panel = None; | |
| 1755 | 1769 | self.panel_open = true; |
| 1756 | 1770 | } else { |
| 1757 | 1771 | self.panel_open = !self.panel_open; |
| @@ -1762,7 +1776,7 @@ impl Workspace { | ||
| 1762 | 1776 | /// The open thread's browser panel while it shows, made on first use. |
| 1763 | 1777 | /// The others let go of their streams. |
| 1764 | 1778 | fn shown_browser(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<BrowserPanel>> { |
| 1765 | - let session = self.active.clone().filter(|_| self.browser_open && !self.settings_open); | |
| 1779 | + let session = self.active.clone().filter(|_| self.surface_open(surfaces::Surface::Browser) && !self.settings_open); | |
| 1766 | 1780 | let panel = session.map(|session| { |
| 1767 | 1781 | let id = session.entity_id(); |
| 1768 | 1782 | if let Some(panel) = self.browsers.get(&id) { |
| @@ -1772,7 +1786,7 @@ impl Workspace { | ||
| 1772 | 1786 | let panel = cx.new(|cx| BrowserPanel::new(&thread_id, window, cx)); |
| 1773 | 1787 | let subscription = cx.subscribe(&panel, |this, _, event: &BrowserPanelEvent, cx| match event { |
| 1774 | 1788 | BrowserPanelEvent::Close => { |
| 1775 | - this.browser_open = false; | |
| 1789 | + this.right_panel = None; | |
| 1776 | 1790 | cx.notify(); |
| 1777 | 1791 | } |
| 1778 | 1792 | }); |
| @@ -2069,12 +2083,16 @@ impl Render for Workspace { | ||
| 2069 | 2083 | cx.set_global(directory); |
| 2070 | 2084 | let browser = self.shown_browser(window, cx); |
| 2071 | 2085 | let p = metal::palette(cx); |
| 2086 | + let subagents_visible = self.surface_open(surfaces::Surface::Subagents); | |
| 2087 | + let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open; | |
| 2088 | + let docked_terminal = | |
| 2089 | + if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None }; | |
| 2072 | 2090 | let panel_visible = self.panel_open |
| 2073 | - && browser.is_none() | |
| 2091 | + && self.right_panel.is_none() | |
| 2074 | 2092 | && self.active_project().is_some() |
| 2075 | 2093 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| 2076 | 2094 | // The browser gives way before the conversation gets too narrow. |
| 2077 | - let chat_min = if browser.is_some() { px(360.) } else { px(0.) }; | |
| 2095 | + let chat_min = if browser.is_some() || docked_terminal.is_some() { px(360.) } else { px(0.) }; | |
| 2078 | 2096 | |
| 2079 | 2097 | h_flex() |
| 2080 | 2098 | .key_context("Workspace") |
| @@ -2119,10 +2137,7 @@ impl Render for Workspace { | ||
| 2119 | 2137 | } |
| 2120 | 2138 | })) |
| 2121 | 2139 | .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx))) |
| 2122 | - .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| { | |
| 2123 | - this.browser_open = !this.browser_open; | |
| 2124 | - cx.notify(); | |
| 2125 | - })) | |
| 2140 | + .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx))) | |
| 2126 | 2141 | .on_action(cx.listener(|this, _: &SelectTheme, window, cx| { |
| 2127 | 2142 | this.open_palette(palette::PaletteView::Themes, window, cx) |
| 2128 | 2143 | })) |
| @@ -2167,9 +2182,11 @@ impl Render for Workspace { | ||
| 2167 | 2182 | cx.notify(); |
| 2168 | 2183 | })) |
| 2169 | 2184 | .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| this.toggle_project_panel(cx))) |
| 2170 | - .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| { | |
| 2171 | - this.browser_open = !this.browser_open; | |
| 2172 | - cx.notify(); | |
| 2185 | + .on_action(cx.listener(|this, _: &ToggleSubagents, window, cx| { | |
| 2186 | + this.toggle_surface(surfaces::Surface::Subagents, window, cx) | |
| 2187 | + })) | |
| 2188 | + .on_action(cx.listener(|this, _: &ToggleBrowser, window, cx| { | |
| 2189 | + this.toggle_surface(surfaces::Surface::Browser, window, cx) | |
| 2173 | 2190 | })) |
| 2174 | 2191 | .on_action(cx.listener(|this, _: &FocusSearch, window, cx| { |
| 2175 | 2192 | this.settings_open = false; |
| @@ -2214,9 +2231,12 @@ impl Render for Workspace { | ||
| 2214 | 2231 | Some(chat) => this.child(chat), |
| 2215 | 2232 | None => this.child(self.render_empty_sheet(cx)), |
| 2216 | 2233 | })) |
| 2217 | - .children(self.render_terminal_drawer(window, cx)), | |
| 2234 | + .children(self.render_terminal_drawer(false, window, cx)), | |
| 2218 | 2235 | ) |
| 2219 | 2236 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| 2237 | + .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx))) | |
| 2238 | + .when(picker_visible, |this| this.child(self.render_surface_picker(cx))) | |
| 2239 | + .children(docked_terminal) | |
| 2220 | 2240 | .when_some(browser, |this, browser| this.child(browser)), |
| 2221 | 2241 | ), |
| 2222 | 2242 | ), |
| @@ -0,0 +1,467 @@ | ||
| 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's agent | |
| 6 | + //! as a message the transcript doesn't show, which wakes it. | |
| 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's agent, which wakes it. | |
| 408 | + let title = child.read(cx).title.to_string(); | |
| 409 | + let mut message = format!( | |
| 410 | + "{} {task} (\"{title}\") {}.", | |
| 411 | + orchestrator::RESULT_PREFIX, | |
| 412 | + match task_state { | |
| 413 | + TaskState::Completed => "completed", | |
| 414 | + TaskState::Failed => "failed", | |
| 415 | + TaskState::Cancelled => "was stopped by the user", | |
| 416 | + _ => "stopped", | |
| 417 | + } | |
| 418 | + ); | |
| 419 | + match result.get("summary").and_then(Value::as_str) { | |
| 420 | + Some(summary) => message.push_str(&format!(" Its final answer:\n\n{summary}")), | |
| 421 | + None => message.push_str(" It gave no answer."), | |
| 422 | + } | |
| 423 | + parent.update(cx, |parent, cx| parent.send_hidden(message, cx)); | |
| 424 | + } | |
| 425 | + | |
| 426 | + /// Stops a delegated task from the subagents panel. Its parent's agent | |
| 427 | + /// hears that the user stopped it. | |
| 428 | + pub(super) fn stop_delegated(&mut self, task: &str, cx: &mut Context<Self>) { | |
| 429 | + let Some(child) = self.thread_by_id(task, cx) else { return }; | |
| 430 | + child.update(cx, |child, cx| { | |
| 431 | + if let Some(delegation) = child.delegation.as_mut() { | |
| 432 | + delegation.cancelled = true; | |
| 433 | + } | |
| 434 | + child.interrupt(cx); | |
| 435 | + cx.notify(); | |
| 436 | + }); | |
| 437 | + self.dirty.insert(child.entity_id()); | |
| 438 | + } | |
| 439 | + } | |
| 440 | + | |
| 441 | + /// What `orchestrator_capabilities` reports: every agent that's on, with | |
| 442 | + /// its models, and what the calling thread runs. | |
| 443 | + fn capabilities(thread: &Session, cx: &App) -> Value { | |
| 444 | + let instances: Vec<Value> = Catalog::global(cx) | |
| 445 | + .instances | |
| 446 | + .iter() | |
| 447 | + .filter(|state| state.config.enabled && !matches!(state.status, ProbeStatus::Disabled)) | |
| 448 | + .map(|state| { | |
| 449 | + let models: Vec<Value> = state | |
| 450 | + .models | |
| 451 | + .iter() | |
| 452 | + .map(|model| json!({"id": model.id, "name": model.name, "detail": model.detail})) | |
| 453 | + .collect(); | |
| 454 | + json!({ | |
| 455 | + "providerInstanceId": state.config.id, | |
| 456 | + "kind": state.config.kind.key(), | |
| 457 | + "name": if state.config.name.trim().is_empty() { state.config.kind.label().to_string() } else { state.config.name.clone() }, | |
| 458 | + "models": models, | |
| 459 | + }) | |
| 460 | + }) | |
| 461 | + .collect(); | |
| 462 | + json!({ | |
| 463 | + "current": {"providerInstanceId": thread.instance, "model": thread.model, "activeModel": thread.active_model}, | |
| 464 | + "providerInstances": instances, | |
| 465 | + "tools": [DELEGATE, TASK_STATUS, TASK_CANCEL, CAPABILITIES], | |
| 466 | + }) | |
| 467 | + } | |
| @@ -9,6 +9,7 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 9 | 9 | use gpui_kit::*; |
| 10 | 10 | |
| 11 | 11 | use super::Workspace; |
| 12 | + use super::surfaces::Surface; | |
| 12 | 13 | use crate::Dismiss; |
| 13 | 14 | use crate::ui::browser::BrowserWatch; |
| 14 | 15 | use crate::ui::icons::Lucide; |
| @@ -114,6 +115,28 @@ impl Workspace { | ||
| 114 | 115 | .child(div().max_w(px(160.)).truncate().font_weight(FontWeight::MEDIUM).child(project.name.clone())) |
| 115 | 116 | }); |
| 116 | 117 | |
| 118 | + // A subagent's thread leads back to the thread that delegated its task. | |
| 119 | + let parent = active | |
| 120 | + .and_then(|session| session.delegation.as_ref()) | |
| 121 | + .and_then(|delegation| self.local_thread(&delegation.parent, cx)) | |
| 122 | + .map(|(parent_ix, parent)| { | |
| 123 | + let parent_title = parent.read(cx).title.clone(); | |
| 124 | + let tip = SharedString::from(format!("Subagent of {parent_title}")); | |
| 125 | + h_flex() | |
| 126 | + .id("parent-thread") | |
| 127 | + .flex_none() | |
| 128 | + .gap(px(6.)) | |
| 129 | + .cursor_pointer() | |
| 130 | + .text_size(px(14.)) | |
| 131 | + .text_color(p.ink3) | |
| 132 | + .hover(|style| style.text_color(p.ink)) | |
| 133 | + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) | |
| 134 | + .on_click(cx.listener(move |this, _, window, cx| this.select(parent_ix, parent.clone(), window, cx))) | |
| 135 | + .child(div().max_w(px(200.)).truncate().child(parent_title)) | |
| 136 | + .child(div().text_color(p.icon).child("/")) | |
| 137 | + .child(Icon::new(Lucide::Bot).size(px(14.))) | |
| 138 | + }); | |
| 139 | + | |
| 117 | 140 | let title_element = title.map(|title| -> AnyElement { |
| 118 | 141 | if let Some(rename) = renaming { |
| 119 | 142 | return div() |
| @@ -202,6 +225,7 @@ impl Workspace { | ||
| 202 | 225 | .when_some(breadcrumb, |this, breadcrumb| { |
| 203 | 226 | this.child(breadcrumb).child(div().text_size(px(14.)).text_color(p.icon).child("/")) |
| 204 | 227 | }) |
| 228 | + .when_some(parent, |this, parent| this.child(parent)) | |
| 205 | 229 | .when_some(title_element, |this, title| this.child(title)) |
| 206 | 230 | .child(div().flex_1()) |
| 207 | 231 | .child(self.render_layout_controls(panel_visible, cx)), |
| @@ -210,11 +234,15 @@ impl Workspace { | ||
| 210 | 234 | } |
| 211 | 235 | |
| 212 | 236 | /// T3's panel toggles: thread details (our project panel), the terminal |
| 213 | - /// drawer, and the right panel (the browser). | |
| 237 | + /// drawer, and the right panel (the browser), plus the subagents panel. | |
| 214 | 238 | fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement { |
| 215 | 239 | let p = metal::palette(cx); |
| 216 | - let live = !self.browser_open | |
| 240 | + // While the panel shows something else, its light says subagents are | |
| 241 | + // working, or the agent is using the browser. | |
| 242 | + let subagents_working = !self.surface_open(Surface::Subagents) && self.working_subagents(cx) > 0; | |
| 243 | + let browser_live = !self.surface_open(Surface::Browser) | |
| 217 | 244 | && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx)); |
| 245 | + let live = subagents_working || browser_live; | |
| 218 | 246 | h_flex() |
| 219 | 247 | .flex_none() |
| 220 | 248 | .gap(px(4.)) |
| @@ -232,7 +260,7 @@ impl Workspace { | ||
| 232 | 260 | .ghost() |
| 233 | 261 | .mini() |
| 234 | 262 | .icon(IconName::PanelBottom) |
| 235 | - .selected(self.terminal_open()) | |
| 263 | + .selected(self.terminal_open() || self.surface_open(Surface::Terminal)) | |
| 236 | 264 | .tooltip("Toggle terminal drawer (Ctrl+J)") |
| 237 | 265 | .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx))) |
| 238 | 266 | .into_any_element() |
| @@ -252,15 +280,14 @@ impl Workspace { | ||
| 252 | 280 | .ghost() |
| 253 | 281 | .mini() |
| 254 | 282 | .icon(IconName::PanelRight) |
| 255 | - .selected(self.browser_open) | |
| 283 | + .selected(self.right_panel.is_some()) | |
| 256 | 284 | .tooltip("Toggle right panel (Ctrl+Alt+B)") |
| 257 | - .on_click(cx.listener(|this, _, _, cx| { | |
| 258 | - this.browser_open = !this.browser_open; | |
| 259 | - cx.notify(); | |
| 260 | - })), | |
| 285 | + .on_click(cx.listener(|this, _, window, cx| this.toggle_right_panel(window, cx))), | |
| 261 | 286 | ) |
| 262 | 287 | .when(live, |this| { |
| 263 | - this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("browser-live", Led::Live, false, p))) | |
| 288 | + this.child( | |
| 289 | + div().absolute().top(px(1.)).right(px(1.)).child(metal::led("right-panel-live", Led::Live, subagents_working, p)), | |
| 290 | + ) | |
| 264 | 291 | }), |
| 265 | 292 | ) |
| 266 | 293 | } |
| @@ -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 | }) |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.