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
9 changes: 8 additions & 1 deletion bt-daemon/src/transcript_import/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,10 @@ pub(super) fn envelopes(
"agent_transcript_path": subagent.path
});
if !is_start {
payload["last_assistant_message"] = json!(subagent.last_assistant_message);
payload["last_assistant_message"] = json!(subagent
.handback_message
.clone()
.or_else(|| subagent.last_assistant_message.clone()));
}
events.push(import_envelope(
source_version.clone(),
Expand Down Expand Up @@ -315,6 +318,7 @@ struct Subagent {
start_ms: i64,
end_ms: i64,
last_assistant_message: Option<Value>,
handback_message: Option<Value>,
}

fn subagents(path: &Path, records: &[Value]) -> anyhow::Result<Vec<Subagent>> {
Expand Down Expand Up @@ -461,6 +465,9 @@ fn subagents(path: &Path, records: &[Value]) -> anyhow::Result<Vec<Subagent>> {
start_ms,
end_ms,
last_assistant_message: last_assistant_text(&subagent_records),
handback_message: crate::translate::successful_subagent_handback_message(
&subagent_records,
),
})
})
.collect::<Vec<_>>();
Expand Down
77 changes: 75 additions & 2 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ struct ClaudeTranslator {
emitted_tools: RecentSet<String>,
pending_tools: HashMap<String, PendingTool>,
subagents: HashMap<String, Subagent>,
subagent_handbacks: HashMap<String, Value>,
pending_skills: Vec<String>,
pending_emission: Option<PendingEmission>,
claude_version: Option<String>,
Expand Down Expand Up @@ -331,6 +332,7 @@ impl ClaudeTranslator {
emitted_tools: RecentSet::default(),
pending_tools: HashMap::new(),
subagents: HashMap::new(),
subagent_handbacks: HashMap::new(),
pending_skills: Vec::new(),
pending_emission: None,
claude_version: None,
Expand Down Expand Up @@ -781,6 +783,15 @@ impl ClaudeTranslator {
let error = (approval != ToolApproval::Denied)
.then(|| forced_error.or_else(|| tool_error(&event.payload)))
.flatten();
if approval == ToolApproval::Approved && error.is_none() && tool_name == "SubagentHandback"
{
if let (Some(agent_id), Some(message)) = (
string_field(&event.payload, "agent_id"),
raw_tool_input(&event.payload).and_then(|input| input.get("message")),
) {
self.subagent_handbacks.insert(agent_id, message.clone());
}
}
let mut metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input);
if let Some(pending) = self.pending_tools.remove(&call_id) {
// A PreToolUse hook may rewrite the call via `updatedInput`, so the
Expand Down Expand Up @@ -859,12 +870,14 @@ impl ClaudeTranslator {
if let Some(agent) = self.subagents.get_mut(&agent_id) {
agent.transcript_path = path.clone();
}
let mut transcript_handback = None;
if let Some(path) = path {
let cursor = self.transcripts.entry(path.clone()).or_default();
cursor
.buffered
.extend(read_event_records(event, &path, &mut cursor.offset));
let records = std::mem::take(&mut cursor.buffered);
transcript_handback = successful_subagent_handback_message(&records);
self.queue_transcript(
records,
PendingHistory::Owned(MessageHistory::default()),
Expand All @@ -881,12 +894,17 @@ impl ClaudeTranslator {
"Subagent ended before tool completion",
ops,
);
let output = self
.subagent_handbacks
.remove(&agent_id)
.or(transcript_handback)
.or_else(|| event.payload.get("last_assistant_message").cloned());
Comment thread
Qard marked this conversation as resolved.
ops.push(SpanOp::Merge(SpanRow {
span_id: parent.clone(),
root_span_id: self.root_span_id.clone(),
parent_span_ids,
end_ms: Some(event.ts_ms),
output: event.payload.get("last_assistant_message").cloned(),
output,
..Default::default()
}));
self.subagents.remove(&agent_id);
Expand Down Expand Up @@ -1732,6 +1750,22 @@ fn parse_transcript(records: &[Value], mut history: MessageHistory) -> ParsedTra
}
}

/// Returns the most recent SubagentHandback report whose matching transcript
/// result proves that the tool completed successfully.
pub(crate) fn successful_subagent_handback_message(records: &[Value]) -> Option<Value> {
parse_transcript(records, MessageHistory::default())
.tools
.into_iter()
.rev()
.find(|tool| {
tool.tool_name == "SubagentHandback"
&& tool.approval == Some(ToolApproval::Approved)
&& tool.error.is_none()
&& !tool.cancelled
})
.and_then(|tool| tool.input.get("message").cloned())
}

fn continuation_origin(payload: &Value) -> Option<(&'static str, &str)> {
if let Some(origin) = payload.get("origin") {
if origin.get("kind").and_then(Value::as_str) == Some("peer")
Expand Down Expand Up @@ -2600,7 +2634,7 @@ fn turn_input_kind(payload: &Value) -> Option<&'static str> {
mod tests {
use super::{
decode, parse_rfc3339_timestamp, parse_timestamp_ms, read_snapshot_bounded,
TranscriptEnvelope,
successful_subagent_handback_message, TranscriptEnvelope,
};

#[test]
Expand Down Expand Up @@ -2666,4 +2700,43 @@ mod tests {
);
assert!(read_snapshot_bounded(&contents, &mut offset, None, None).is_empty());
}

#[test]
fn handback_requires_a_successful_matching_tool_result() {
let assistant = serde_json::json!({
"type": "assistant",
"message": {"id": "request", "content": [{
"type": "tool_use", "id": "handback", "name": "SubagentHandback",
"input": {"message": "report"}
}]}
});
let successful = serde_json::json!({
"type": "user",
"message": {"content": [{
"type": "tool_result", "tool_use_id": "handback", "content": "Delivered"
}]}
});
let failed = serde_json::json!({
"type": "user",
"message": {"content": [{
"type": "tool_result", "tool_use_id": "handback", "content": "Failed",
"is_error": true
}]}
});

assert_eq!(
successful_subagent_handback_message(&[assistant.clone(), successful]),
Some(serde_json::json!("report"))
);
assert_eq!(
successful_subagent_handback_message(&[assistant.clone(), failed]),
None,
"a failed handback proposal is not output delivered to the caller"
);
assert_eq!(
successful_subagent_handback_message(&[assistant]),
None,
"an incomplete handback proposal is not output delivered to the caller"
);
}
}
1 change: 1 addition & 0 deletions bt-daemon/src/translate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod git;
mod grok;
mod opencode;
mod pi;
pub(crate) use claude::successful_subagent_handback_message;
pub(crate) use pi::request_config as pi_request_config;
mod recent;
mod tool;
Expand Down
174 changes: 174 additions & 0 deletions bt-daemon/tests/claude_translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,180 @@ fn claude_subagent_stops_preserve_lifecycle_and_missing_start_recovery() {
assert_eq!(tool.end_ms, Some(50));
}

#[test]
fn claude_subagent_handback_report_is_the_span_output() {
let registry = Registry::default_agents();
let mut translator = registry.create("claude-code", "subagent-handback");
let ctx = SessionCtx {
session_id: "subagent-handback".into(),
config: None,
};
let event = |name, ts_ms, payload| claude_event(&ctx.session_id, name, ts_ms, payload);
let report = "The implementation is complete; these are the findings.";
let mut ops = Vec::new();
for envelope in [
event("UserPromptSubmit", 10, json!({"prompt": "delegate"})),
event(
"SubagentStart",
20,
json!({"agent_id": "worker", "agent_type": "reviewer"}),
),
event(
"PreToolUse",
30,
json!({
"agent_id": "worker", "tool_name": "SubagentHandback",
"tool_use_id": "handback-1", "tool_input": {"message": report}
}),
),
event(
"PostToolUse",
40,
json!({
"agent_id": "worker", "tool_name": "SubagentHandback",
"tool_use_id": "handback-1", "tool_input": {"message": report},
"tool_response": {"message": "Report delivered"}
}),
),
event(
"SubagentStop",
50,
json!({
"agent_id": "worker", "agent_type": "reviewer",
"last_assistant_message": "Handback complete."
}),
),
event(
"SubagentStart",
60,
json!({"agent_id": "ordinary", "agent_type": "worker"}),
),
event(
"SubagentStop",
70,
json!({
"agent_id": "ordinary", "agent_type": "worker",
"last_assistant_message": "Ordinary subagent result."
}),
),
] {
ops.extend(translator.handle(&envelope, &ctx).unwrap());
}
let rows = reduce(ops);
let handback_agent = rows
.values()
.find(|row| {
row.name == "subagent: reviewer"
&& row
.metadata
.as_ref()
.and_then(|metadata| metadata.get("agent_id"))
== Some(&json!("worker"))
})
.unwrap();
assert_eq!(handback_agent.output, Some(json!(report)));
let handback_tool = rows
.values()
.find(|row| {
row.span_type == SpanType::Tool
&& row
.metadata
.as_ref()
.and_then(|metadata| metadata.get("tool_call_id"))
== Some(&json!("handback-1"))
})
.unwrap();
assert_eq!(
handback_tool.parent_span_ids,
vec![handback_agent.span_id.clone()]
);
assert_eq!(
handback_tool.output.as_ref().unwrap()["message"],
json!("Report delivered")
);

let ordinary_agent = rows
.values()
.find(|row| {
row.name == "subagent: worker"
&& row
.metadata
.as_ref()
.and_then(|metadata| metadata.get("agent_id"))
== Some(&json!("ordinary"))
})
.unwrap();
assert_eq!(
ordinary_agent.output,
Some(json!("Ordinary subagent result."))
);
}

#[test]
fn claude_subagent_stop_recovers_handback_from_transcript_without_post_tool_hook() {
let tmp = tempfile::tempdir().unwrap();
let transcript = tmp.path().join("agent-worker.jsonl");
let report = "Recovered report delivered to the caller.";
let records = [
json!({"type":"assistant","timestamp":"1970-01-01T00:00:03Z","message":{"id":"handback","content":[{"type":"tool_use","id":"handback-1","name":"SubagentHandback","input":{"message":report}}]}}),
json!({"type":"user","timestamp":"1970-01-01T00:00:04Z","message":{"content":[{"type":"tool_result","tool_use_id":"handback-1","content":"Delivered"}]}}),
json!({"type":"assistant","timestamp":"1970-01-01T00:00:04.500Z","message":{"id":"closing","content":[{"type":"text","text":"Handback complete."}]}}),
];
std::fs::write(
&transcript,
records
.iter()
.map(Value::to_string)
.collect::<Vec<_>>()
.join("\n")
+ "\n",
)
.unwrap();

let registry = Registry::default_agents();
let mut translator = registry.create("claude-code", "subagent-transcript-recovery");
let ctx = SessionCtx {
session_id: "subagent-transcript-recovery".into(),
config: None,
};
let path = transcript.to_str().unwrap();
let events = [
claude_event(
&ctx.session_id,
"UserPromptSubmit",
1_000,
json!({"prompt":"delegate"}),
),
claude_event(
&ctx.session_id,
"SubagentStart",
2_000,
json!({"agent_id":"worker","agent_type":"reviewer","agent_transcript_path":path}),
),
claude_event(
&ctx.session_id,
"SubagentStop",
5_000,
json!({"agent_id":"worker","agent_type":"reviewer","agent_transcript_path":path,"last_assistant_message":"Handback complete."}),
),
];
let ops = events
.iter()
.flat_map(|event| translator.handle(event, &ctx).unwrap())
.collect();
let rows = reduce(ops);
let agent = rows
.values()
.find(|row| row.name == "subagent: reviewer")
.unwrap();
assert_eq!(agent.output, Some(json!(report)));
assert!(rows.values().any(|row| {
row.span_type == SpanType::Tool
&& row.name == "SubagentHandback"
&& row.parent_span_ids.contains(&agent.span_id)
}));
}

#[test]
fn claude_subagent_fixture_builds_nested_subagent_llms() {
let rows = reduce(replay("subagent-compact"));
Expand Down
Loading
Loading