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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
383 changes: 322 additions & 61 deletions crates/agent/src/claude.rs

Large diffs are not rendered by default.

126 changes: 109 additions & 17 deletions crates/agent/src/subagent_tail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@ use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::claude::{is_agent_tool, one_line_summary, spawned_subagent};
use crate::{AgentEvent, ItemContent, ItemStatus, ThreadItem};

enum ChildTool {
Tool {
name: String,
input: Value,
},
/// An `Agent` call inside the subagent: a nested subagent whose own
/// transcript arrives keyed by this tool_use id.
Subagent {
content: ItemContent,
background: bool,
},
}

/// Stateful mapper for one subagent transcript. Tool calls are retained until
/// their matching result arrives so completion snapshots keep the original input.
pub(crate) struct TranscriptMapper {
parent_id: String,
tools: HashMap<String, (String, Value)>,
tools: HashMap<String, ChildTool>,
next_user_id: u64,
/// Latest model/effort seen on an assistant record, until taken.
model: Option<(Option<String>, Option<String>)>,
Expand Down Expand Up @@ -90,8 +104,29 @@ impl TranscriptMapper {
.unwrap_or("tool")
.to_owned();
let input = block.get("input").cloned().unwrap_or(Value::Null);
self.tools
.insert(id.to_owned(), (name.clone(), input.clone()));
if is_agent_tool(&name.to_lowercase()) {
let content = spawned_subagent(&input);
let background = input
.get("run_in_background")
.and_then(Value::as_bool)
.unwrap_or(false);
self.tools.insert(
id.to_owned(),
ChildTool::Subagent {
content: content.clone(),
background,
},
);
events.push(AgentEvent::ItemStarted(self.nested_subagent(id, content)));
continue;
}
self.tools.insert(
id.to_owned(),
ChildTool::Tool {
name: name.clone(),
input: input.clone(),
},
);
events.push(AgentEvent::ItemStarted(self.item(
id,
ItemContent::ToolCall {
Expand Down Expand Up @@ -143,26 +178,63 @@ impl TranscriptMapper {
let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
let Some((name, input)) = self.tools.remove(id) else {
let Some(tool) = self.tools.remove(id) else {
continue;
};
let failed = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
events.push(AgentEvent::ItemCompleted(self.item(
id,
ItemContent::ToolCall {
name,
input,
output: Some(content_text(block.get("content"))),
status: if failed {
ItemStatus::Failed
} else {
ItemStatus::Completed
},
},
)));
let output = content_text(block.get("content"));
let status = if failed {
ItemStatus::Failed
} else {
ItemStatus::Completed
};
match tool {
ChildTool::Tool { name, input } => {
events.push(AgentEvent::ItemCompleted(self.item(
id,
ItemContent::ToolCall {
name,
input,
output: Some(output),
status,
},
)));
}
ChildTool::Subagent {
content,
background,
} => {
// A background launch only acknowledges the spawn; the
// nested subagent settles through its own task_notification.
if background || launch_acknowledged(value, &output) {
continue;
}
let ItemContent::Subagent {
agent_type,
description,
model,
effort,
..
} = content
else {
continue;
};
events.push(AgentEvent::ItemCompleted(self.nested_subagent(
id,
ItemContent::Subagent {
agent_type,
description,
status,
summary: (!output.trim().is_empty()).then(|| one_line_summary(&output)),
model,
effort,
},
)));
}
}
}
events
}
Expand All @@ -174,6 +246,26 @@ impl TranscriptMapper {
content,
}
}

/// A nested spawn keeps its bare tool_use id: the grandchild's transcript
/// lines name it as their `parent_tool_use_id`.
fn nested_subagent(&self, tool_use_id: &str, content: ItemContent) -> ThreadItem {
ThreadItem {
id: tool_use_id.to_owned(),
parent_item_id: Some(self.parent_id.clone()),
content,
}
}
}

/// Whether a nested Agent result only reports an asynchronous launch. Claude
/// writes `toolUseResult.status` for top-level agents but omits it from
/// subagent transcripts, where the acknowledgement text is the only marker.
fn launch_acknowledged(value: &Value, output: &str) -> bool {
["/tool_use_result/status", "/toolUseResult/status"]
.iter()
.any(|pointer| value.pointer(pointer).and_then(Value::as_str) == Some("async_launched"))
|| output.starts_with("Async agent launched successfully")
}

/// Incremental reader used both by the polling task and deterministic tests.
Expand Down
15 changes: 15 additions & 0 deletions crates/agent/tests/fixtures/claude/subagent_nested_trace.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{"type":"assistant","message":{"id":"msg-spawn","content":[{"type":"tool_use","id":"toolu_child","name":"Agent","input":{"description":"Research hosting","prompt":"Research hosting options and report.","subagent_type":"general-purpose","run_in_background":true}}]}}
{"type":"system","subtype":"task_started","task_id":"task-child","tool_use_id":"toolu_child","description":"Research hosting","subagent_type":"general-purpose","task_type":"local_agent","prompt":"Research hosting options and report.","session_id":"session-nested"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_child","content":[{"type":"text","text":"Async agent launched successfully. (This tool result is internal metadata.)\nagentId: a-child\nThe agent is working in the background. You will be notified automatically when it completes."}],"is_error":false}]},"tool_use_result":{"isAsync":true,"status":"async_launched","agentId":"a-child","description":"Research hosting","resolvedModel":"claude-sonnet-5"}}
{"type":"result","subtype":"success","is_error":false,"result":"Started the research in the background.","usage":{"input_tokens":10,"output_tokens":5}}
{"type":"user","parent_tool_use_id":"toolu_child","message":{"role":"user","content":"Research hosting options and report."}}
{"type":"assistant","parent_tool_use_id":"toolu_child","effort":"high","message":{"id":"msg-child-1","model":"claude-sonnet-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_grandchild","name":"Agent","input":{"description":"Research Workers limits","subagent_type":"general-purpose","run_in_background":true,"prompt":"Fetch the Workers limits page and report the free-plan request cap."}}]}}
{"type":"system","subtype":"task_started","task_id":"task-grandchild","tool_use_id":"toolu_grandchild","description":"Research Workers limits","subagent_type":"general-purpose","task_type":"local_agent","prompt":"Fetch the Workers limits page and report the free-plan request cap.","session_id":"session-nested"}
{"type":"user","parent_tool_use_id":"toolu_child","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_grandchild","content":[{"type":"text","text":"Async agent launched successfully. (This tool result is internal metadata.)\nagentId: a-grandchild\nThe agent is working in the background. You will be notified automatically when it completes."}],"is_error":false}]}}
{"type":"user","parent_tool_use_id":"toolu_grandchild","message":{"role":"user","content":"Fetch the Workers limits page and report the free-plan request cap."}}
{"type":"assistant","parent_tool_use_id":"toolu_grandchild","effort":"high","message":{"id":"msg-grandchild-1","model":"claude-sonnet-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_gc_fetch","name":"WebFetch","input":{"url":"https://developers.cloudflare.com/workers/platform/limits/","prompt":"free plan request cap"}}]}}
{"type":"user","parent_tool_use_id":"toolu_grandchild","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_gc_fetch","content":"Free plan: 100,000 requests per day.","is_error":false}]}}
{"type":"assistant","parent_tool_use_id":"toolu_grandchild","message":{"id":"msg-grandchild-2","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"Workers Free allows 100,000 requests per day."}]}}
{"type":"system","subtype":"task_notification","task_id":"task-grandchild","tool_use_id":"toolu_grandchild","status":"completed","output_file":"/tmp/tcode-synthetic/tasks/task-grandchild.output","summary":"Agent \"Research Workers limits\" finished","usage":{"total_tokens":12,"tool_uses":1,"duration_ms":34}}
{"type":"assistant","parent_tool_use_id":"toolu_child","message":{"id":"msg-child-2","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"Hosting research done."}]}}
{"type":"system","subtype":"task_notification","task_id":"task-child","tool_use_id":"toolu_child","status":"completed","output_file":"/tmp/tcode-synthetic/tasks/task-child.output","summary":"Hosting researched","usage":{"total_tokens":40,"tool_uses":2,"duration_ms":90}}
9 changes: 8 additions & 1 deletion crates/runtime/src/app/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::sessions::descendant_session_ids;
use super::*;

impl AppState {
Expand Down Expand Up @@ -520,10 +521,16 @@ impl AppState {
}

pub(super) fn close_orchestrator_children(&mut self, parent_id: &str, cx: &mut HostCx) {
// Nested subagent mirrors hang under other mirrors, yet they belong to
// the one provider process that just closed.
let descendants = descendant_session_ids(&self.sessions, parent_id);
let child_ids: Vec<_> = self
.sessions
.iter()
.filter(|meta| meta.parent_session_id.as_deref() == Some(parent_id))
.filter(|meta| {
meta.parent_session_id.as_deref() == Some(parent_id)
|| (meta.native_subagent.is_some() && descendants.contains(&meta.id))
})
.map(|meta| meta.id.clone())
.collect();
for child_id in child_ids {
Expand Down
4 changes: 4 additions & 0 deletions crates/runtime/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ pub struct AppState {
native_subagent_sessions: HashMap<(String, String), String>,
/// Synthetic turn state survives eviction; false remembers a finished child.
native_subagent_turns: HashMap<String, bool>,
/// Subagent items recorded inside mirrors, by (session, item id): the
/// spawns a nested mirror is created from once a grandchild item arrives.
nested_subagent_spawns: HashMap<(String, String), subagents::NestedSpawn>,
pub settings: Settings,
pub providers: ProviderCatalog,
terminal_preferences_path: PathBuf,
Expand Down Expand Up @@ -509,6 +512,7 @@ impl AppState {
pending_native_rewinds: HashMap::new(),
native_subagent_sessions: HashMap::new(),
native_subagent_turns: HashMap::new(),
nested_subagent_spawns: HashMap::new(),
settings,
providers: ProviderCatalog::new(model_catalogs, provider_secret_names),
terminal_preferences_path,
Expand Down
3 changes: 2 additions & 1 deletion crates/runtime/src/app/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,7 +1467,7 @@ impl AppState {
let git_branch = load_branch.then(|| read_git_branch(&cwd));
(timeline, folded, loaded, git_branch)
};
host_cx.enqueue(move |state, _cx| {
host_cx.enqueue(move |state, cx| {
let generation_matches =
state.timeline_load_generations.get(&session_id).copied() == Some(generation);
let target_matches = match target {
Expand Down Expand Up @@ -1502,6 +1502,7 @@ impl AppState {
session.git_branch = git_branch;
}
}
state.repair_orphaned_mirror_turn(&session_id, cx);
});
});
}
Expand Down
Loading