Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions packages/loopover-engine/src/miner/agent-sdk-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
CodingAgentDriver,
CodingAgentDriverResult,
CodingAgentDriverTask,
CodingAgentTokenUsage,
} from "./coding-agent-driver.js";

/**
Expand Down Expand Up @@ -91,13 +92,23 @@ function finiteNonNegativeNumber(value: unknown): number | undefined {
* as `total_cost_usd`. `NonNullableUsage`'s `input_tokens`/`output_tokens` are themselves non-nullable numbers
* once `usage` exists, but this driver reads `resultMessage` as a loosely-typed record (like every other field
* read here), so both are re-validated defensively (finite + non-negative, #5827) rather than trusted from an
* untyped source. Returns undefined (never a fabricated 0) when `usage` is absent or malformed. */
function tokensFromResultMessage(resultMessage: Record<string, unknown> | null): number | undefined {
* untyped source. Returns an EMPTY usage (never a fabricated 0) when `usage` is absent or malformed.
*
* #10198: returns the split alongside the blended total rather than only the sum. Both sides were already read
* here and then added together, discarding the parts -- which left the miner with nothing to put in PostHog's
* own `$ai_input_tokens`/`$ai_output_tokens`, the properties its cost views read. Each field is omitted rather
* than zeroed when the provider did not report it: a fabricated 0 is indistinguishable from a real one in an
* aggregate. */
function tokensFromResultMessage(resultMessage: Record<string, unknown> | null): CodingAgentTokenUsage {
const usage = asRecord(resultMessage?.usage);
const inputTokens = finiteNonNegativeNumber(usage?.input_tokens);
const outputTokens = finiteNonNegativeNumber(usage?.output_tokens);
if (inputTokens === undefined && outputTokens === undefined) return undefined;
return (inputTokens ?? 0) + (outputTokens ?? 0);
if (inputTokens === undefined && outputTokens === undefined) return {};
return {
tokensUsed: (inputTokens ?? 0) + (outputTokens ?? 0),
...(inputTokens === undefined ? {} : { inputTokens }),
...(outputTokens === undefined ? {} : { outputTokens }),
};
}

async function listWorktreeChangedFiles(cwd: string): Promise<string[]> {
Expand Down Expand Up @@ -197,7 +208,7 @@ export function createAgentSdkCodingAgentDriver(
// `total_cost_usd: number` unconditionally -- present whenever a result message arrived at all, success
// or not (the session was billed either way), absent only when the stream produced no result message.
const costUsd = finiteNonNegativeNumber(resultMessage?.total_cost_usd);
const tokensUsed = tokensFromResultMessage(resultMessage);
const tokenUsage = tokensFromResultMessage(resultMessage);
const resultText =
typeof resultMessage?.result === "string" ? redactSecrets(resultMessage.result) : "";
const transcript = redactSecrets(
Expand All @@ -224,7 +235,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
...tokenUsage,
error: `agent_sdk_${subtype === "success" ? "errored" : subtype}`,
};
}
Expand All @@ -241,7 +252,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
...tokenUsage,
error: `agent_sdk_changed_files_unavailable: ${detail}`,
};
}
Expand All @@ -254,7 +265,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
...tokenUsage,
};
},
};
Expand Down
20 changes: 14 additions & 6 deletions packages/loopover-engine/src/miner/cli-subprocess-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
CodingAgentDriver,
CodingAgentDriverResult,
CodingAgentDriverTask,
CodingAgentTokenUsage,
} from "./coding-agent-driver.js";
import { buildAllowlistedEnv, redactSecrets } from "../subprocess-env.js";

