Skip to content

EventLogger discards subagent attribution: agent_id/agent_type arrive in the hook payload and never reach tool-activity.jsonl #1806

Description

@xmasyx

Summary

LifeOS/install/hooks/EventLogger.hook.ts (@version 1.0.3) drops the only two fields that say who made a tool call. Claude Code adds agent_id and agent_type to the hook payload when — and only when — the call originates from a subagent; in the main session both keys are absent. The logger never reads them, so every delegated action is written to tool-activity.jsonl indistinguishable from work the main session did itself.

Evidence

Payload captured from a live PreToolUse/PostToolUse hook run (values redacted, shape verbatim):

// call made BY A SUBAGENT
{
  "session_id":     "<uuid>",
  "transcript_path":"<path>",
  "cwd":            "<path>",
  "prompt_id":      "<id>",
  "hook_event_name":"PostToolUse",
  "agent_id":       "ab0b254f0e22d3a35",   // <-- present only for subagents
  "agent_type":     "Explore",             // <-- present only for subagents
  "tool_name":      "Write",
  "tool_input":     { "file_path": "" }
}

// the same call made from the MAIN SESSION: identical, minus those two keys

The critical part is what is not different: session_id, transcript_path, cwd and prompt_id are byte-identical between the main session and its subagents. agent_type is therefore the only signal distinguishing the two callers — anything else (notably transcript_path, which some hooks use for this) cannot work.

Why it matters

tool-activity.jsonl is the ground-truth audit trail the rest of the system reads. Today it answers "what happened" but silently mis-answers "who did it": a file written by a delegated agent is recorded exactly like a file written by the operator's own session. That breaks three things at once:

  • attribution — a bad write cannot be traced to the agent that made it;
  • per-caller policy — no downstream consumer can build "delegates may not touch X" rules on top of the log, because the log has thrown the caller away;
  • incident review — after an unexpected change, the log cannot narrow the search to delegated work.

The fields are already in the payload. Nothing needs to be plumbed; they just need to survive.

Root cause

Two event literals build the record from a hand-picked field list, and neither list includes the attribution:

  1. interface ToolUseInput (line 91) does not declare the fields, so handlePostToolUse cannot see them, and the const event = { … } literal (line 196) does not emit them.
  2. The same for the failure path: interface ToolFailureInput (line 267) and the ToolFailureEvent literal it feeds.

Suggested fix

Additive, ~10 lines, no behavior change for main-session events (the keys simply do not appear, exactly as today).

// near interface ToolUseInput (line 91)
interface ToolUseInput {
  session_id: string;
  tool_name?: string;
  tool_input?: Record<string, unknown>;
  tool_response?: unknown;
  agent_id?: string;    // present ONLY when the caller is a subagent
  agent_type?: string;  // idem
}

/**
 * NO DEFAULT, EVER: a field that names WHO acted must not be invented.
 * An absent key means unattributed, and the record simply does not carry it —
 * writing a name we did not read would turn a guess into evidence.
 */
function agentFields(data: { agent_id?: unknown; agent_type?: unknown }): {
  agent_id?: string; agent_type?: string;
} {
  const out: { agent_id?: string; agent_type?: string } = {};
  if (typeof data?.agent_id === 'string' && data.agent_id.length > 0) out.agent_id = data.agent_id;
  if (typeof data?.agent_type === 'string' && data.agent_type.length > 0) out.agent_type = data.agent_type;
  return out;
}

then spread it into the two literals:

// line ~196, inside handlePostToolUse
    const event = {
      timestamp: getISOTimestamp(),
      event: 'tool_use',
      source: 'tool-activity',
      type: 'tool_use',
      session_id: data.session_id,
      ...agentFields(data),        // <-- added
      tool_name: toolName,
      tool_input_preview: inputPreview,
      ...(groundTruth ? { ground_truth: groundTruth } : {}),
    };

and the same one-line spread in the ToolFailureEvent literal (add the two optional fields to ToolFailureInput / ToolFailureEvent alongside it).

Using a conditional spread rather than agent_id: data.agent_id ?? 'main' is the load-bearing detail: a defaulted actor name is a guess wearing the costume of a fact, and it would be worse than no field at all.

Reproduction

# 1. subagent-shaped payload → the two keys should survive into the record
echo '{"hook_event_name":"PostToolUse","session_id":"repro","tool_name":"Read",
       "agent_id":"ab0b254f0e22d3a35","agent_type":"Explore","tool_input":{"file_path":"x"}}' \
  | bun hooks/EventLogger.hook.ts
tail -1 "$LIFEOS_DIR/MEMORY/OBSERVABILITY/tool-activity.jsonl"
# actual:   no agent_id / agent_type
# expected: both fields present

# 2. negative pole — main-session payload must NOT gain the fields
echo '{"hook_event_name":"PostToolUse","session_id":"repro","tool_name":"Read",
       "tool_input":{"file_path":"x"}}' | bun hooks/EventLogger.hook.ts
tail -1 "$LIFEOS_DIR/MEMORY/OBSERVABILITY/tool-activity.jsonl"
# expected: neither key present (absent, not null, not "main")

Tested on @version 1.0.3 of the shipped hook. Happy to send this as a PR if the shape above looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions