fix(cli): count subagent stats

This commit is contained in:
Alex Alecu
2026-04-24 19:19:41 +03:00
committed by Imanol Maiztegui
parent 2a94a0cc11
commit 9e419b3e86
2 changed files with 68 additions and 22 deletions
+14 -7
View File
@@ -1,8 +1,9 @@
import type { Argv } from "yargs"
import type { MessageV2 } from "../../session/message-v2" // kilocode_change
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
import { Database, isNull } from "../../storage" // kilocode_change - isNull for root session filter
import { Database } from "../../storage"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project"
import { Instance } from "../../project/instance"
@@ -89,10 +90,7 @@ async function getCurrentProject(): Promise<Project.Info> {
}
async function getAllSessions(): Promise<Session.Info[]> {
// kilocode_change start - exclude subagent (child) sessions; their cost is already propagated into the
// parent's tool-wrapper assistant message, so summing both would double-count (#6321)
const rows = Database.use((db) => db.select().from(SessionTable).where(isNull(SessionTable.parent_id)).all())
// kilocode_change end
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
@@ -196,7 +194,16 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
// kilocode_change start - count propagated subagent cost once but keep child model stats (#6321)
const cost = (() => {
const parts = message.parts.filter(
(part: MessageV2.Part): part is MessageV2.StepFinishPart => part.type === "step-finish",
)
if (parts.length === 0) return message.info.cost || 0
return parts.reduce((sum: number, part: MessageV2.StepFinishPart) => sum + part.cost, 0)
})()
if (!session.parentID) sessionCost += message.info.cost || 0
// kilocode_change end
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
@@ -207,7 +214,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
sessionModelUsage[modelKey].cost += cost
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
@@ -1,8 +1,7 @@
// Verifies `kilo stats` does not double-count subagent cost. The task tool
// propagates each child session's total cost up to the parent's tool-wrapper
// assistant message. If stats summed every session indiscriminately, that
// propagated cost would appear in both the parent wrapper and the child's
// own messages. The aggregator is now filtered to root sessions (#6321).
// Verifies `kilo stats` does not double-count subagent cost while still
// including child-session messages, tokens, tools, and model usage. The task
// tool propagates each child session's total cost up to the parent's
// tool-wrapper assistant message (#6321).
import { afterEach, describe, expect, test } from "bun:test"
import { aggregateSessionStats } from "../../src/cli/cmd/stats"
@@ -10,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Session } from "../../src/session"
import { MessageID } from "../../src/session/schema"
import { MessageID, PartID } from "../../src/session/schema"
import { Log } from "../../src/util"
import { tmpdir } from "../fixture/fixture"
@@ -42,8 +41,40 @@ function assistant(sessionID: string, parentID: string, cost: number): MessageV2
}
}
async function step(sessionID: string, messageID: string, cost: number) {
await Session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
type: "step-finish",
reason: "stop",
cost,
tokens: { total: 15, input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
})
}
async function tool(sessionID: string, messageID: string) {
const time = Date.now()
await Session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: {},
output: "ok",
title: "bash",
metadata: {},
time: { start: time, end: time },
},
})
}
describe("stats subagent cost", () => {
test("totalCost excludes children whose cost was propagated into the parent", async () => {
test("counts child usage without double-counting propagated cost", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
@@ -51,7 +82,6 @@ describe("stats subagent cost", () => {
const parent = await Session.create({ title: "root" })
const child = await Session.create({ parentID: parent.id, title: "subagent" })
// The parent's tool-wrapper assistant message shows the propagated total (own LLM + child).
const userMsg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -60,8 +90,9 @@ describe("stats subagent cost", () => {
model: ref,
time: { created: Date.now() },
} as any)
await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5))
// The child session independently records its own LLM cost.
const parentMsg = await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5))
await step(parent.id, parentMsg.id, 1)
const childUser = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -70,14 +101,22 @@ describe("stats subagent cost", () => {
model: ref,
time: { created: Date.now() },
} as any)
await Session.updateMessage(assistant(child.id, childUser.id, 0.5))
const childMsg = await Session.updateMessage(assistant(child.id, childUser.id, 0.5))
await step(child.id, childMsg.id, 0.5)
await tool(child.id, childMsg.id)
const stats = await aggregateSessionStats()
// Without the fix, totalCost would be 1.5 + 0.5 = 2.0 (child counted twice).
// With the fix, only the parent (root) session contributes: 1.5 (which already
// includes the 0.5 propagated from the child).
const model = stats.modelUsage["test/test-model"]!
expect(stats.totalCost).toBeCloseTo(1.5, 6)
expect(stats.totalSessions).toBe(1)
expect(stats.totalSessions).toBe(2)
expect(stats.totalMessages).toBe(4)
expect(stats.totalTokens.input).toBe(20)
expect(stats.totalTokens.output).toBe(10)
expect(stats.toolUsage.bash).toBe(1)
expect(model.messages).toBe(2)
expect(model.tokens.input).toBe(20)
expect(model.tokens.output).toBe(10)
expect(model.cost).toBeCloseTo(1.5, 6)
},
})
})