Skip to content

Commit 2168f7e

Browse files
authored
feat(agent): emit rtk token-savings telemetry at end of cloud run (#3146)
1 parent c016f35 commit 2168f7e

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

packages/agent/src/acp-extensions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export const POSTHOG_NOTIFICATIONS = {
7878

7979
/** Permission request resolved, persisted so a reconnecting client can tell it is no longer pending */
8080
PERMISSION_RESOLVED: "_posthog/permission_resolved",
81+
82+
/** RTK output-compression token savings tallied at the end of a run */
83+
RTK_SAVINGS: "_posthog/rtk_savings",
8184
} as const;
8285

8386
/**

packages/agent/src/server/agent-server.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
} from "./cloud-prompt";
7676
import { TaskRunEventStreamSender } from "./event-stream-sender";
7777
import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
78+
import { resolveRtkSavings } from "./rtk-savings";
7879
import {
7980
handoffLocalGitStateSchema,
8081
jsonRpcRequestSchema,
@@ -304,6 +305,7 @@ export class AgentServer {
304305
private app: Hono;
305306
private posthogAPI: PostHogAPIClient;
306307
private eventStreamSender: TaskRunEventStreamSender | null = null;
308+
private rtkSavingsEmitted = false;
307309
private questionRelayedToSlack = false;
308310
private adapterEmittedTurnComplete = false;
309311
private detectedPrUrl: string | null = null;
@@ -2838,6 +2840,11 @@ ${signedCommitInstructions}
28382840
error: errorMessage ?? "Agent error",
28392841
});
28402842

2843+
// Emit before the finally below stops the stream — failed runs still used
2844+
// RTK-compressed commands and must be counted. cleanupSession's emit runs
2845+
// after this stop on the error path, so it can't rely on that one.
2846+
await this.emitRtkSavings();
2847+
28412848
try {
28422849
await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
28432850
status,
@@ -3365,6 +3372,7 @@ ${signedCommitInstructions}
33653372
}
33663373

33673374
if (completeEventStream) {
3375+
await this.emitRtkSavings();
33683376
await this.eventStreamSender?.stop();
33693377
}
33703378

@@ -3373,6 +3381,47 @@ ${signedCommitInstructions}
33733381
this.session = null;
33743382
}
33753383

3384+
/**
3385+
* Emit RTK's output-compression token savings for this run as a telemetry
3386+
* event, so PostHog can report how much context RTK saved. Best-effort and
3387+
* cloud-only (no-op when the event stream isn't configured). Fires at most
3388+
* once per run: it's called from both terminal seams (the error path stops
3389+
* the stream in signalTaskComplete, before cleanupSession runs) and must land
3390+
* before the stream is stopped, since enqueue is a no-op once stopped.
3391+
*/
3392+
private async emitRtkSavings(): Promise<void> {
3393+
if (!this.eventStreamSender || this.rtkSavingsEmitted) return;
3394+
this.rtkSavingsEmitted = true;
3395+
3396+
try {
3397+
const savings = await resolveRtkSavings();
3398+
if (!savings) return;
3399+
3400+
this.eventStreamSender.enqueue({
3401+
type: "notification",
3402+
timestamp: new Date().toISOString(),
3403+
notification: {
3404+
jsonrpc: "2.0",
3405+
method: POSTHOG_NOTIFICATIONS.RTK_SAVINGS,
3406+
// snake_case: these land as PostHog event properties.
3407+
params: {
3408+
task_id: this.config.taskId,
3409+
run_id: this.config.runId,
3410+
team_id: this.config.projectId,
3411+
total_commands: savings.totalCommands,
3412+
input_tokens: savings.inputTokens,
3413+
output_tokens: savings.outputTokens,
3414+
tokens_saved: savings.tokensSaved,
3415+
avg_savings_pct: savings.avgSavingsPct,
3416+
},
3417+
},
3418+
});
3419+
this.logger.debug("Emitted rtk savings", { ...savings });
3420+
} catch (error) {
3421+
this.logger.debug("Failed to emit rtk savings", { error });
3422+
}
3423+
}
3424+
33763425
private async captureCheckpointState(
33773426
localGitState?: HandoffLocalGitState,
33783427
): Promise<void> {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { resolveRtkSavings } from "./rtk-savings";
3+
4+
const GAIN_JSON = JSON.stringify({
5+
summary: {
6+
total_commands: 2,
7+
total_input: 502691,
8+
total_output: 5835,
9+
total_saved: 496856,
10+
avg_savings_pct: 98.8392471717218,
11+
total_time_ms: 3456,
12+
avg_time_ms: 1728,
13+
},
14+
daily: [],
15+
});
16+
17+
function gain(stdout: string) {
18+
return vi.fn().mockResolvedValue(stdout);
19+
}
20+
21+
describe("resolveRtkSavings", () => {
22+
it("parses the rtk gain summary into a typed savings object", async () => {
23+
const runGain = gain(GAIN_JSON);
24+
const savings = await resolveRtkSavings({
25+
resolveBinary: () => "/bundled/rtk",
26+
runGain,
27+
});
28+
29+
expect(runGain).toHaveBeenCalledWith("/bundled/rtk", expect.anything());
30+
expect(savings).toEqual({
31+
totalCommands: 2,
32+
inputTokens: 502691,
33+
outputTokens: 5835,
34+
tokensSaved: 496856,
35+
avgSavingsPct: 98.8392471717218,
36+
});
37+
});
38+
39+
it("does not run rtk when the binary is unresolved (disabled / not found)", async () => {
40+
const runGain = gain(GAIN_JSON);
41+
const savings = await resolveRtkSavings({
42+
resolveBinary: () => undefined,
43+
runGain,
44+
});
45+
46+
expect(savings).toBeNull();
47+
expect(runGain).not.toHaveBeenCalled();
48+
});
49+
50+
it.each([
51+
[
52+
"nothing was tracked (zero commands)",
53+
JSON.stringify({ summary: { total_commands: 0, total_saved: 0 } }),
54+
],
55+
["malformed JSON", "not json"],
56+
["missing summary block", JSON.stringify({ daily: [] })],
57+
["the payload is JSON null", "null"],
58+
["the payload is a non-object", "42"],
59+
])("returns null when %s", async (_label, stdout) => {
60+
const savings = await resolveRtkSavings({
61+
resolveBinary: () => "/bundled/rtk",
62+
runGain: gain(stdout),
63+
});
64+
65+
expect(savings).toBeNull();
66+
});
67+
68+
it("returns null when rtk gain throws rather than disrupting the run", async () => {
69+
const savings = await resolveRtkSavings({
70+
resolveBinary: () => "/bundled/rtk",
71+
runGain: vi.fn().mockRejectedValue(new Error("rtk exploded")),
72+
});
73+
74+
expect(savings).toBeNull();
75+
});
76+
77+
it("coerces missing or non-numeric fields to zero", async () => {
78+
const savings = await resolveRtkSavings({
79+
resolveBinary: () => "/bundled/rtk",
80+
runGain: gain(
81+
JSON.stringify({
82+
summary: {
83+
total_commands: 3,
84+
total_saved: "lots",
85+
avg_savings_pct: null,
86+
},
87+
}),
88+
),
89+
});
90+
91+
expect(savings).toEqual({
92+
totalCommands: 3,
93+
inputTokens: 0,
94+
outputTokens: 0,
95+
tokensSaved: 0,
96+
avgSavingsPct: 0,
97+
});
98+
});
99+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
import { resolveRtkPrefix } from "../adapters/claude/session/rtk";
4+
5+
const execFileAsync = promisify(execFile);
6+
7+
/**
8+
* The `summary` block of `rtk gain --format json` — RTK's own tally of the
9+
* output it compressed away before it reached the model. The counts are RTK's
10+
* token estimates (its own output labels them "Input/Output tokens" and "Tokens
11+
* saved"), not raw bytes.
12+
*/
13+
export interface RtkSavingsSummary {
14+
totalCommands: number;
15+
inputTokens: number;
16+
outputTokens: number;
17+
tokensSaved: number;
18+
avgSavingsPct: number;
19+
}
20+
21+
interface ResolveRtkSavingsOptions {
22+
env?: NodeJS.ProcessEnv;
23+
/** Resolves the rtk binary to invoke; undefined disables reporting. Overridable for tests. */
24+
resolveBinary?: (env: NodeJS.ProcessEnv) => string | undefined;
25+
/** Runs `rtk gain` and returns its stdout; overridable for tests. */
26+
runGain?: (binary: string, env: NodeJS.ProcessEnv) => Promise<string>;
27+
}
28+
29+
function toFiniteNumber(value: unknown): number {
30+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
31+
}
32+
33+
function parseGainSummary(stdout: string): RtkSavingsSummary | null {
34+
const parsed: unknown = JSON.parse(stdout);
35+
// `JSON.parse("null")` returns null, and a non-object payload has no summary —
36+
// guard before indexing rather than leaning on the caller's try/catch.
37+
if (!parsed || typeof parsed !== "object") return null;
38+
const summary = (parsed as { summary?: Record<string, unknown> }).summary;
39+
if (!summary || typeof summary !== "object") return null;
40+
return {
41+
totalCommands: toFiniteNumber(summary.total_commands),
42+
inputTokens: toFiniteNumber(summary.total_input),
43+
outputTokens: toFiniteNumber(summary.total_output),
44+
tokensSaved: toFiniteNumber(summary.total_saved),
45+
avgSavingsPct: toFiniteNumber(summary.avg_savings_pct),
46+
};
47+
}
48+
49+
async function defaultRunGain(
50+
binary: string,
51+
env: NodeJS.ProcessEnv,
52+
): Promise<string> {
53+
const { stdout } = await execFileAsync(binary, ["gain", "--format", "json"], {
54+
timeout: 5_000,
55+
env,
56+
});
57+
return stdout;
58+
}
59+
60+
/**
61+
* Reads RTK's own token-savings tally (`rtk gain --format json`).
62+
*
63+
* Best-effort: returns null when RTK is disabled or unavailable, when it has
64+
* tracked nothing, or on any exec/parse failure — reporting savings must never
65+
* disrupt a run. The tally is machine-global, which equals a single run's
66+
* savings in the ephemeral cloud sandbox (fresh DB per run). A long-lived host
67+
* would need to snapshot-and-diff instead.
68+
*/
69+
export async function resolveRtkSavings({
70+
env = process.env,
71+
resolveBinary = resolveRtkPrefix,
72+
runGain = defaultRunGain,
73+
}: ResolveRtkSavingsOptions = {}): Promise<RtkSavingsSummary | null> {
74+
const binary = resolveBinary(env);
75+
if (!binary) return null;
76+
77+
try {
78+
const stdout = await runGain(binary, env);
79+
const summary = parseGainSummary(stdout);
80+
// No rtk-wrapped commands ran this session — nothing worth reporting.
81+
if (!summary || summary.totalCommands <= 0) return null;
82+
return summary;
83+
} catch {
84+
return null;
85+
}
86+
}

0 commit comments

Comments
 (0)