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
18 changes: 18 additions & 0 deletions packages/gittensory-engine/src/miner/agent-sdk-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}

/** Real token count from the SDK's own result message (#5653). Both `SDKResultSuccess` and `SDKResultError`
* declare `usage: NonNullableUsage` unconditionally -- present whenever a result message arrived at all, same
* 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 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 {
const usage = asRecord(resultMessage?.usage);
const inputTokens = typeof usage?.input_tokens === "number" ? usage.input_tokens : undefined;
const outputTokens = typeof usage?.output_tokens === "number" ? usage.output_tokens : undefined;
if (inputTokens === undefined && outputTokens === undefined) return undefined;
return (inputTokens ?? 0) + (outputTokens ?? 0);
}

async function listWorktreeChangedFiles(cwd: string): Promise<string[]> {
const [tracked, untracked] = await Promise.all([
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "HEAD", "--"]),
Expand Down Expand Up @@ -168,6 +182,7 @@ export function createAgentSdkCodingAgentDriver(
// or not (the session was billed either way), absent only when the stream produced no result message.
const costUsd =
typeof resultMessage?.total_cost_usd === "number" ? resultMessage.total_cost_usd : undefined;
const tokensUsed = tokensFromResultMessage(resultMessage);
const resultText =
typeof resultMessage?.result === "string" ? redactSecrets(resultMessage.result) : "";
const transcript = redactSecrets(
Expand All @@ -194,6 +209,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
error: `agent_sdk_${subtype === "success" ? "errored" : subtype}`,
};
}
Expand All @@ -210,6 +226,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
error: `agent_sdk_changed_files_unavailable: ${detail}`,
};
}
Expand All @@ -222,6 +239,7 @@ export function createAgentSdkCodingAgentDriver(
transcript,
turnsUsed,
costUsd,
tokensUsed,
};
},
};
Expand Down
87 changes: 63 additions & 24 deletions packages/gittensory-engine/src/miner/cli-subprocess-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,41 +122,69 @@ function resolveDefaultBuildArgs(command: string): (task: CodingAgentDriverTask)
throw new Error(`unsupported_cli_subprocess_command:${command}`);
}

/** Best-effort real dollar-cost extraction from a CLI's own stdout. Mirrors src/selfhost/ai.ts's
* `extractCliUsage`/`COST_KEYS` (redeclared here, not imported, per this file's own no-src-import
* convention) but narrowed to just the cost field this driver's `CodingAgentDriverResult` surfaces -- tokens
* and model aren't part of that shape. Tries the whole trimmed stdout as one JSON object first (claude's
* `--output-format json` shape, empirically confirmed to carry `total_cost_usd`, the exact same field name
* the Agent-SDK's own result message carries), then scans line by line (codex's `--json` JSONL stream,
* "still evolving" per src/selfhost/ai.ts's own comment, so multiple real key spellings are tolerated). A
* missing/malformed field means "no cost signal", never an error -- never fabricated. */
/** Best-effort real dollar-cost AND token-usage extraction from a CLI's own stdout. Mirrors src/selfhost/ai.ts's
* `extractCliUsage`/`COST_KEYS`/`INPUT_TOKEN_KEYS`/`OUTPUT_TOKEN_KEYS`/`TOTAL_TOKEN_KEYS` (redeclared here, not
* imported, per this file's own no-src-import convention) -- ported in full as of #5653 (previously narrowed
* to just cost; tokens were left out at the time, not because the data doesn't exist). Tries the whole trimmed
* stdout as one JSON object first (claude's `--output-format json` shape, empirically confirmed to carry
* `total_cost_usd`, the exact same field name the Agent-SDK's own result message carries), then scans line by
* line (codex's `--json` JSONL stream, "still evolving" per src/selfhost/ai.ts's own comment, so multiple real
* key spellings are tolerated, and usage/token_usage/tokenUsage/usage_metadata sub-objects are all checked, same
* as src/selfhost/ai.ts). A missing/malformed field means "no signal", never an error -- never fabricated. */
const COST_KEYS = ["total_cost_usd", "totalCostUsd", "cost_usd", "costUsd"] as const;
const INPUT_TOKEN_KEYS = ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"] as const;
const OUTPUT_TOKEN_KEYS = ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"] as const;
const TOTAL_TOKEN_KEYS = ["total_tokens", "totalTokens"] as const;

