Skip to content

Commit 0e05d1d

Browse files
authored
Merge pull request #1710 from entireio/fix/329-subagent-extract
fix(attribution): scan full transcript for subagents spawned before checkpoint
2 parents 3b6957b + 9f16312 commit 0e05d1d

18 files changed

Lines changed: 1324 additions & 31 deletions

cmd/entire/cli/agent/agent.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,24 @@ type SubagentAwareExtractor interface {
361361
ExtractAllModifiedFiles(transcriptData []byte, fromOffset int, subagentsDir string) ([]string, error)
362362

363363
// CalculateTotalTokenUsage computes token usage including all spawned subagents.
364-
// The subagentsDir parameter specifies where subagent transcripts are stored.
364+
// The subagentsDir parameter specifies where subagent transcripts are stored
365+
// (an empty subagentsDir skips subagent accounting and leaves SubagentTokens nil).
366+
//
367+
// CONTRACT — the returned SubagentTokens is a CUMULATIVE-SINCE-SESSION-START
368+
// snapshot, NOT a delta scoped to fromOffset like the main-agent fields
369+
// (InputTokens/OutputTokens/...). Implementations MUST discover spawned agent
370+
// IDs from the FULL transcript prefix [0,end) — so a subagent spawned before
371+
// fromOffset is still found (#329) — and re-read each subagent transcript from
372+
// line 0 on every call. Consequently a subagent's full total repeats on every
373+
// call after it is first discovered.
374+
//
375+
// Callers that accumulate across checkpoints/turns therefore MUST NOT sum
376+
// SubagentTokens across calls: replace the running total with the latest
377+
// snapshot, and rescope any window delta by subtracting a previously captured
378+
// baseline (see accumulateTokenUsage / resetCheckpointWindow and
379+
// session.State.SubagentTokensBaseline in cmd/entire/cli/strategy, and
380+
// rescopeSubagentTokensToDeltas in cmd/entire/cli/agentimport for the import
381+
// path). An implementation that instead returned per-window deltas would
382+
// silently break that accounting with no compile-time or test signal.
365383
CalculateTotalTokenUsage(transcriptData []byte, fromOffset int, subagentsDir string) (*TokenUsage, error)
366384
}

cmd/entire/cli/agent/claudecode/transcript.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,37 @@ func (c *ClaudeCodeAgent) CalculateTotalTokenUsage(transcriptData []byte, startL
395395
// Calculate token usage from parsed transcript
396396
mainUsage := CalculateTokenUsage(parsed)
397397

398-
// Extract spawned agent IDs from the same parsed transcript
399-
agentIDs := ExtractSpawnedAgentIDs(parsed)
398+
if subagentsDir == "" {
399+
return mainUsage, nil
400+
}
401+
402+
// Extract spawned agent IDs from the FULL transcript (startLine=0), not the
403+
// sliced portion. A subagent spawned before this checkpoint's startLine can
404+
// keep writing to its transcript in later turns; scanning only the slice
405+
// would miss it and undercount subagent token usage (#329).
406+
//
407+
// PERF (considered, retained deliberately): this re-parses the full
408+
// transcript in addition to the sliced parse above — two JSONL parses per
409+
// call, growing with session length. A single-pass version was rejected as
410+
// not worth the risk: ParseFromBytes silently drops malformed lines, so a
411+
// parsed-entry index does not correspond to a raw line number and naively
412+
// slicing the full parse at startLine would misattribute main-agent usage;
413+
// doing it safely would mean threading raw-line numbers through the shared
414+
// transcript parser used by every agent. A cheap line scan for the Task
415+
// marker instead of a full parse would duplicate ExtractSpawnedAgentIDs'
416+
// nested tool_result decoding. The common no-subagent case already avoids
417+
// this cost entirely via the subagentsDir == "" short-circuit above.
418+
fullParsed, err := transcript.ParseFromBytes(transcriptData)
419+
if err != nil {
420+
return nil, fmt.Errorf("failed to parse full transcript: %w", err)
421+
}
422+
agentIDs := ExtractSpawnedAgentIDs(fullParsed)
400423

401-
// Calculate subagent token usage (skip when subagentsDir is empty to avoid reading from cwd)
402-
if len(agentIDs) > 0 && subagentsDir != "" {
424+
// Calculate subagent token usage. This re-reads each subagent transcript from
425+
// line 0 on every call, so mainUsage.SubagentTokens is a cumulative-since-
426+
// session-start snapshot — see the CalculateTotalTokenUsage interface contract
427+
// in cmd/entire/cli/agent for how callers must accumulate it.
428+
if len(agentIDs) > 0 {
403429
subagentUsage := &agent.TokenUsage{}
404430
for agentID := range agentIDs {
405431
agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID))
@@ -449,11 +475,23 @@ func (c *ClaudeCodeAgent) ExtractAllModifiedFiles(transcriptData []byte, startLi
449475
}
450476
}
451477

452-
// Find spawned subagents and collect their modified files (skip when subagentsDir is empty to avoid reading from cwd)
453-
agentIDs := ExtractSpawnedAgentIDs(parsed)
454478
if subagentsDir == "" {
455479
return files, nil
456480
}
481+
482+
// Find spawned subagents from the FULL transcript (startLine=0): a subagent
483+
// spawned before this checkpoint's startLine may keep modifying files in
484+
// later turns, and scanning only the slice would miss it (#329). Main-agent
485+
// file extraction above stays scoped to the slice.
486+
//
487+
// PERF: the second full-transcript parse is retained deliberately for the
488+
// same reasons documented on CalculateTotalTokenUsage above; the common
489+
// no-subagent case is short-circuited by the subagentsDir == "" guard.
490+
fullParsed, err := transcript.ParseFromBytes(transcriptData)
491+
if err != nil {
492+
return nil, fmt.Errorf("failed to parse full transcript: %w", err)
493+
}
494+
agentIDs := ExtractSpawnedAgentIDs(fullParsed)
457495
for agentID := range agentIDs {
458496
agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID))
459497
agentLines, agentErr := transcript.ParseFromFileAtLine(agentPath, 0)

