Keep Claude commands out of subagents

This commit is contained in:
iris-ai committed 2026-09-15 14:29:18 -04:00
1 parent 8262ceb786
commit 1c60e78b55
2 files changed
+81 -49

No files matched your search

+63 -36
View File
@@ -214,13 +214,15 @@ impl Translator {
self.interrupting = true;
}
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own
// start/end instead of every nested step. Routed into that
// subagent's own transcript rather than dropped -- see
// `SUBAGENTS.md`.
// A parent id says that a line is nested, not what it is nested in.
// Only a Task/Agent call or an agent lifecycle edge may create the
// subagent; the id alone is not proof of either.
if let Some(parent_id) = message.get("parent_tool_use_id").and_then(Value::as_str) {
return self.translate_child(parent_id, message);
return if self.subagents.get(parent_id).is_some() {
self.translate_child(parent_id, message)
} else {
Vec::new()
};
}
self.dispatch(message)
}
@@ -239,14 +241,7 @@ impl Translator {
self.subagents.reopen(id);
}
Some(_) => {}
None => {
// Nobody has heard of this id yet: the Task call itself
// either has not been seen or never will be. Started here
// with the best title available -- the tool name of this
// first line -- since SUBAGENTS.md's real title only
// arrives with the Task call.
self.subagents.start(id, &fallback_title(message), None);
}
None => return Vec::new(),
}
let child = self
.children
@@ -625,10 +620,16 @@ impl Translator {
}
Some("task_started") => {
// What makes the session `Waiting` when its turn ends. The
// subagent itself is created by the Task `tool_use` in the
// parent's own message, which arrives first and carries the
// title this side shows.
// Task/Agent tool call ordinarily created the subagent first.
// The lifecycle edge is the recovery path when an adopted
// current CLI's earlier tool call was not observed. Commands
// use `local_bash`, so they must never enter the registry.
if let Some(about) = about {
if message.get("task_type").and_then(Value::as_str) == Some("local_agent")
&& self.subagents.get(&about).is_none()
{
self.start_subagent_from_task(&about, message);
}
self.awaiting_task_summaries.remove(&about);
self.open_tasks.insert(about);
}
@@ -1180,23 +1181,6 @@ fn prefixed_lines(prefix: char, text: &str) -> String {
.collect()
}
/// The title to start a subagent under when its own first line arrives
/// before (or without) its Task call ever being seen: the tool name of that
/// first line, which is the only thing known about it yet. `"subagent"` for
/// a line this cannot even find a tool name in, such as one that opens with
/// something other than a tool call.
fn fallback_title(message: &Value) -> String {
message["message"]["content"]
.as_array()
.into_iter()
.flatten()
.find(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.and_then(|block| block.get("name"))
.and_then(Value::as_str)
.unwrap_or("subagent")
.to_string()
}
/// Whether this line is a subagent's *own* turn ending -- the only thing
/// that does, per `SUBAGENTS.md`: not the parent's `tool_result`, which for
/// a background Task arrives at launch rather than at completion.
@@ -1578,16 +1562,22 @@ mod tests {
}
#[test]
fn subagent_events_are_not_duplicated_into_the_transcript() {
fn a_background_commands_nested_line_does_not_create_a_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_started","task_id":"command1","tool_use_id":"toolu_parent","description":"cargo test","is_backgrounded":true,"task_type":"local_bash"}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
assert!(events.is_empty());
assert!(
subagents.get("toolu_parent").is_none(),
"a nested command is not a subagent"
);
}
/// A child line does not just vanish from the parent -- it lands in its
@@ -1599,11 +1589,18 @@ mod tests {
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_parent","name":"Agent","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
],
);
let events = translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_c1","name":"Bash","input":{"command":"echo hi"}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
assert!(events.is_empty());
let subagent = subagents.get("toolu_parent").expect("subagent started");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
@@ -1645,6 +1642,35 @@ mod tests {
));
}
#[test]
fn an_agent_lifecycle_edge_recovers_an_unseen_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_started","task_id":"agent1","tool_use_id":"toolu_agent","description":"Investigate","subagent_type":"general-purpose","prompt":"Find it","is_backgrounded":true,"task_type":"local_agent"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{}}]},"parent_tool_use_id":"toolu_agent"}"#,
],
);
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Investigate (general-purpose)");
let subagent = subagents.get("toolu_agent").expect("subagent recovered");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(lines.iter().any(
|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text == "Find it")
));
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
}
/// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test]
@@ -2288,6 +2314,7 @@ mod tests {
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task_a","name":"Agent","input":{"description":"helper a"}},{"type":"tool_use","id":"toolu_task_b","name":"Agent","input":{"description":"helper b"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_a","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task_a"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_b","name":"Read","input":{}}]},"parent_tool_use_id":"toolu_task_b"}"#,
],