Expand Down Expand Up @@ -206,10 +207,18 @@ function extractCliUsage(stdout: string): CliUsage {
/** Real token count (input + output) from `extractCliUsage`'s CliUsage, when either is present -- prefers an
* explicit `totalTokens` key if the CLI reported one directly (never double-counted against input+output),
* otherwise sums input+output. Undefined (never a fabricated 0) when neither is present. */
function totalTokensFromUsage(usage: CliUsage): number | undefined {
if (usage.totalTokens !== undefined) return usage.totalTokens;
if (usage.inputTokens === undefined && usage.outputTokens === undefined) return undefined;
return (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
/** #10198: carries the input/output split through alongside the blended total. The CLI reports the two sides
* separately and this used to return only their sum, so the miner could not populate PostHog's own
* `$ai_input_tokens`/`$ai_output_tokens`. A CLI that reports ONLY `totalTokens` still yields just the blended
* figure -- the split stays absent rather than being invented from it. */
function totalTokensFromUsage(usage: CliUsage): CodingAgentTokenUsage {
const split = {
...(usage.inputTokens === undefined ? {} : { inputTokens: usage.inputTokens }),
...(usage.outputTokens === undefined ? {} : { outputTokens: usage.outputTokens }),
};
if (usage.totalTokens !== undefined) return { tokensUsed: usage.totalTokens, ...split };
if (usage.inputTokens === undefined && usage.outputTokens === undefined) return {};
return { tokensUsed: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0), ...split };
}

/** Claude Code's `--output-format json` sometimes exits non-zero while still emitting a structured
Expand Down Expand Up @@ -282,10 +291,9 @@ export function createCliSubprocessCodingAgentDriver(options: CliSubprocessDrive
// below report spend symmetrically with the success path and with agent-sdk-driver.ts, preventing
// attempt-metering budget-ceiling undercounting (#8871). extractCliUsage tolerates unparseable/empty stdout.
const usage = extractCliUsage(spawned.stdout);
const tokensUsed = totalTokensFromUsage(usage);
const usageFields = {
...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
...(tokensUsed !== undefined ? { tokensUsed } : {}),
...totalTokensFromUsage(usage),
};

if (spawned.timedOut && spawned.stalledNoOutput) {
Expand Down
11 changes: 11 additions & 0 deletions packages/loopover-engine/src/miner/coding-agent-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@ export type CodingAgentDriverResult = {
* zero) when the provider never got far enough, or reports no token signal at all -- never fabricated,
* mirroring `costUsd`'s own convention. */
tokensUsed?: number | undefined;
/** The input/output split behind `tokensUsed`, when the provider reports the two sides separately (#10198).
* Both drivers already read them individually and then summed them away, which left the miner unable to
* populate PostHog's own `$ai_input_tokens`/`$ai_output_tokens` -- the properties its cost views read --
* so miner spend was invisible there. Same never-fabricated convention as the two fields above: a provider
* that reports only a blended total leaves these absent, and the blended `tokensUsed` still stands alone. */
inputTokens?: number | undefined;
outputTokens?: number | undefined;
error?: string | undefined;
};

/** The token fields a driver contributes to its result (#10198) -- spread into the result at each return site
* so a driver can never report a split that disagrees with its own blended total. */
export type CodingAgentTokenUsage = Pick<CodingAgentDriverResult, "tokensUsed" | "inputTokens" | "outputTokens">;

export interface CodingAgentDriver {
run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult>;
}
Expand Down
76 changes: 76 additions & 0 deletions packages/loopover-engine/test/agent-sdk-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,79 @@ test("constructs with no options, defaulting to the real SDK query loop without
const driver = createAgentSdkCodingAgentDriver();
assert.equal(typeof driver.run, "function");
});

// #10198: the driver used to sum input/output tokens and DISCARD the two sides. The miner's PostHog capture
// therefore had nothing to put in `$ai_input_tokens`/`$ai_output_tokens` -- the properties PostHog's own cost
// views read -- so every miner generation registered there as 0 input and 0 output tokens.
test("reports the input/output split alongside the blended total (#10198)", async () => {
const driver = driverWith({
query: queryYielding([
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 2,
result: "done",
usage: { input_tokens: 1000, output_tokens: 234 },
},
]),
});

const result = await driver.run(task);

assert.equal(result.tokensUsed, 1234);
assert.equal(result.inputTokens, 1000);
assert.equal(result.outputTokens, 234);
});

test("the split rides the failure results too, exactly like tokensUsed and costUsd (#10198)", async () => {
const driver = driverWith({
query: queryYielding([
{
type: "result",
subtype: "error_max_turns",
is_error: true,
num_turns: 6,
total_cost_usd: 0.05,
usage: { input_tokens: 500, output_tokens: 100 },
},
]),
});

const result = await driver.run(task);

assert.equal(result.ok, false);
assert.equal(result.inputTokens, 500);
assert.equal(result.outputTokens, 100);
});

