fix(tap): a subagent's tool calls are no longer credited to its parent
deploy / test (push) Successful in 4m57s
deploy / build (push) Successful in 5m34s

The previous commit's message ended "mission agents are spawning subagents and
nothing in our design accounts for it." Twelve spawns across the two production
missions, all of them used as a fetch mechanism — container missions have no
`WebFetch` or `WebSearch`, so they reach the network through `Bash` + `curl`,
and 151 of 158 Bash calls are exactly that.

Measured against the real claude 2.1.246 binary rather than reasoned about,
because the containers were reaped and the question had three possible answers:

  1. A subagent's tool calls DO fire both hooks. `PostToolUse` records them, and
     `PreToolUse` blocked a subagent's denied curl and got the reason back to
     it. `Agent` is not a gate bypass — worth knowing before shipping the rule
     in the previous commit.
  2. They carry the PARENT's session_id. One parent plus one subagent produced
     three events on one id. This is why attribution resolved 119/119: a
     subagent never adds a session, so attribute_sessions' exact count holds.
  3. Only `agent_type` / `agent_id` tell them apart — present on a subagent's
     payload, absent on the parent's own.

`hook_script` appends the raw payload, so both fields were already on disk in
every production run. `parse()` read past them. The guest was never the lossy
half, so nothing container-side changes and no redeploy of the image is needed.

`Observed.subagent` / `.subagent_id` now carry them into `mission_events.detail`.
A blank `agent_type` reads as "the turn's own agent", because absence IS the
signal here and a subagent named "" is not a thing.

Same defect class as the tap discarding tool ARGUMENTS until 2026-08-21: the
record looked complete while being wrong about who did the work.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-08-26 21:09:04 -05:00
co-authored by Claude Opus 5
parent 5a2ed8fb42
commit fe5c7d2c87
2 changed files with 83 additions and 0 deletions
+75
View File
@@ -93,6 +93,23 @@ pub struct Observed {
/// not from the repository either, because `tdd-red-green-refactor` says
/// in so many words to "commit the RED-to-GREEN pair as one commit".
pub response: Value,
/// The subagent that made this call, when it was not the turn's own agent.
///
/// Claude Code's `Agent` tool spawns a subagent that runs its own tools,
/// and those calls DO reach this hook — measured against the real binary,
/// which is the good news, because it means nothing is invisible. What they
/// carry is the PARENT's `session_id`, so [`Observed::session`] cannot tell
/// them apart and attribution silently credits the parent for work a
/// subagent did.
///
/// The payload has always said so: `agent_type` and `agent_id` are present
/// on a subagent's call and absent on the parent's. This parser read past
/// them. Two production missions spawned twelve subagents to fetch web
/// pages, and every tool call they made was recorded as the parent's with
/// nothing anywhere reporting the difference.
pub subagent: Option<String>,
/// Which subagent instance, so several running under one turn stay apart.
pub subagent_id: Option<String>,
/// The tool's arguments, bounded by [`bounded_input`].
///
/// Kept because the tool NAME alone answers almost nothing. A phase that
@@ -318,12 +335,27 @@ pub fn parse(raw: &str) -> Vec<Observed> {
.or_else(|| v.get("sessionId"))
.and_then(Value::as_str)
.map(str::to_string),
// Absent on the turn agent's own calls, present on a
// subagent's. That absence IS the signal, so an empty string
// must read as "not a subagent" rather than as one named "".
subagent: non_empty(&v, "agent_type", "agentType"),
subagent_id: non_empty(&v, "agent_id", "agentId"),
tool,
})
})
.collect()
}
/// A string field under either spelling, treating empty as missing.
fn non_empty(v: &Value, snake: &str, camel: &str) -> Option<String> {
v.get(snake)
.or_else(|| v.get(camel))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
/// Single-quote for `sh`. Local copy, same rule as the stop gate's — these two
/// modules deliberately share no code, so neither can break the other.
pub fn shell_quote(s: &str) -> String {
@@ -411,6 +443,8 @@ mod tests {
path: Some("/mission/repo/src/a.rs".into()),
input: json!({"file_path": "/mission/repo/src/a.rs"}),
session: None,
subagent: None,
subagent_id: None,
response: Value::Null,
},
Observed {
@@ -418,12 +452,53 @@ mod tests {
path: None,
input: json!({"command": "ls"}),
session: None,
subagent: None,
subagent_id: None,
response: Value::Null,
},
]
);
}
/// A subagent's tool calls reach this hook carrying the PARENT's session
/// id, so the only thing that separates them is `agent_type`/`agent_id`.
///
/// Measured against claude 2.1.246: spawning one subagent and having it run
/// `echo SUB` produced three `PostToolUse` events on one session id — the
/// parent's `Agent` call, the parent's own `Bash`, and the subagent's
/// `Bash` — and only the last carried an `agent_type`. Reading past those
/// fields is what made twelve production subagent spawns indistinguishable
/// from the work of the agents that spawned them.
#[test]
fn a_subagents_call_is_told_apart_from_its_parents() {
let raw = concat!(
r#"{"tool_name":"Agent","session_id":"s1","tool_input":{"prompt":"fetch it"}}"#,
"\n",
r#"{"tool_name":"Bash","session_id":"s1","tool_input":{"command":"echo PARENT"}}"#,
"\n",
r#"{"tool_name":"Bash","session_id":"s1","agent_id":"a0b8","agent_type":"general-purpose","#,
r#""tool_input":{"command":"echo SUB"}}"#,
);
let got = parse(raw);
assert_eq!(got.len(), 3);
assert!(
got.iter().all(|o| o.session.as_deref() == Some("s1")),
"a subagent shares its parent's session id — that is the whole problem"
);
assert_eq!(got[0].subagent, None, "the parent spawned it; it did not run inside it");
assert_eq!(got[1].subagent, None);
assert_eq!(got[2].subagent.as_deref(), Some("general-purpose"));
assert_eq!(got[2].subagent_id.as_deref(), Some("a0b8"));
}
/// An empty `agent_type` must read as "the turn's own agent", not as a
/// subagent whose name happens to be blank.
#[test]
fn a_blank_agent_type_is_not_a_subagent() {
let raw = r#"{"tool_name":"Bash","session_id":"s1","agent_type":" ","tool_input":{"command":"ls"}}"#;
assert_eq!(parse(raw)[0].subagent, None);
}
/// The command survives the parse.
///
/// The regression this guards is the one that made the first container-tier