type CliUsage = { costUsd?: number; inputTokens?: number; outputTokens?: number; totalTokens?: number };

function finiteNonNegativeNumber(value: unknown): number | undefined {
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
return Number.isFinite(n) && n >= 0 ? n : undefined;
}

function costUsdFromRecord(record: Record<string, unknown>): number | undefined {
let best: number | undefined;
for (const key of COST_KEYS) {
function maxNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
let out: number | undefined;
for (const key of keys) {
const n = finiteNonNegativeNumber(record[key]);
if (n !== undefined) best = Math.max(best ?? 0, n);
if (n !== undefined) out = Math.max(out ?? 0, n);
}
return best;
return out;
}

function asPlainRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}

function extractCostUsd(stdout: string): number | undefined {
function mergeCliUsage(out: CliUsage, record: Record<string, unknown>): void {
const nested = [
record,
asPlainRecord(record.usage),
asPlainRecord(record.token_usage),
asPlainRecord(record.tokenUsage),
asPlainRecord(record.usage_metadata),
asPlainRecord(record.usageMetadata),
].filter((entry): entry is Record<string, unknown> => Boolean(entry));
for (const entry of nested) {
const costUsd = maxNumber(entry, COST_KEYS);
if (costUsd !== undefined) out.costUsd = Math.max(out.costUsd ?? 0, costUsd);
const inputTokens = maxNumber(entry, INPUT_TOKEN_KEYS);
if (inputTokens !== undefined) out.inputTokens = Math.max(out.inputTokens ?? 0, inputTokens);
const outputTokens = maxNumber(entry, OUTPUT_TOKEN_KEYS);
if (outputTokens !== undefined) out.outputTokens = Math.max(out.outputTokens ?? 0, outputTokens);
const totalTokens = maxNumber(entry, TOTAL_TOKEN_KEYS);
if (totalTokens !== undefined) out.totalTokens = Math.max(out.totalTokens ?? 0, totalTokens);
}
}

function extractCliUsage(stdout: string): CliUsage {
const usage: CliUsage = {};
const trimmed = stdout.trim();
if (!trimmed) return undefined;
let best: number | undefined;
if (!trimmed) return usage;
const tryLine = (text: string): void => {
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const n = costUsdFromRecord(parsed as Record<string, unknown>);
if (n !== undefined) best = Math.max(best ?? 0, n);
}
const parsed = asPlainRecord(JSON.parse(text));
if (parsed) mergeCliUsage(usage, parsed);
} catch {
/* not JSON -- best-effort only */
}
Expand All @@ -165,7 +193,16 @@ function extractCostUsd(stdout: string): number | undefined {
for (const line of trimmed.split(/\r?\n/)) {
if (line.trim()) tryLine(line);
}
return best;
return usage;
}

/** 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);
}

/** Claude Code's `--output-format json` sometimes exits non-zero while still emitting a structured
Expand Down Expand Up @@ -328,13 +365,15 @@ export function createCliSubprocessCodingAgentDriver(options: CliSubprocessDrive
};
}
}
const costUsd = extractCostUsd(spawned.stdout);
const usage = extractCliUsage(spawned.stdout);
const tokensUsed = totalTokensFromUsage(usage);
return {
ok: true,
changedFiles: [],
summary: `${options.command} completed for ${task.attemptId}`,
transcript,
...(costUsd !== undefined ? { costUsd } : {}),
...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
...(tokensUsed !== undefined ? { tokensUsed } : {}),
};
},
};
Expand Down
4 changes: 4 additions & 0 deletions packages/gittensory-engine/src/miner/coding-agent-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export type CodingAgentDriverResult = {
/** Real dollar cost of this driver run, when the provider reports one. Absent (not zero) when the provider
* never got far enough to have a cost, or reports no cost signal at all -- never fabricated. */
costUsd?: number | undefined;
/** Real token count (input + output) of this driver run, when the provider reports one (#5653). Absent (not
* 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;
error?: string | undefined;
};

Expand Down
19 changes: 10 additions & 9 deletions packages/gittensory-engine/src/miner/iterate-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
//
// BOUNDED INSIDE THE LOOP: both the iteration ceiling (`input.maxIterations`) and the optional cumulative
// budget (`input.budget`, evaluated every iteration via attempt-metering.ts's `accumulateAttemptUsage`/
// `evaluateAttemptBudget` against real per-iteration turns/costUsd/wallClockMs -- tokens stays an honest 0,
// no driver reports a real token count today, #5395) are enforced here every iteration -- not left to an
// external caller to remember, and not just capped after the fact between loop cycles (loop-cli.js's own
// governor cap usage). A `maxIterations <= 0` input abandons immediately, before ever invoking the driver.
// `evaluateAttemptBudget` against real per-iteration turns/costUsd/wallClockMs/tokens, #5395/#5653) are
// enforced here every iteration -- not left to an external caller to remember, and not just capped after the
// fact between loop cycles (loop-cli.js's own governor cap usage). A `maxIterations <= 0` input abandons
// immediately, before ever invoking the driver.
//
// AUDITABLE: every iteration's decision (continue / handoff / abandon) is recorded via the injected
// `appendAttemptLogEvent` dependency (attempt-log.ts's normalized event shape) before this function returns
Expand Down Expand Up @@ -120,8 +120,8 @@ export type IterateLoopResult = {
totalCostUsd: number;
/** The real accumulated {@link AttemptMeterTotals} across every iteration that ran (attempt-metering.ts,
* #5395) -- a superset of `totalTurnsUsed`/`totalCostUsd` above that also carries `wallClockMs` (real,
* measured around each driver invocation) and `tokens` (always 0 today -- no driver reports a real token
* count, an honest absence rather than a fabricated number). */
* measured around each driver invocation) and `tokens` (real per-iteration token usage when the driver
* reports one, #5653 -- 0 for a driver/iteration that reports no token signal, never fabricated). */
finalMeterTotals: AttemptMeterTotals;
/** The budget axes breached at the point this attempt abandoned, when `input.budget` was set and at least
* one axis was at/over its ceiling -- empty when no budget was configured or none breached. */
Expand Down Expand Up @@ -328,10 +328,11 @@ async function runIterateLoopCore(input: IterateLoopInput, deps: IterateLoopDeps
const iterationElapsedMs = Math.max(0, nowMs() - iterationStartMs);
totalTurnsUsed += driverResult.turnsUsed ?? 0;
totalCostUsd += driverResult.costUsd ?? 0;
// tokens stays an honest 0: no CodingAgentDriver reports a real per-iteration token count today (#5395) --
// an absence, never a fabricated number, matching this package's costUsd discipline elsewhere.
// Real per-iteration tokens (#5653): CodingAgentDriverResult.tokensUsed is now populated by every driver
// that reports one (Agent SDK's own result-message usage, or CLI JSON/JSONL stdout) -- 0 only when the
// driver genuinely reports no token signal for this iteration, same honest-absence discipline as costUsd.
tracker.totals = accumulateAttemptUsage(tracker.totals, {
tokens: 0,
tokens: driverResult.tokensUsed ?? 0,
turns: driverResult.turnsUsed ?? 0,
wallClockMs: iterationElapsedMs,
costUsd: driverResult.costUsd ?? 0,
Expand Down
117 changes: 117 additions & 0 deletions packages/gittensory-engine/test/agent-sdk-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,123 @@ test("success fails closed when changed-file enumeration is unavailable, but sti
assert.equal(result.costUsd, 0.0042);
});

test("success reports real input+output tokens from the SDK's own usage field (#5653)", async () => {
const driver = driverWith({
query: queryYielding([
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 3,
result: "done",
total_cost_usd: 0.01,
usage: { input_tokens: 1000, output_tokens: 234 },
},
]),
});

const result = await driver.run(task);

assert.equal(result.ok, true);
assert.equal(result.tokensUsed, 1234);
});

test("failure (non-success subtype) still reports real tokens -- the session was billed either way, same as costUsd", 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.tokensUsed, 600);
});

