| @@ -113,19 +113,142 @@ pub struct ToolCall { |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | 115 | impl ToolCall { |
| 116 | | - /// Where a subagent stands: running until its tool returns, or, sent to |
| 117 | | - /// the background, until its task ends. `None` for other tools. |
| 118 | | - pub fn subagent_status(&self) -> Option<TaskStatus> { |
| 119 | | - self.subagent.as_ref()?; |
| 120 | | - Some(match (self.background, self.state) { |
| 116 | + /// The subagents this call started, as the subagents panel lists them: |
| 117 | + /// one for Claude's Task or Agent call, one per task for Pi's `subagent` |
| 118 | + /// extension, and the agents a jcode `swarm` spawn started. |
| 119 | + pub fn subagents(&self) -> Vec<SubagentEntry> { |
| 120 | + let Some(progress) = &self.subagent else { return Vec::new() }; |
| 121 | + let call_running = self.state == ToolState::Running; |
| 122 | + if !progress.members.is_empty() { |
| 123 | + return progress |
| 124 | + .members |
| 125 | + .iter() |
| 126 | + .map(|member| { |
| 127 | + let mut status = TaskStatus::from_cli(&member.status); |
| 128 | + // A Pi subagent runs inside its call; a swarm agent |
| 129 | + // outlives the call that spawned it. |
| 130 | + if status == TaskStatus::Running && !call_running && self.name != SWARM_TOOL { |
| 131 | + status = TaskStatus::Stopped; |
| 132 | + } |
| 133 | + let running = status == TaskStatus::Running; |
| 134 | + SubagentEntry { |
| 135 | + label: member.label.clone(), |
| 136 | + kind: member.kind.clone(), |
| 137 | + status, |
| 138 | + background: false, |
| 139 | + tool_uses: member.tool_uses, |
| 140 | + tokens: member.tokens, |
| 141 | + elapsed: elapsed_between(member.started, member.finished, running), |
| 142 | + tool: member.tool.clone().filter(|_| running), |
| 143 | + summary: member.summary.clone(), |
| 144 | + } |
| 145 | + }) |
| 146 | + .collect(); |
| 147 | + } |
| 148 | + // Only a swarm call that spawns an agent starts one. |
| 149 | + if self.name == SWARM_TOOL && tool_field(&self.input, &["action"]).as_deref() != Some("spawn") { |
| 150 | + return Vec::new(); |
| 151 | + } |
| 152 | + let status = match (self.background, self.state) { |
| 121 | 153 | (Some(status), ToolState::Running | ToolState::Done) => status, |
| 122 | 154 | (_, ToolState::Error) => TaskStatus::Failed, |
| 123 | 155 | (None, ToolState::Running) => TaskStatus::Running, |
| 124 | 156 | (None, ToolState::Done) => TaskStatus::Completed, |
| 125 | | - }) |
| 157 | + }; |
| 158 | + let running = status == TaskStatus::Running; |
| 159 | + let label = tool_field(&self.input, &["description", "label", "task"]) |
| 160 | + .filter(|label| !label.trim().is_empty()) |
| 161 | + .or_else(|| { |
| 162 | + let tasks = self.input.get("tasks").or_else(|| self.input.get("chain"))?.as_array()?.len(); |
| 163 | + Some(format!("{tasks} subagents")) |
| 164 | + }) |
| 165 | + .unwrap_or_else(|| "Subagent".into()); |
| 166 | + vec![SubagentEntry { |
| 167 | + label, |
| 168 | + kind: tool_field(&self.input, &["subagent_type", "agent"]), |
| 169 | + status, |
| 170 | + background: self.background.is_some(), |
| 171 | + tool_uses: self.subagent_tool_count, |
| 172 | + tokens: progress.tokens, |
| 173 | + elapsed: elapsed_between(progress.started, progress.finished, running), |
| 174 | + tool: progress.tool.clone().filter(|_| running), |
| 175 | + summary: progress.summary.clone(), |
| 176 | + }] |
| 126 | 177 | } |
| 127 | 178 | } |
| 128 | 179 | |
| 180 | + /// A subagent as the subagents panel shows it. |
| 181 | + pub struct SubagentEntry { |
| 182 | + /// What it was asked to do. |
| 183 | + pub label: String, |
| 184 | + /// The kind of agent, e.g. `Explore`. |
| 185 | + pub kind: Option<String>, |
| 186 | + pub status: TaskStatus, |
| 187 | + pub background: bool, |
| 188 | + pub tool_uses: usize, |
| 189 | + pub tokens: Option<u64>, |
| 190 | + pub elapsed: Option<Duration>, |
| 191 | + /// Its latest tool call, while it works. |
| 192 | + pub tool: Option<(String, Value)>, |
| 193 | + pub summary: Option<String>, |
| 194 | + } |
| 195 | + |
| 196 | + /// jcode's tool for running a swarm of agents, each in a session of its own. |
| 197 | + const SWARM_TOOL: &str = "swarm"; |
| 198 | + /// How often the app checks on a thread's working swarm agents. |
| 199 | + const SWARM_POLL: Duration = Duration::from_secs(3); |
| 200 | + /// The jcode requests that check on swarm agents, by what they're for. |
| 201 | + const LIST_AGENTS: &str = "list the swarm's agents"; |
| 202 | + const PEEK_AGENT: &str = "read a swarm agent's progress"; |
| 203 | + |
| 204 | + /// One agent among several a call started: a task of Pi's `subagent` call, |
| 205 | + /// or an agent a jcode `swarm` call spawned. |
| 206 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| 207 | + pub struct SubagentMember { |
| 208 | + /// The task's place in a Pi call, or the jcode agent's session id. |
| 209 | + pub id: String, |
| 210 | + pub label: String, |
| 211 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 212 | + pub kind: Option<String>, |
| 213 | + /// `running`, `completed`, `failed` or `stopped`. |
| 214 | + pub status: String, |
| 215 | + pub started: u64, |
| 216 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 217 | + pub finished: Option<u64>, |
| 218 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 219 | + pub tokens: Option<u64>, |
| 220 | + #[serde(default)] |
| 221 | + pub tool_uses: usize, |
| 222 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 223 | + pub tool: Option<(String, Value)>, |
| 224 | + #[serde(default, skip_serializing_if = "Option::is_none")] |
| 225 | + pub summary: Option<String>, |
| 226 | + } |
| 227 | + |
| 228 | + impl SubagentMember { |
| 229 | + /// Moves it to `status`, noting when it stopped running (or started |
| 230 | + /// again: a swarm agent can be handed more work). |
| 231 | + fn set_status(&mut self, status: TaskStatus) { |
| 232 | + self.status = status.key().to_string(); |
| 233 | + if status == TaskStatus::Running { |
| 234 | + self.finished = None; |
| 235 | + } else { |
| 236 | + self.finished.get_or_insert_with(|| to_millis(SystemTime::now())); |
| 237 | + } |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + /// How long something ran from `started` to `finished` (or now, while it |
| 242 | + /// runs), in milliseconds since the epoch; `None` when that isn't known. |
| 243 | + fn elapsed_between(started: u64, finished: Option<u64>, running: bool) -> Option<Duration> { |
| 244 | + let end = match finished { |
| 245 | + Some(finished) => finished, |
| 246 | + None if running => to_millis(SystemTime::now()), |
| 247 | + None => return None, |
| 248 | + }; |
| 249 | + (started > 0).then(|| Duration::from_millis(end.saturating_sub(started))) |
| 250 | + } |
| 251 | + |
| 129 | 252 | /// A subagent's progress, for the subagents panel. It's saved with the |
| 130 | 253 | /// thread, so it reaches remote clients too. |
| 131 | 254 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] |
| @@ -143,28 +266,107 @@ pub struct SubagentProgress { |
| 143 | 266 | /// The CLI's running summary of what it's doing. |
| 144 | 267 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 145 | 268 | pub summary: Option<String>, |
| 269 | + /// The agents it started, when it starts several or they live apart |
| 270 | + /// from it (Pi's `subagent` tasks, jcode's swarm agents). |
| 271 | + #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 272 | + pub members: Vec<SubagentMember>, |
| 146 | 273 | } |
| 147 | 274 | |
| 148 | 275 | impl SubagentProgress { |
| 149 | 276 | fn finish(&mut self) { |
| 150 | 277 | self.finished.get_or_insert_with(|| to_millis(SystemTime::now())); |
| 151 | 278 | } |
| 279 | + } |
| 280 | + |
| 281 | + /// Whether a tool starts subagents: Claude's Task and Agent, Pi's |
| 282 | + /// `subagent` extension (and jcode's old tool of that name), and jcode's |
| 283 | + /// swarm, whose spawns do. |
| 284 | + pub fn is_subagent_tool(name: &str) -> bool { |
| 285 | + matches!(name, "Task" | "Agent" | "subagent" | SWARM_TOOL) |
| 286 | + } |
| 287 | + |
| 288 | + /// The tool call a model's message ends on, from Pi's or jcode's message |
| 289 | + /// content: its name and its input's short text fields. |
| 290 | + fn last_tool_call(content: &Value) -> Option<(String, Value)> { |
| 291 | + content.as_array()?.iter().rev().find_map(|block| { |
| 292 | + (block.get("type").and_then(Value::as_str) == Some("toolCall")).then(|| { |
| 293 | + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool").to_string(); |
| 294 | + (name, brief_input(&block["arguments"])) |
| 295 | + }) |
| 296 | + }) |
| 297 | + } |
| 152 | 298 | |
| 153 | | - /// How long it has run, or ran, if that's known: threads saved before |
| 154 | | - /// subagents were timed, and ones cut off by a restart, don't say. |
| 155 | | - pub fn elapsed(&self, running: bool) -> Option<Duration> { |
| 156 | | - let end = match self.finished { |
| 157 | | - Some(finished) => finished, |
| 158 | | - None if running => to_millis(SystemTime::now()), |
| 159 | | - None => return None, |
| 299 | + /// Folds the results Pi's `subagent` extension reports (one per task, in |
| 300 | + /// `details.results`) into the call's members. |
| 301 | + fn pi_subagent_results(tool: &mut ToolCall, details: Option<&Value>) { |
| 302 | + let (Some(progress), Some(results)) = |
| 303 | + (tool.subagent.as_mut(), details.and_then(|details| details.get("results")).and_then(Value::as_array)) |
| 304 | + else { |
| 305 | + return; |
| 306 | + }; |
| 307 | + let now = to_millis(SystemTime::now()); |
| 308 | + for (ix, result) in results.iter().enumerate() { |
| 309 | + let id = ix.to_string(); |
| 310 | + if !progress.members.iter().any(|member| member.id == id) { |
| 311 | + progress.members.push(SubagentMember { id: id.clone(), started: now, ..Default::default() }); |
| 312 | + } |
| 313 | + let Some(member) = progress.members.iter_mut().find(|member| member.id == id) else { continue }; |
| 314 | + let text = |key: &str| result.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()); |
| 315 | + member.label = text("task") |
| 316 | + .and_then(|task| task.lines().map(str::trim).find(|line| !line.is_empty())) |
| 317 | + .map(|line| line.chars().take(200).collect()) |
| 318 | + .unwrap_or_else(|| "Subagent".into()); |
| 319 | + member.kind = text("agent").map(str::to_string); |
| 320 | + let messages: Vec<&Value> = result.get("messages").and_then(Value::as_array).into_iter().flatten().collect(); |
| 321 | + let content = |message: &&Value| message.get("content").cloned().unwrap_or(Value::Null); |
| 322 | + member.tool_uses = messages |
| 323 | + .iter() |
| 324 | + .filter_map(|message| message.get("content")?.as_array()) |
| 325 | + .flatten() |
| 326 | + .filter(|block| block.get("type").and_then(Value::as_str) == Some("toolCall")) |
| 327 | + .count(); |
| 328 | + member.tool = messages.iter().rev().find_map(|message| last_tool_call(&content(message))); |
| 329 | + member.summary = messages |
| 330 | + .iter() |
| 331 | + .rev() |
| 332 | + .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) |
| 333 | + .find_map(|message| last_line(&tool_result_text(&content(message)))); |
| 334 | + let usage = &result["usage"]; |
| 335 | + let tokens: u64 = ["input", "output", "cacheRead", "cacheWrite"] |
| 336 | + .iter() |
| 337 | + .filter_map(|key| usage.get(*key).and_then(Value::as_u64)) |
| 338 | + .sum(); |
| 339 | + member.tokens = (tokens > 0).then_some(tokens); |
| 340 | + let exit = result.get("exitCode").and_then(Value::as_i64).unwrap_or(-1); |
| 341 | + let status = match text("stopReason") { |
| 342 | + _ if exit < 0 => TaskStatus::Running, |
| 343 | + Some("aborted") => TaskStatus::Stopped, |
| 344 | + Some("error") => TaskStatus::Failed, |
| 345 | + _ if exit != 0 => TaskStatus::Failed, |
| 346 | + _ => TaskStatus::Completed, |
| 160 | 347 | }; |
| 161 | | - (self.started > 0).then(|| Duration::from_millis(end.saturating_sub(self.started))) |
| 348 | + member.set_status(status); |
| 162 | 349 | } |
| 163 | 350 | } |
| 164 | 351 | |
| 165 | | - /// Whether a tool starts a subagent. |
| 166 | | - pub fn is_subagent_tool(name: &str) -> bool { |
| 167 | | - matches!(name, "Task" | "Agent") |
| 352 | + /// A swarm agent's state from jcode's session list: its swarm lifecycle |
| 353 | + /// status, or failing that whether its session is mid-turn. `ready` is an |
| 354 | + /// agent done with its work and waiting for more. |
| 355 | + fn jcode_agent_status(swarm: Option<&str>, session: Option<&str>) -> TaskStatus { |
| 356 | + match swarm { |
| 357 | + Some("completed" | "done" | "ready") => TaskStatus::Completed, |
| 358 | + Some("failed" | "crashed") => TaskStatus::Failed, |
| 359 | + Some("stopped" | "closed" | "disconnected") => TaskStatus::Stopped, |
| 360 | + Some(_) => TaskStatus::Running, |
| 361 | + None if session == Some("running") => TaskStatus::Running, |
| 362 | + None => TaskStatus::Completed, |
| 363 | + } |
| 364 | + } |
| 365 | + |
| 366 | + /// The last line of what an agent said, for its summary. |
| 367 | + fn last_line(text: &str) -> Option<String> { |
| 368 | + let line = text.lines().rev().map(str::trim).find(|line| !line.is_empty())?; |
| 369 | + Some(line.chars().take(200).collect()) |
| 168 | 370 | } |
| 169 | 371 | |
| 170 | 372 | /// A tool input cut down to its short text fields: enough to say what the |
| @@ -544,6 +746,8 @@ struct JcodeLink { |
| 544 | 746 | provider: Option<String>, |
| 545 | 747 | /// The browser panel's instructions, sent along with the next message. |
| 546 | 748 | browser_note: Option<String>, |
| 749 | + /// Checks on the swarm agents the thread spawned, while any work. |
| 750 | + agent_poll: Option<Task<()>>, |
| 547 | 751 | } |
| 548 | 752 | |
| 549 | 753 | pub struct Session { |
| @@ -2718,13 +2922,24 @@ impl Session { |
| 2718 | 2922 | ); |
| 2719 | 2923 | } |
| 2720 | 2924 | } |
| 2925 | + // Pi's `subagent` extension reports its tasks as they go. |
| 2926 | + Some("tool_execution_update") => { |
| 2927 | + let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default(); |
| 2928 | + if let Some(tool) = self.find_tool_mut(id) { |
| 2929 | + pi_subagent_results(tool, message.pointer("/partialResult/details")); |
| 2930 | + } |
| 2931 | + } |
| 2721 | 2932 | Some("tool_execution_end") => { |
| 2722 | 2933 | let id = message.get("toolCallId").and_then(Value::as_str).unwrap_or_default(); |
| 2723 | 2934 | let is_error = message.get("isError").and_then(Value::as_bool).unwrap_or(false); |
| 2724 | 2935 | let text = tool_result_text(message.pointer("/result/content").unwrap_or(&Value::Null)); |
| 2725 | 2936 | if let Some(tool) = self.find_tool_mut(id) { |
| 2937 | + pi_subagent_results(tool, message.pointer("/result/details")); |
| 2726 | 2938 | tool.result = Some(text); |
| 2727 | 2939 | tool.state = if is_error { ToolState::Error } else { ToolState::Done }; |
| 2940 | + if let Some(progress) = tool.subagent.as_mut() { |
| 2941 | + progress.finish(); |
| 2942 | + } |
| 2728 | 2943 | } |
| 2729 | 2944 | } |
| 2730 | 2945 | Some("agent_settled") => { |
| @@ -2956,10 +3171,29 @@ impl Session { |
| 2956 | 3171 | for (what, request) in std::mem::take(&mut self.jcode.held) { |
| 2957 | 3172 | self.jcode_request(what, request); |
| 2958 | 3173 | } |
| 3174 | + // Swarm agents carry on in jcode while the app is away. |
| 3175 | + self.watch_swarm(cx); |
| 3176 | + } |
| 3177 | + "sessions" if request == Some(LIST_AGENTS) => self.update_swarm(&message["sessions"]), |
| 3178 | + "history" if request == Some(PEEK_AGENT) => { |
| 3179 | + let agent = text("session_id"); |
| 3180 | + let said = message |
| 3181 | + .get("messages") |
| 3182 | + .and_then(Value::as_array) |
| 3183 | + .into_iter() |
| 3184 | + .flatten() |
| 3185 | + .rev() |
| 3186 | + .filter(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) |
| 3187 | + .find_map(|message| last_line(message.get("content").and_then(Value::as_str).unwrap_or_default())); |
| 3188 | + if let Some(member) = self.swarm_member_mut(agent) { |
| 3189 | + member.summary = said.or(member.summary.take()); |
| 3190 | + } |
| 2959 | 3191 | } |
| 2960 | 3192 | "error" => { |
| 2961 | 3193 | let error = text("message"); |
| 2962 | 3194 | match request { |
| 3195 | + // Checking on swarm agents is best effort, and repeats. |
| 3196 | + Some(LIST_AGENTS | PEEK_AGENT) => {} |
| 2963 | 3197 | // The session is gone from jcode's store; carry on in a new one. |
| 2964 | 3198 | Some("attach") => { |
| 2965 | 3199 | self.push_notice( |
| @@ -3046,11 +3280,30 @@ impl Session { |
| 3046 | 3280 | }; |
| 3047 | 3281 | take_partial_input(tool); |
| 3048 | 3282 | tool.state = if error.is_some() { ToolState::Error } else { ToolState::Done }; |
| 3283 | + // A swarm spawn names the agent's session: watch it from here. |
| 3284 | + let spawned = (tool.name == SWARM_TOOL && error.is_none()) |
| 3285 | + .then(|| output.trim().strip_prefix("Spawned new agent:").map(|id| id.trim().to_string())) |
| 3286 | + .flatten() |
| 3287 | + .filter(|id| !id.is_empty()); |
| 3288 | + if let (Some(agent), Some(progress)) = (spawned, tool.subagent.as_mut()) { |
| 3289 | + let label = tool_field(&tool.input, &["label", "prompt"]) |
| 3290 | + .and_then(|label| label.lines().next().map(|line| line.chars().take(200).collect())) |
| 3291 | + .unwrap_or_else(|| "Agent".into()); |
| 3292 | + progress.members.push(SubagentMember { |
| 3293 | + id: agent, |
| 3294 | + label, |
| 3295 | + kind: tool_field(&tool.input, &["role"]).filter(|role| role != "agent"), |
| 3296 | + status: TaskStatus::Running.key().to_string(), |
| 3297 | + started: progress.started, |
| 3298 | + ..Default::default() |
| 3299 | + }); |
| 3300 | + } |
| 3049 | 3301 | tool.result = Some(match error { |
| 3050 | 3302 | Some(error) if output.trim().is_empty() => error, |
| 3051 | 3303 | Some(error) => format!("{output}\n\n{error}"), |
| 3052 | 3304 | None => output, |
| 3053 | 3305 | }); |
| 3306 | + self.watch_swarm(cx); |
| 3054 | 3307 | } |
| 3055 | 3308 | "token_usage" => { |
| 3056 | 3309 | let count = |key: &str| message.get(key).and_then(Value::as_u64).unwrap_or(0); |
| @@ -3124,6 +3377,130 @@ impl Session { |
| 3124 | 3377 | }) |
| 3125 | 3378 | } |
| 3126 | 3379 | |
| 3380 | + /// The swarm agents this thread spawned, across its swarm calls. |
| 3381 | + fn swarm_members(&self) -> impl Iterator<Item = &SubagentMember> { |
| 3382 | + self.items.iter().filter_map(|item| match item { |
| 3383 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_ref(), |
| 3384 | + _ => None, |
| 3385 | + }) |
| 3386 | + .flat_map(|progress| &progress.members) |
| 3387 | + } |
| 3388 | + |
| 3389 | + fn swarm_member_mut(&mut self, agent: &str) -> Option<&mut SubagentMember> { |
| 3390 | + self.items |
| 3391 | + .iter_mut() |
| 3392 | + .filter_map(|item| match item { |
| 3393 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(), |
| 3394 | + _ => None, |
| 3395 | + }) |
| 3396 | + .flat_map(|progress| progress.members.iter_mut()) |
| 3397 | + .find(|member| member.id == agent) |
| 3398 | + } |
| 3399 | + |
| 3400 | + /// Whether the thread has swarm agents to keep an eye on: ones that work, |
| 3401 | + /// or a spawn that hasn't named its agent yet. |
| 3402 | + fn swarm_busy(&self) -> bool { |
| 3403 | + self.swarm_members().any(|member| member.status == TaskStatus::Running.key()) |
| 3404 | + || self.items.iter().any(|item| { |
| 3405 | + matches!(item, ChatItem::Tool(tool) if tool.name == SWARM_TOOL && tool.state == ToolState::Running) |
| 3406 | + }) |
| 3407 | + } |
| 3408 | + |
| 3409 | + /// Starts checking on the thread's swarm agents every few seconds, if |
| 3410 | + /// any work and it isn't already. jcode's API doesn't stream another |
| 3411 | + /// session's progress, so the app asks: the session list says where each |
| 3412 | + /// agent stands, and a peek at its transcript what it last said. |
| 3413 | + fn watch_swarm(&mut self, cx: &mut Context<Self>) { |
| 3414 | + if self.jcode.agent_poll.is_some() || !self.swarm_busy() { |
| 3415 | + return; |
| 3416 | + } |
| 3417 | + self.jcode.agent_poll = Some(cx.spawn(async move |this, cx| { |
| 3418 | + loop { |
| 3419 | + cx.background_executor().timer(SWARM_POLL).await; |
| 3420 | + let busy = this.update(cx, |this, _| { |
| 3421 | + let busy = this.process.is_some() && this.swarm_busy(); |
| 3422 | + if busy { |
| 3423 | + this.poll_swarm(); |
| 3424 | + } else if let Some(poll) = this.jcode.agent_poll.take() { |
| 3425 | + // This very loop: let it end on its own. |
| 3426 | + poll.detach(); |
| 3427 | + } |
| 3428 | + busy |
| 3429 | + }); |
| 3430 | + if !matches!(busy, Ok(true)) { |
| 3431 | + break; |
| 3432 | + } |
| 3433 | + } |
| 3434 | + })); |
| 3435 | + } |
| 3436 | + |
| 3437 | + fn poll_swarm(&mut self) { |
| 3438 | + self.jcode_send(LIST_AGENTS, json!({"req": "list_sessions", "limit": 200})); |
| 3439 | + let working: Vec<String> = self |
| 3440 | + .swarm_members() |
| 3441 | + .filter(|member| member.status == TaskStatus::Running.key()) |
| 3442 | + .map(|member| member.id.clone()) |
| 3443 | + .collect(); |
| 3444 | + for agent in working { |
| 3445 | + self.jcode_send(PEEK_AGENT, json!({"req": "peek_session", "session_id": agent, "limit": 4})); |
| 3446 | + } |
| 3447 | + } |
| 3448 | + |
| 3449 | + /// Takes in where the thread's swarm agents stand, from jcode's session |
| 3450 | + /// list. Agents another agent of the thread spawned list too, under the |
| 3451 | + /// latest swarm call. |
| 3452 | + fn update_swarm(&mut self, sessions: &Value) { |
| 3453 | + let Some(own) = self.jcode.session.clone() else { return }; |
| 3454 | + let Some(sessions) = sessions.as_array() else { return }; |
| 3455 | + let ours: HashSet<String> = self.swarm_members().map(|member| member.id.clone()).collect(); |
| 3456 | + let mut family: HashSet<String> = ours.iter().cloned().chain([own.clone()]).collect(); |
| 3457 | + // Spawned by this session, or by one of its agents. |
| 3458 | + loop { |
| 3459 | + let before = family.len(); |
| 3460 | + for session in sessions { |
| 3461 | + let parent = session.get("parent_session_id").and_then(Value::as_str).unwrap_or_default(); |
| 3462 | + if let Some(id) = session.get("session_id").and_then(Value::as_str) |
| 3463 | + && family.contains(parent) |
| 3464 | + { |
| 3465 | + family.insert(id.to_string()); |
| 3466 | + } |
| 3467 | + } |
| 3468 | + if family.len() == before { |
| 3469 | + break; |
| 3470 | + } |
| 3471 | + } |
| 3472 | + for session in sessions { |
| 3473 | + let Some(id) = session.get("session_id").and_then(Value::as_str) else { continue }; |
| 3474 | + if id == own || !family.contains(id) { |
| 3475 | + continue; |
| 3476 | + } |
| 3477 | + let text = |key: &str| session.get(key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()); |
| 3478 | + let status = jcode_agent_status(text("swarm_status"), text("status")); |
| 3479 | + if self.swarm_member_mut(id).is_none() { |
| 3480 | + // An agent the app didn't see spawned: file it under the latest swarm call. |
| 3481 | + let Some(progress) = self.items.iter_mut().rev().find_map(|item| match item { |
| 3482 | + ChatItem::Tool(tool) if tool.name == SWARM_TOOL => tool.subagent.as_mut(), |
| 3483 | + _ => None, |
| 3484 | + }) else { |
| 3485 | + return; |
| 3486 | + }; |
| 3487 | + progress.members.push(SubagentMember { |
| 3488 | + id: id.to_string(), |
| 3489 | + started: to_millis(SystemTime::now()), |
| 3490 | + ..Default::default() |
| 3491 | + }); |
| 3492 | + } |
| 3493 | + let Some(member) = self.swarm_member_mut(id) else { continue }; |
| 3494 | + if let Some(label) = text("agent_label").or(text("title")) { |
| 3495 | + member.label = label.to_string(); |
| 3496 | + } |
| 3497 | + if member.label.is_empty() { |
| 3498 | + member.label = "Agent".into(); |
| 3499 | + } |
| 3500 | + member.set_status(status); |
| 3501 | + } |
| 3502 | + } |
| 3503 | + |
| 3127 | 3504 | // ---- helpers ----------------------------------------------------- |
| 3128 | 3505 | |
| 3129 | 3506 | fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize { |
| @@ -4155,7 +4532,66 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String |
| 4155 | 4532 | |
| 4156 | 4533 | #[cfg(test)] |
| 4157 | 4534 | mod tests { |
| 4158 | | - use super::finished_prefix; |
| 4535 | + use super::{TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; |
| 4536 | + use serde_json::{Value, json}; |
| 4537 | + |
| 4538 | + fn tool(name: &str, input: Value) -> ToolCall { |
| 4539 | + ToolCall { |
| 4540 | + id: "call".into(), |
| 4541 | + name: name.into(), |
| 4542 | + input, |
| 4543 | + partial_input: String::new(), |
| 4544 | + result: None, |
| 4545 | + state: ToolState::Running, |
| 4546 | + permission: None, |
| 4547 | + expanded: false, |
| 4548 | + subagent_tool_count: 0, |
| 4549 | + detached: false, |
| 4550 | + ask: None, |
| 4551 | + task_id: None, |
| 4552 | + background: None, |
| 4553 | + subagent: super::is_subagent_tool(name).then(super::SubagentProgress::default), |
| 4554 | + } |
| 4555 | + } |
| 4556 | + |
| 4557 | + #[::core::prelude::v1::test] |
| 4558 | + fn pi_parallel_subagents() { |
| 4559 | + let mut call = tool("subagent", json!({"tasks": [{"agent": "scout", "task": "Find it"}, {"agent": "fixer", "task": "Fix it"}]})); |
| 4560 | + let details = json!({"mode": "parallel", "results": [ |
| 4561 | + {"agent": "scout", "task": "Find it\nthoroughly", "exitCode": -1, |
| 4562 | + "usage": {"input": 1000, "output": 200}, |
| 4563 | + "messages": [{"role": "assistant", "content": [ |
| 4564 | + {"type": "text", "text": "Looking around"}, |
| 4565 | + {"type": "toolCall", "id": "a", "name": "read", "arguments": {"path": "src/main.rs", "content": 7}}]}]}, |
| 4566 | + {"agent": "fixer", "task": "Fix it", "exitCode": 1, "stopReason": "error", "messages": []}, |
| 4567 | + ]}); |
| 4568 | + pi_subagent_results(&mut call, Some(&details)); |
| 4569 | + let entries = call.subagents(); |
| 4570 | + assert_eq!(entries.len(), 2); |
| 4571 | + assert_eq!(entries[0].label, "Find it"); |
| 4572 | + assert_eq!(entries[0].kind.as_deref(), Some("scout")); |
| 4573 | + assert_eq!(entries[0].status, TaskStatus::Running); |
| 4574 | + assert_eq!(entries[0].tokens, Some(1200)); |
| 4575 | + assert_eq!(entries[0].tool_uses, 1); |
| 4576 | + assert_eq!(entries[0].tool, Some(("read".into(), json!({"path": "src/main.rs"})))); |
| 4577 | + assert_eq!(entries[0].summary.as_deref(), Some("Looking around")); |
| 4578 | + assert_eq!(entries[1].status, TaskStatus::Failed); |
| 4579 | + // Once the call is over, a task still marked running was cut off. |
| 4580 | + call.state = ToolState::Done; |
| 4581 | + assert_eq!(call.subagents()[0].status, TaskStatus::Stopped); |
| 4582 | + } |
| 4583 | + |
| 4584 | + #[::core::prelude::v1::test] |
| 4585 | + fn swarm_calls() { |
| 4586 | + // Only spawns start agents; talking to the swarm doesn't. |
| 4587 | + assert!(tool("swarm", json!({"action": "message", "message": "hi"})).subagents().is_empty()); |
| 4588 | + let spawn = tool("swarm", json!({"action": "spawn", "label": "Refactor"})); |
| 4589 | + assert_eq!(spawn.subagents()[0].label, "Refactor"); |
| 4590 | + assert_eq!(jcode_agent_status(Some("running"), None), TaskStatus::Running); |
| 4591 | + assert_eq!(jcode_agent_status(Some("ready"), Some("idle")), TaskStatus::Completed); |
| 4592 | + assert_eq!(jcode_agent_status(Some("crashed"), None), TaskStatus::Failed); |
| 4593 | + assert_eq!(jcode_agent_status(None, Some("running")), TaskStatus::Running); |
| 4594 | + } |
| 4159 | 4595 | |
| 4160 | 4596 | #[::core::prelude::v1::test] |
| 4161 | 4597 | fn finished_paragraphs() { |