test("a side the provider did not report stays ABSENT rather than being zeroed (#10198)", async () => {
// A fabricated 0 is indistinguishable from a real 0 once aggregated, so an out-of-contract or missing side
// must not become one -- the blended total still counts only what was real.
const onlyInput = driverWith({
query: queryYielding([
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done", usage: { input_tokens: 100 } },
]),
});
const onlyInputResult = await onlyInput.run(task);
assert.equal(onlyInputResult.tokensUsed, 100);
assert.equal(onlyInputResult.inputTokens, 100);
assert.equal(onlyInputResult.outputTokens, undefined);

const badOutput = driverWith({
query: queryYielding([
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done", usage: { input_tokens: 100, output_tokens: -5 } },
]),
});
const badOutputResult = await badOutput.run(task);
assert.equal(badOutputResult.tokensUsed, 100);
assert.equal(badOutputResult.outputTokens, undefined);

const noUsage = driverWith({
query: queryYielding([{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" }]),
});
const noUsageResult = await noUsage.run(task);
assert.equal(noUsageResult.tokensUsed, undefined);
assert.equal(noUsageResult.inputTokens, undefined);
assert.equal(noUsageResult.outputTokens, undefined);
});
8 changes: 5 additions & 3 deletions packages/loopover-miner/lib/coding-agent-construction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ export function createRealCliSubprocessSpawn(): CliSubprocessSpawnFn {
* vendor client -- the same "no cross-package import" boundary this file's own header already documents
* for its spawn implementation): this is the miner CLI's own host-bound construction site, exactly where
* `src/selfhost/`'s equivalent ORB-side wrapper (`withAiGenerationCapture`, ai.ts) lives relative to its
* own chain. `CodingAgentDriverResult` carries a single blended `costUsd`/`tokensUsed` (no input/output
* split, unlike ORB's `AiUsage`) -- captureMinerPostHogAiGeneration is deliberately built for that exact
* shape, never fabricating a split its source data doesn't have. A driver failure is reported via
* own chain. `CodingAgentDriverResult` carries the blended `costUsd`/`tokensUsed` plus the input/output
* split when the provider reported one (#10198); all of it is forwarded verbatim, and a driver that knows
* no split simply leaves those fields absent rather than having one fabricated. A driver failure is reported via
* `result.ok === false` (the real, observed contract every shipped driver follows -- none of them throw
* for an ordinary task failure), with a genuine thrown exception handled defensively on top. */
export function withCodingAgentAiGenerationCapture(providerName: string, model: string, driver: CodingAgentDriver): CodingAgentDriver {
Expand All @@ -95,6 +95,8 @@ export function withCodingAgentAiGenerationCapture(providerName: string, model:
latencyMs: Date.now() - startedAtMs,
isError: !result.ok,
totalTokens: result.tokensUsed,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
totalCostUsd: result.costUsd,
error: result.ok ? undefined : result.error,
});
Expand Down
27 changes: 17 additions & 10 deletions packages/loopover-miner/lib/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,24 @@ export function captureMinerPostHogEvent(event: string, properties: Record<strin
}
}

/** One AMS coding-agent driver attempt (#8296 AMS follow-up, epic #8286 track 3). All three driver types
* (claude-cli/codex-cli/agent-sdk, packages/loopover-engine's CodingAgentDriver) report a single blended
* `costUsd`/`tokensUsed` -- unlike ORB's self-host `AiUsage`, there is no input/output split available at
* this layer, so this deliberately does NOT populate `$ai_input_tokens`/`$ai_output_tokens` with a
* fabricated split (they stay 0, honestly representing "no split known"); the real total rides in the
* plain `tokens_used` property instead. `$ai_total_cost_usd` IS one of PostHog's own recognized
* `$ai_generation` properties and needs no split, so it's populated directly when known. No field here
* ever carries prompt/diff/transcript content -- metadata only, same policy as the ORB side. */
/** One AMS coding-agent driver attempt (#8296 AMS follow-up, epic #8286 track 3).
*
* #10198: `inputTokens`/`outputTokens` are the REAL split, now that `CodingAgentDriverResult` carries it.
* Both engine drivers already read the two sides from their provider and then summed them away, so this
* event could only report a blended `tokens_used` -- a property PostHog's own cost views do not read, which
* made every miner generation register as 0 input and 0 output tokens there. The split is still never
* FABRICATED: a provider that reports only a blended total leaves both absent, and the blended figure keeps
* riding in `tokens_used` on its own. `$ai_total_cost_usd` is one of PostHog's recognized properties and
* needs no split, so it is populated directly when known. No field here ever carries prompt/diff/transcript
* content -- metadata only, same policy as the ORB side. */
export type MinerAiGenerationEvent = {
provider: string;
model: string;
latencyMs: number;
isError: boolean;
totalTokens?: number | undefined;
inputTokens?: number | undefined;
outputTokens?: number | undefined;
totalCostUsd?: number | undefined;
error?: unknown;
};
Expand All @@ -143,8 +147,11 @@ export function captureMinerPostHogAiGeneration(event: MinerAiGenerationEvent):
// PostHog's own $ai_generation schema reports latency in SECONDS, not ms.
$ai_latency: event.latencyMs / 1000,
$ai_http_status: event.isError ? 500 : 200,
$ai_input_tokens: 0,
$ai_output_tokens: 0,
// #10198: the provider's real split when it reported one. 0 remains the honest fallback for a provider
// that only ever reports a blended total -- it means "no split known", and `tokens_used` below still
// carries the figure that IS known.
$ai_input_tokens: Number.isFinite(event.inputTokens) ? event.inputTokens : 0,
$ai_output_tokens: Number.isFinite(event.outputTokens) ? event.outputTokens : 0,
$ai_is_error: event.isError,
};
if (Number.isFinite(event.totalTokens)) properties.tokens_used = event.totalTokens;
Expand Down
46 changes: 46 additions & 0 deletions test/unit/cli-subprocess-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,52 @@ describe("createCliSubprocessCodingAgentDriver (#4266)", () => {
});

describe("REGRESSION: real token-usage extraction, ported from src/selfhost/ai.ts's extractCliUsage (#5653)", () => {
// #10198: the driver read both sides and returned only their sum, so the miner's PostHog capture had
// nothing for `$ai_input_tokens`/`$ai_output_tokens` -- the properties PostHog's own cost views read.
it("reports the input/output split alongside the blended total (#10198)", async () => {
const { spawn } = fakeSpawn({
stdout: JSON.stringify({ type: "result", subtype: "success", result: "done", input_tokens: 1000, output_tokens: 234 }),
code: 0,
});
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn });
const result = await driver.run(TASK);
expect(result.tokensUsed).toBe(1234);
expect(result.inputTokens).toBe(1000);
expect(result.outputTokens).toBe(234);
});

it("keeps an explicit total_tokens as the blended figure WITHOUT inventing a split from it (#10198)", async () => {
// A CLI that reports only a total genuinely has no split to report; deriving one would be a fabrication.
const { spawn } = fakeSpawn({ stdout: JSON.stringify({ total_tokens: 999 }), code: 0 });
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn });
const result = await driver.run(TASK);
expect(result.tokensUsed).toBe(999);
expect(result.inputTokens).toBeUndefined();
expect(result.outputTokens).toBeUndefined();
});

it("carries the split through when total_tokens AND the two sides are all reported (#10198)", async () => {
const { spawn } = fakeSpawn({
stdout: JSON.stringify({ input_tokens: 100, output_tokens: 50, total_tokens: 999 }),
code: 0,
});
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn });
const result = await driver.run(TASK);
// The CLI's own total still wins over the sum -- unchanged from before the split existed.
expect(result.tokensUsed).toBe(999);
expect(result.inputTokens).toBe(100);
expect(result.outputTokens).toBe(50);
});

it("leaves a side ABSENT rather than zeroed when the CLI reported only one of them (#10198)", async () => {
const { spawn } = fakeSpawn({ stdout: JSON.stringify({ input_tokens: 42 }), code: 0 });
const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn });
const result = await driver.run(TASK);
expect(result.tokensUsed).toBe(42);
expect(result.inputTokens).toBe(42);
expect(result.outputTokens).toBeUndefined();
});

it("sums claude's top-level input_tokens + output_tokens from its single JSON result on success", async () => {
const { spawn } = fakeSpawn({
stdout: JSON.stringify({ type: "result", subtype: "success", result: "done", input_tokens: 1000, output_tokens: 234 }),
Expand Down
Loading