test("tokensUsed is undefined (never a fabricated 0) when the result message carries no usage field at all", async () => {
const driver = driverWith({
query: queryYielding([
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done" },
]),
});

const result = await driver.run(task);

assert.equal(result.ok, true);
assert.equal(result.tokensUsed, undefined);
});

test("tokensUsed is undefined when usage exists but is malformed (not an object, or non-numeric fields)", async () => {
const malformedUsage = driverWith({
query: queryYielding([
{ type: "result", subtype: "success", is_error: false, num_turns: 2, result: "done", usage: "not-an-object" },
]),
});
const malformedResult = await malformedUsage.run(task);
assert.equal(malformedResult.tokensUsed, undefined);

const nonNumericFields = driverWith({
query: queryYielding([
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 2,
result: "done",
usage: { input_tokens: "a lot", output_tokens: null },
},
]),
});
const nonNumericResult = await nonNumericFields.run(task);
assert.equal(nonNumericResult.tokensUsed, undefined);
});

test("tokensUsed sums whichever of input/output tokens IS a real number, when only input is present", async () => {
const driver = driverWith({
query: queryYielding([
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 2,
result: "done",
usage: { input_tokens: 42 },
},
]),
});

const result = await driver.run(task);

assert.equal(result.tokensUsed, 42);
});

