feat(observer): emit subagent_invoked events from Agent tool_use
Closes brain-retro 2026-05-20 #12 — each Agent tool_use produces a subagent_invoked event with subagent_type / model (if explicit) / first 80 chars of description. Visibility from parent Claude's perspective; full subagent trace lives in subagents/ directory and is out of scope for this parser. 6 new vitest tests, 315/315 GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -532,6 +532,33 @@ export function parseRoutingTag(turn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-Agent-tool_use event (Task 12) — surfaces subagent dispatches in the
|
||||
* episode `events[]`. Captures subagent_type / model (if explicit in input)
|
||||
* / first 80 chars of description.
|
||||
*
|
||||
* Not the full subagent trace (that lives in ~/.claude/projects/.../subagents/);
|
||||
* just visibility from the parent Claude's perspective.
|
||||
*/
|
||||
export function extractAgentInvocations(turn) {
|
||||
const out = [];
|
||||
for (const e of turn || []) {
|
||||
const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
|
||||
for (const b of content) {
|
||||
if (b && b.type === 'tool_use' && b.name === 'Agent') {
|
||||
const inp = b.input || {};
|
||||
out.push({
|
||||
kind: 'subagent_invoked',
|
||||
subagent_type: inp.subagent_type || 'unknown',
|
||||
model: inp.model || null,
|
||||
description: typeof inp.description === 'string' ? inp.description.slice(0, 80) : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const REASONING_TAG_RE =
|
||||
/<!--\s*reasoning:\s*triggers="([^"]*)"\s+candidates="([^"]*)"\s+boundaries="([^"]*)"\s*-->/;
|
||||
|
||||
@@ -611,6 +638,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
}
|
||||
events.push(...extractProcessEvents(turn, broken, total, durationMs));
|
||||
events.push(...extractAskUserQuestionEvents(turn));
|
||||
events.push(...extractAgentInvocations(turn));
|
||||
|
||||
const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX));
|
||||
const prompt = promptText(entries[start]);
|
||||
|
||||
@@ -1375,3 +1375,61 @@ describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', ()
|
||||
expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16');
|
||||
});
|
||||
});
|
||||
|
||||
import { extractAgentInvocations } from './observer-transcript-parser.mjs';
|
||||
|
||||
describe('extractAgentInvocations (Task 12)', () => {
|
||||
it('emits subagent_invoked event from Agent tool_use', () => {
|
||||
const turn = [{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', name: 'Agent',
|
||||
input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } }
|
||||
] } }];
|
||||
const ev = extractAgentInvocations(turn);
|
||||
expect(ev).toHaveLength(1);
|
||||
expect(ev[0].kind).toBe('subagent_invoked');
|
||||
expect(ev[0].subagent_type).toBe('general-purpose');
|
||||
expect(ev[0].model).toBe('sonnet');
|
||||
expect(ev[0].description).toBe('check files');
|
||||
});
|
||||
it('uses "unknown" when subagent_type missing', () => {
|
||||
const turn = [{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', name: 'Agent', input: {} }
|
||||
] } }];
|
||||
expect(extractAgentInvocations(turn)[0].subagent_type).toBe('unknown');
|
||||
});
|
||||
it('truncates description at 80 chars', () => {
|
||||
const turn = [{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', name: 'Agent',
|
||||
input: { subagent_type: 'g', description: 'a'.repeat(200) } }
|
||||
] } }];
|
||||
expect(extractAgentInvocations(turn)[0].description.length).toBe(80);
|
||||
});
|
||||
it('returns empty for non-Agent tool_use', () => {
|
||||
const turn = [{ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/a' } }
|
||||
] } }];
|
||||
expect(extractAgentInvocations(turn)).toEqual([]);
|
||||
});
|
||||
it('safe on null/empty', () => {
|
||||
expect(extractAgentInvocations(null)).toEqual([]);
|
||||
expect(extractAgentInvocations([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript — subagent_invoked events (Task 12)', () => {
|
||||
it('emits subagent_invoked from Agent tool_use', () => {
|
||||
const transcript = [
|
||||
JSON.stringify({ sessionId: 's' }),
|
||||
JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
|
||||
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 't1', name: 'Agent',
|
||||
input: { subagent_type: 'tester', model: 'haiku', description: 'verify spec' } }
|
||||
] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
|
||||
].join('\n');
|
||||
const ep = parseTranscript(transcript);
|
||||
const subs = ep.events.filter((e) => e.kind === 'subagent_invoked');
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(subs[0].subagent_type).toBe('tester');
|
||||
expect(subs[0].model).toBe('haiku');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user