cmd/entire/cli/agent/claudecode/transcript_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,3 +887,78 @@ func TestExtractAllModifiedFiles_SubagentOnlyChanges(t *testing.T) {
887887
t.Errorf("missing expected file %q", f)
888888
}
889889
}
890+
891+
// Regression for #329: a subagent spawned BEFORE the checkpoint's startLine
892+
// must still be discovered, because it can keep modifying files in later turns.
893+
// The Task spawn/result live in lines before startLine; only the full transcript
894+
// scan finds them.
895+
func TestExtractAllModifiedFiles_FindsSubagentSpawnedBeforeStartLine(t *testing.T) {
896+
t.Parallel()
897+
898+
tmpDir := t.TempDir()
899+
subagentsDir := tmpDir + "/tasks/toolu_task1"
900+
c := &ClaudeCodeAgent{}
901+
if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
902+
t.Fatalf("failed to create subagents dir: %v", err)
903+
}
904+
905+
transcriptData := buildJSONL(
906+
makeTaskToolUseLine(t, "a1", "toolu_taskA"), // line 0 (before startLine)
907+
makeTaskResultLine(t, "uA", "toolu_taskA", "subA"), // line 1 (before startLine)
908+
makeWriteToolLine(t, "a2", "/repo/main.go"), // line 2 (>= startLine)
909+
)
910+
writeJSONLFile(t, subagentsDir+"/agent-subA.jsonl",
911+
makeWriteToolLine(t, "sa1", "/repo/helper.go"),
912+
)
913+
914+
files, err := c.ExtractAllModifiedFiles(transcriptData, 2, subagentsDir)
915+
if err != nil {
916+
t.Fatalf("ExtractAllModifiedFiles() error: %v", err)
917+
}
918+
919+
got := make(map[string]bool, len(files))
920+
for _, f := range files {
921+
got[f] = true
922+
}
923+
if !got["/repo/main.go"] {
924+
t.Errorf("missing main-agent file /repo/main.go: %v", files)
925+
}
926+
if !got["/repo/helper.go"] {
927+
t.Errorf("subagent spawned before startLine was not discovered; missing /repo/helper.go: %v", files)
928+
}
929+
}
930+
931+
// Regression for #329: subagent token usage must be counted even when the
932+
// subagent was spawned before the checkpoint's startLine.
933+
func TestCalculateTotalTokenUsage_CountsSubagentSpawnedBeforeStartLine(t *testing.T) {
934+
t.Parallel()
935+
936+
tmpDir := t.TempDir()
937+
subagentsDir := tmpDir + "/tasks/toolu_task1"
938+
c := &ClaudeCodeAgent{}
939+
if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
940+
t.Fatalf("failed to create subagents dir: %v", err)
941+
}
942+
943+
// Subagent spawned in lines 0-1 (before startLine=2); main usage on line 2.
944+
transcriptData := buildJSONL(
945+
makeTaskToolUseLine(t, "a1", "toolu_taskB"),
946+
makeTaskResultLine(t, "uB", "toolu_taskB", "subB"),
947+
`{"type":"assistant","uuid":"a2","message":{"id":"m2","usage":{"input_tokens":300,"output_tokens":150}}}`,
948+
)
949+
writeJSONLFile(t, subagentsDir+"/agent-subB.jsonl",
950+
`{"type":"assistant","uuid":"sa1","message":{"id":"sm1","usage":{"input_tokens":50,"output_tokens":25}}}`,
951+
)
952+
953+
usage, err := c.CalculateTotalTokenUsage(transcriptData, 2, subagentsDir)
954+
if err != nil {
955+
t.Fatalf("CalculateTotalTokenUsage() error: %v", err)
956+
}
957+
if usage.SubagentTokens == nil {
958+
t.Fatal("subagent spawned before startLine was not counted (SubagentTokens is nil)")
959+
}
960+
if usage.SubagentTokens.InputTokens != 50 || usage.SubagentTokens.OutputTokens != 25 {
961+
t.Errorf("subagent tokens = input %d output %d, want input 50 output 25",
962+
usage.SubagentTokens.InputTokens, usage.SubagentTokens.OutputTokens)
963+
}
964+
}