test("tokensUsed sums whichever of input/output tokens IS a real number, when only output is present", async () => {
const driver = driverWith({
query: queryYielding([
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 2,
result: "done",
usage: { output_tokens: 17 },
},
]),
});

const result = await driver.run(task);

assert.equal(result.tokensUsed, 17);
});

test("success stringifies a non-Error changed-file enumeration failure", async () => {
const driver = driverWith({
query: queryYielding([
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type AttemptCliResult =
submissionMode: "observe" | "enforce";
totalTurnsUsed: number;
totalCostUsd: number;
totalTokensUsed: number;
iterationsUsed: number;
reason?: string;
decision?: unknown;
Expand Down
13 changes: 9 additions & 4 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,10 @@ export async function runAttempt(args, options = {}) {
// is 0 for those -- an honest absence, not a fabricated number.
totalTurnsUsed: result.loopResult.totalTurnsUsed,
totalCostUsd: result.loopResult.totalCostUsd,
// Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field
// (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal
// on any iteration this attempt ran, never fabricated.
totalTokensUsed: result.loopResult.finalMeterTotals.tokens,
iterationsUsed: result.loopResult.iterationsUsed,
...("reason" in result ? { reason: result.reason } : {}),
...("decision" in result ? { decision: result.decision } : {}),
Expand All @@ -481,10 +485,10 @@ export async function runAttempt(args, options = {}) {
// AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail
// iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails
// closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees
// resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. tokensUsed is deliberately omitted
// (normalizes to null): no driver reports real token usage today (#5395), and null-for-"no signal" is more
// honest here than a fabricated 0. A logging failure must never fail an otherwise-successful attempt --
// mirrors iterate-loop.ts's own safeAppendAttemptLogEvent non-fatal handling.
// resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real,
// driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never
// fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's
// own safeAppendAttemptLogEvent non-fatal handling.
try {
attemptLog.appendAttemptLogEvent({
eventType: "attempt_outcome_summary",
Expand All @@ -494,6 +498,7 @@ export async function runAttempt(args, options = {}) {
reason: `attempt finished with outcome: ${result.outcome}`,
provider: resolveFirstConfiguredCodingAgentDriverName(env),
costUsd: finalResult.totalCostUsd,
tokensUsed: finalResult.totalTokensUsed,
});
} catch {
// Deliberately swallowed -- see comment above.
Expand Down
Loading