cmd/entire/cli/agent/factoryaidroid/transcript.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,32 @@ func CalculateTotalTokenUsageFromBytes(data []byte, startLine int, subagentsDir
366366

367367
mainUsage := CalculateTokenUsage(parsed)
368368

369-
agentIDs := ExtractSpawnedAgentIDs(parsed)
370-
if len(agentIDs) > 0 && subagentsDir != "" {
369+
if subagentsDir == "" {
370+
return mainUsage, nil
371+
}
372+
373+
// Extract spawned agent IDs from the FULL transcript (startLine=0): a
374+
// subagent spawned before this checkpoint's startLine can keep writing to
375+
// its transcript, so scanning only the slice would undercount it (#329).
376+
//
377+
// PERF (considered, retained deliberately): this re-parses the full
378+
// transcript in addition to the sliced parse above — two JSONL parses per
379+
// call, growing with session length. A single-pass version was rejected:
380+
// the Droid parser drops non-message / malformed lines, so a parsed-entry
381+
// index does not map to a raw line number and naively slicing the full parse
382+
// at startLine would misattribute main-agent usage; doing it safely would
383+
// mean threading raw-line numbers through the shared parser. The common
384+
// no-subagent case already avoids this via the subagentsDir == "" guard.
385+
fullParsed, _, err := ParseDroidTranscriptFromBytes(data, 0)
386+
if err != nil {
387+
return nil, fmt.Errorf("failed to parse full transcript: %w", err)
388+
}
389+
agentIDs := ExtractSpawnedAgentIDs(fullParsed)
390+
// This re-reads each subagent transcript from line 0 on every call below, so
391+
// mainUsage.SubagentTokens ends up cumulative-since-session-start — see the
392+
// CalculateTotalTokenUsage interface contract in cmd/entire/cli/agent for how
393+
// callers must accumulate it (shared with Claude Code).
394+
if len(agentIDs) > 0 {
371395
subagentUsage := &agent.TokenUsage{}
372396
for agentID := range agentIDs {
373397
agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID))
@@ -409,10 +433,23 @@ func ExtractAllModifiedFilesFromBytes(data []byte, startLine int, subagentsDir s
409433
fileSet[f] = true
410434
}
411435

412-
agentIDs := ExtractSpawnedAgentIDs(parsed)
413436
if subagentsDir == "" {
414437
return files, nil
415438
}
439+
440+
// Find spawned subagents from the FULL transcript (startLine=0): a subagent
441+
// spawned before this checkpoint's startLine may keep modifying files in
442+
// later turns, and scanning only the slice would miss it (#329). Main-agent
443+
// file extraction above stays scoped to the slice.
444+
//
445+
// PERF: the second full-transcript parse is retained deliberately for the
446+
// same reasons documented on CalculateTotalTokenUsageFromBytes above; the
447+
// common no-subagent case is short-circuited by the subagentsDir == "" guard.
448+
fullParsed, _, err := ParseDroidTranscriptFromBytes(data, 0)
449+
if err != nil {
450+
return nil, fmt.Errorf("failed to parse full transcript: %w", err)
451+
}
452+
agentIDs := ExtractSpawnedAgentIDs(fullParsed)
416453
for agentID := range agentIDs {
417454
agentPath := filepath.Join(subagentsDir, fmt.Sprintf("agent-%s.jsonl", agentID))
418455
agentLines, _, agentErr := ParseDroidTranscript(agentPath, 0)

cmd/entire/cli/agent/factoryaidroid/transcript_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,3 +1224,73 @@ func TestExtractAllModifiedFilesFromBytes_SubagentOnlyChanges(t *testing.T) {
12241224
t.Errorf("missing expected file %q", f)
12251225
}
12261226
}
1227+
1228+
// Regression for #329: a subagent spawned BEFORE the checkpoint's startLine must
1229+
// still be discovered for file extraction (it can keep modifying files later).
1230+
func TestExtractAllModifiedFilesFromBytes_FindsSubagentSpawnedBeforeStartLine(t *testing.T) {
1231+
t.Parallel()
1232+
1233+
tmpDir := t.TempDir()
1234+
subagentsDir := tmpDir + "/tasks/toolu_task1"
1235+
if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
1236+
t.Fatalf("failed to create subagents dir: %v", err)
1237+
}
1238+
1239+
data := joinJSONL(
1240+
makeTaskToolUseLine(t, "a1", "toolu_taskC"), // line 0 (before startLine)
1241+
makeTaskResultLine(t, "uC", "toolu_taskC", "sub1"), // line 1 (before startLine)
1242+
makeWriteToolLine(t, "a2", "/repo/main.go"), // line 2 (>= startLine)
1243+
)
1244+
writeJSONLFile(t, subagentsDir+"/agent-sub1.jsonl",
1245+
makeWriteToolLine(t, "sa1", "/repo/helper.go"),
1246+
)
1247+
1248+
files, err := ExtractAllModifiedFilesFromBytes(data, 2, subagentsDir)
1249+
if err != nil {
1250+
t.Fatalf("ExtractAllModifiedFilesFromBytes() error: %v", err)
1251+
}
1252+
1253+
got := make(map[string]bool, len(files))
1254+
for _, f := range files {
1255+
got[f] = true
1256+
}
1257+
if !got["/repo/main.go"] {
1258+
t.Errorf("missing main-agent file /repo/main.go: %v", files)
1259+
}
1260+
if !got["/repo/helper.go"] {
1261+
t.Errorf("subagent spawned before startLine was not discovered; missing /repo/helper.go: %v", files)
1262+
}
1263+
}
1264+
1265+
// Regression for #329: subagent token usage must be counted even when the
1266+
// subagent was spawned before the checkpoint's startLine.
1267+
func TestCalculateTotalTokenUsageFromBytes_CountsSubagentSpawnedBeforeStartLine(t *testing.T) {
1268+
t.Parallel()
1269+
1270+
tmpDir := t.TempDir()
1271+
subagentsDir := tmpDir + "/tasks/toolu_task1"
1272+
if err := os.MkdirAll(subagentsDir, 0o755); err != nil {
1273+
t.Fatalf("failed to create subagents dir: %v", err)
1274+
}
1275+
1276+
data := joinJSONL(
1277+
makeTaskToolUseLine(t, "a1", "toolu_taskD"), // line 0 (before startLine)
1278+
makeTaskResultLine(t, "uD", "toolu_taskD", "sub1"), // line 1 (before startLine)
1279+
makeAssistantTokenLine(t, "a2", "msg_main", 300, 150), // line 2
1280+
)
1281+
writeJSONLFile(t, subagentsDir+"/agent-sub1.jsonl",
1282+
makeAssistantTokenLine(t, "sa1", "msg_sub", 50, 25),
1283+
)
1284+
1285+
usage, err := CalculateTotalTokenUsageFromBytes(data, 2, subagentsDir)
1286+
if err != nil {
1287+
t.Fatalf("CalculateTotalTokenUsageFromBytes() error: %v", err)
1288+
}
1289+
if usage.SubagentTokens == nil {
1290+
t.Fatal("subagent spawned before startLine was not counted (SubagentTokens is nil)")
1291+
}
1292+
if usage.SubagentTokens.InputTokens != 50 || usage.SubagentTokens.OutputTokens != 25 {
1293+
t.Errorf("subagent tokens = input %d output %d, want input 50 output 25",
1294+
usage.SubagentTokens.InputTokens, usage.SubagentTokens.OutputTokens)
1295+
}
1296+
}

cmd/entire/cli/agent/types/token_usage.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,35 @@ func AddTokenUsage(a, b *TokenUsage) *TokenUsage {
4545
sum.SubagentTokens = AddTokenUsage(aSub, bSub)
4646
return sum
4747
}
48+
49+
// SubtractTokenUsage returns a-b, recursing into subagent usage and clamping
50+
// every field at zero (a nil operand is treated as zero). Neither input is
51+
// mutated. Used to rescope a cumulative-since-session-start snapshot (e.g.
52+
// subagent token usage, which is always re-read from the start of each
53+
// subagent transcript) down to a delta since a previously captured baseline.
54+
func SubtractTokenUsage(a, b *TokenUsage) *TokenUsage {
55+
if a == nil {
56+
return nil
57+
}
58+
if b == nil {
59+
b = &TokenUsage{}
60+
}
61+
diff := &TokenUsage{
62+
InputTokens: clampSubtract(a.InputTokens, b.InputTokens),
63+
CacheCreationTokens: clampSubtract(a.CacheCreationTokens, b.CacheCreationTokens),
64+
CacheReadTokens: clampSubtract(a.CacheReadTokens, b.CacheReadTokens),
65+
OutputTokens: clampSubtract(a.OutputTokens, b.OutputTokens),
66+
APICallCount: clampSubtract(a.APICallCount, b.APICallCount),
67+
}
68+
diff.SubagentTokens = SubtractTokenUsage(a.SubagentTokens, b.SubagentTokens)
69+
return diff
70+
}
71+
72+
// clampSubtract returns a-b, floored at zero so a stale or racy baseline
73+
// never produces a negative delta.
74+
func clampSubtract(a, b int) int {
75+
if a < b {
76+
return 0
77+
}
78+
return a - b
79+
}

cmd/entire/cli/agentimport/agentimport.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,16 @@ type Turn struct {
4444
UUID string
4545
Prompt, Model string
4646
CreatedAt time.Time
47-
Tokens *types.TokenUsage
47+
// Tokens is this turn's token usage. Every field is a per-turn delta:
48+
// main-agent fields are scoped to the turn's [LineStart, LineEnd) slice by
49+
// the token helpers, and SubagentTokens is rescoped from the cumulative
50+
// snapshot those helpers return to a per-turn increment by
51+
// rescopeSubagentTokensToDeltas (see linesplit.go). That invariant lets
52+
// callers sum turns freely: writeSessionState sums them for the session
53+
// total and each imported checkpoint stores its own turn's delta, so a
54+
// subagent's tokens are counted exactly once rather than re-added on every
55+
// turn after it is discovered.
56+
Tokens *types.TokenUsage
4857
}
4958

5059
// Importer is the per-agent seam: it locates an agent's transcripts for a repo
@@ -215,6 +224,13 @@ func writeSessionState(ctx context.Context, imp Importer, sf SessionFile, turns
215224
if turn.Model != "" {
216225
model = turn.Model
217226
}
227+
// turn.Tokens holds per-turn deltas for every field, including
228+
// SubagentTokens (rescoped from a cumulative snapshot in
229+
// rescopeSubagentTokensToDeltas — see the Turn.Tokens doc). Summing
230+
// them therefore yields the correct session total: main-agent fields
231+
// add up, and the subagent deltas sum back to the final cumulative
232+
// subagent snapshot exactly once instead of being multiplied by the
233+
// number of turns after each subagent was first discovered.
218234
tokens = types.AddTokenUsage(tokens, turn.Tokens)
219235
}
220236
if started.IsZero() {

cmd/entire/cli/agentimport/claude.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ func (claudeImporter) Discover(repoRoot, overridePath string, now time.Time, ses
3232
return discoverSessionFiles(dir, now, sessionFilter, jsonlSessionResolver(".jsonl", identitySessionID))
3333
}
3434

35-
// SplitTurns produces one Turn per user-prompt line. Token usage for each turn
36-
// is computed on the slice [LineStart, LineEnd) so turns don't double-count
37-
// later turns. tool_result lines (Type == "user" but no text content) do not
38-
// start a turn.
35+
// SplitTurns produces one Turn per user-prompt line. Main-agent token usage for
36+
// each turn is computed on the slice [LineStart, LineEnd) so turns don't
37+
// double-count later turns; subagent token usage is discovered from the full
38+
// prefix and rescoped to a per-turn delta by splitLineTurns (see
39+
// rescopeSubagentTokensToDeltas). tool_result lines (Type == "user" but no text
40+
// content) do not start a turn.
3941
func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {
4042
subagentsDir := filepath.Join(filepath.Dir(sf.Path), sf.SessionID, "subagents")
4143
ag := &claudecode.ClaudeCodeAgent{}

0 commit comments

Comments
 (0)