Skip to content

Commit ba35c8b

Browse files
JSONboredJSONbored
andauthored
feat(observability): capture every miner AI generation, and stop fabricating zero token counts (#10233)
* feat(observability): capture every miner AI generation, and stop fabricating zero token counts Closes #10200 Closes #10207 * chore(contract): regenerate api-schemas for the linkedIssueMaintainerExempt field #10160 added linkedIssueMaintainerExempt to the repository-settings surface without regenerating packages/loopover-contract/src/api-schemas.ts, so contract:api-schemas:check has been failing on main since. The check is part of the local test:ci aggregate but is not wired into ci.yml, which is why nothing surfaced it. Pure regeneration: the one added line is the generator's own output for a field that already exists everywhere else. --------- Co-authored-by: JSONbored <aetherealdev@gmail.com>
1 parent e055c59 commit ba35c8b

15 files changed

Lines changed: 799 additions & 96 deletions

packages/loopover-engine/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,21 @@ export {
392392
createFakeCodingAgentDriverForFactory,
393393
isConfiguredCodingAgentDriver,
394394
resolveConfiguredCodingAgentDriverNames,
395+
resolveCodingAgentTelemetryModel,
395396
resolveFirstConfiguredCodingAgentDriverName,
396397
runCodingAgentAttempt,
397398
type CodingAgentDriverName,
398399
type CreateCodingAgentDriverOptions,
399400
type RunCodingAgentAttemptOptions,
400401
} from "./miner/driver-factory.js";
402+
export {
403+
emitMinerAiGeneration,
404+
hasMinerAiGenerationSink,
405+
setMinerAiGenerationSink,
406+
withCodingAgentGenerationCapture,
407+
type MinerAiGenerationRecord,
408+
type MinerAiGenerationSink,
409+
} from "./miner/ai-generation-sink.js";
401410
export * from "./miner/attempt-metering.js";
402411
export {
403412
buildRepoMap,
@@ -415,6 +424,7 @@ export {
415424
} from "./miner/repo-map.js";
416425
export {
417426
createAgentSdkCodingAgentDriver,
427+
readAgentSdkResultUsage,
418428
type AgentSdkHooks,
419429
type AgentSdkQueryFn,
420430
type AgentSdkQueryOptions,
@@ -423,6 +433,7 @@ export {
423433
export {
424434
buildChatPrompt,
425435
CHAT_GROUNDING_MCP_SERVER_NAME,
436+
CHAT_GROUNDING_PROVIDER,
426437
CHAT_GROUNDING_TOOL_NAMES,
427438
CHAT_REDACTED_TEXT,
428439
CHAT_SYSTEM_PROMPT,

packages/loopover-engine/src/miner/agent-sdk-driver.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ function tokensFromResultMessage(resultMessage: Record<string, unknown> | null):
111111
};
112112
}
113113

114+
/** The billing facts an SDK `result` frame carries: the token split above plus the session's real dollar cost.
115+
* `SDKResultSuccess`/`SDKResultError` both declare `total_cost_usd: number` unconditionally — present whenever
116+
* a result message arrived at all, success or not (the session was billed either way), absent only when the
117+
* stream produced no result frame. `finiteNonNegativeNumber` (not a bare `typeof`) because a malformed value
118+
* from an untyped source must degrade to undefined rather than propagate (#5827).
119+
*
120+
* Exported (#10200) so `chat-grounding.ts` reads the SAME frame the same way. It drives its own `query()`
121+
* session and needs the identical usage/cost facts; a private copy there would be a third instance of these
122+
* defensive reads, which is the duplicated-block class #10170 catalogues. */
123+
export function readAgentSdkResultUsage(resultMessage: Record<string, unknown> | null): {
124+
tokens: CodingAgentTokenUsage;
125+
costUsd: number | undefined;
126+
} {
127+
return {
128+
tokens: tokensFromResultMessage(resultMessage),
129+
costUsd: finiteNonNegativeNumber(resultMessage?.total_cost_usd),
130+
};
131+
}
132+
114133
async function listWorktreeChangedFiles(cwd: string): Promise<string[]> {
115134
const [tracked, untracked] = await Promise.all([
116135
execFileAsync("git", ["-C", cwd, "diff", "--name-only", "HEAD", "--"]),
@@ -204,11 +223,7 @@ export function createAgentSdkCodingAgentDriver(
204223
// negative) must degrade to undefined here, or it reaches accumulateAttemptUsage unguarded and throws a
205224
// RangeError that rejects runIterateLoopCore before any decision is logged (#5827).
206225
const turnsUsed = finiteNonNegativeNumber(resultMessage?.num_turns);
207-
// Real dollar cost: the SDK's own SDKResultSuccess/SDKResultError message types both declare
208-
// `total_cost_usd: number` unconditionally -- present whenever a result message arrived at all, success
209-
// or not (the session was billed either way), absent only when the stream produced no result message.
210-
const costUsd = finiteNonNegativeNumber(resultMessage?.total_cost_usd);
211-
const tokenUsage = tokensFromResultMessage(resultMessage);
226+
const { tokens: tokenUsage, costUsd } = readAgentSdkResultUsage(resultMessage);
212227
const resultText =
213228
typeof resultMessage?.result === "string" ? redactSecrets(resultMessage.result) : "";
214229
const transcript = redactSecrets(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Host-registered `$ai_generation` sink for the miner's AMS surfaces (#10200, epic #8286 Phase 3).
2+
//
3+
// Every real model call this package drives -- a coding-agent driver attempt (driver-factory.ts) and a
4+
// chat-grounding session (chat-grounding.ts) -- is spend that belongs in PostHog. The engine cannot capture it
5+
// itself: it is the portable package, and `posthog-node` (or any other vendor client) is exactly the dependency
6+
// it must not carry -- the same boundary cli-subprocess-driver.ts's and coding-agent-construction.ts's own
7+
// headers already document.
8+
//
9+
// The previous shape solved that by attaching the capture at a HOST construction site instead
10+
// (`withCodingAgentAiGenerationCapture`, applied inside `constructProductionCodingAgentDriver`), which made
11+
// opting out silent: `runCodingAgentAttempt` builds its driver through `createCodingAgentDriver` DIRECTLY and
12+
// never passes through that site, so every attempt it ran was uncaptured and nothing anywhere said so. A
13+
// wrapper attached at one of N construction sites is precisely the "two or more places that must agree, with
14+
// nothing enforcing it" shape #10170 catalogues, and #10127's fix for the same class is the precedent: make the
15+
// bypass unrepresentable rather than documenting it.
16+
//
17+
// So the sink is registered ONCE by the host process and consumed inside the engine at the single chokepoint
18+
// every real driver is constructed through. A future third construction site cannot silently opt out, because
19+
// there is nothing left for it to forget to attach.
20+
21+
import type { CodingAgentDriver } from "./coding-agent-driver.js";
22+
23+
/**
24+
* One completed model call, in the host-neutral shape a `$ai_generation` capture needs. Metadata only: model and
25+
* provider ids, timing, token/cost accounting, and the raw caught value on the error path -- there is
26+
* deliberately no field for the prompt, the transcript, or any tool output, matching the ORB side's identical
27+
* policy (`PostHogAiGenerationEvent`, src/selfhost/posthog.ts).
28+
*
29+
* Every token/cost field is OPTIONAL and stays absent when the provider reported nothing (#10207): a fabricated
30+
* 0 is indistinguishable from a real 0 in an aggregate, so the sink must be able to tell "zero" from "unknown".
31+
*/
32+
export type MinerAiGenerationRecord = {
33+
provider: string;
34+
model: string;
35+
latencyMs: number;
36+
isError: boolean;
37+
totalTokens?: number | undefined;
38+
inputTokens?: number | undefined;
39+
outputTokens?: number | undefined;
40+
totalCostUsd?: number | undefined;
41+
error?: unknown;
42+
};
43+
44+
/** The host's capture function. Synchronous and fire-and-forget -- a sink that needs to do I/O queues it. */
45+
export type MinerAiGenerationSink = (record: MinerAiGenerationRecord) => void;
46+
47+
let sink: MinerAiGenerationSink | undefined;
48+
49+
/**
50+
* Register (or, with `undefined`, clear) the process-wide sink. Called once by the host during startup --
51+
* `initMinerPostHog` (packages/loopover-miner/lib/posthog.ts) registers its own capture here when the operator
52+
* has opted in, and registers nothing when they have not, so the no-phone-home default (#6011) is preserved by
53+
* construction: with no sink registered, {@link emitMinerAiGeneration} is a no-op.
54+
*
55+
* Module-level rather than threaded through every call: the same shape posthog.ts's own `client`/`active` module
56+
* state already uses on the host side, and for the same reason -- a process has exactly one telemetry sink, and
57+
* an optional parameter threaded through N call sites is the opt-out this module exists to remove.
58+
*/
59+
export function setMinerAiGenerationSink(next: MinerAiGenerationSink | undefined): void {
60+
sink = next;
61+
}
62+
63+
/** True when a host sink is registered. Exported for the host's own wiring assertions, not for gating a call. */
64+
export function hasMinerAiGenerationSink(): boolean {
65+
return sink !== undefined;
66+
}
67+
68+
/** Report one completed model call. No-op when no sink is registered, and never throws -- telemetry must never
69+
* crash the AI call it is instrumenting, the same contract every capture function in the miner's posthog.ts
70+
* already holds on its own side. */
71+
export function emitMinerAiGeneration(record: MinerAiGenerationRecord): void {
72+
if (!sink) return;
73+
try {
74+
sink(record);
75+
} catch {
76+
/* A sink that throws is a telemetry bug, never the caller's problem. */
77+
}
78+
}
79+
80+
/**
81+
* Wrap a real `CodingAgentDriver` so every attempt it runs reports a generation. Moved here from the miner's own
82+
* construction site (#10200) so the single engine-side factory can apply it to every driver it builds.
83+
*
84+
* `CodingAgentDriverResult` carries the blended `tokensUsed` plus the input/output split when the provider
85+
* reported one (#10198); all of it is forwarded verbatim, and a driver that knows no split simply leaves those
86+
* fields absent rather than having one fabricated. A driver failure is reported via `result.ok === false` (the
87+
* real, observed contract every shipped driver follows -- none of them throw for an ordinary task failure), with
88+
* a genuine thrown exception handled defensively on top.
89+
*/
90+
export function withCodingAgentGenerationCapture(provider: string, model: string, driver: CodingAgentDriver): CodingAgentDriver {
91+
return {
92+
async run(task) {
93+
const startedAtMs = Date.now();
94+
try {
95+
const result = await driver.run(task);
96+
emitMinerAiGeneration({
97+
provider,
98+
model,
99+
latencyMs: Date.now() - startedAtMs,
100+
isError: !result.ok,
101+
totalTokens: result.tokensUsed,
102+
inputTokens: result.inputTokens,
103+
outputTokens: result.outputTokens,
104+
totalCostUsd: result.costUsd,
105+
error: result.ok ? undefined : result.error,
106+
});
107+
return result;
108+
} catch (error) {
109+
emitMinerAiGeneration({ provider, model, latencyMs: Date.now() - startedAtMs, isError: true, error });
110+
throw error;
111+
}
112+
},
113+
};
114+
}

packages/loopover-engine/src/miner/chat-grounding.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
// The endpoint is stateless: the caller supplies the full message history per request (no conversation store).
1919

2020
import { PUBLIC_FIELD_BLOCKLIST } from "../track-record-summary.js";
21-
import { resolveFirstConfiguredCodingAgentDriverName } from "./driver-factory.js";
21+
import { readAgentSdkResultUsage } from "./agent-sdk-driver.js";
22+
import { emitMinerAiGeneration } from "./ai-generation-sink.js";
23+
import { resolveCodingAgentTelemetryModel, resolveFirstConfiguredCodingAgentDriverName } from "./driver-factory.js";
2224

2325
/**
2426
* The exact read-only tools this endpoint may call — one `server.registerTool(...)` call each in
@@ -42,6 +44,11 @@ export const CHAT_GROUNDING_TOOL_NAMES = Object.freeze([
4244
/** The MCP server name the session registers the miner tools under. */
4345
export const CHAT_GROUNDING_MCP_SERVER_NAME = "loopover-miner";
4446

47+
/** The only provider this endpoint can run on (boundary 1 above) — and therefore the provider id its
48+
* `$ai_generation` telemetry reports (#10200). One definition, consumed by both the fail-closed check and the
49+
* capture, so the two can never disagree about what is actually running. */
50+
export const CHAT_GROUNDING_PROVIDER = "agent-sdk";
51+
4552
/** Ceiling on a single conversational session's tool-calling turns. */
4653
const CHAT_MAX_TURNS = 12;
4754

@@ -201,7 +208,7 @@ export function resolveChatProviderError(
201208
"No coding-agent provider is configured. Chat requires the agent-sdk provider — set MINER_CODING_AGENT_PROVIDER=agent-sdk.",
202209
};
203210
}
204-
if (provider !== "agent-sdk") {
211+
if (provider !== CHAT_GROUNDING_PROVIDER) {
205212
return {
206213
code: "chat_requires_agent_sdk_provider",
207214
message: `Chat requires the agent-sdk provider; the configured provider is ${provider}, which is a single-turn, buffered coding driver.`,
@@ -254,9 +261,35 @@ function* foldToolResultMessage(message: Record<string, unknown>): Generator<Cha
254261
}
255262
}
256263

264+
/**
265+
* Why a completed session was not a successful generation, or `undefined` when it was. A stream that ends
266+
* without a `result` frame never reported completion, and one whose result frame is itself an error reported
267+
* failure — agent-sdk-driver.ts classifies exactly these two the same way (`agent_sdk_no_result` /
268+
* `agent_sdk_<subtype>`), and the telemetry must not call either of them a success.
269+
*/
270+
function chatResultFailureReason(resultMessage: Record<string, unknown> | null): string | undefined {
271+
if (!resultMessage) return "chat_grounding_no_result";
272+
if (resultMessage.is_error === true) return "chat_grounding_errored";
273+
if (resultMessage.subtype !== "success") {
274+
return `chat_grounding_${typeof resultMessage.subtype === "string" ? resultMessage.subtype : "unknown"}`;
275+
}
276+
return undefined;
277+
}
278+
257279
/**
258280
* Drives one grounded conversational turn, yielding wire events. Never throws: an SDK failure becomes an `error`
259281
* event, and `done` always terminates the stream — including on the fail-closed provider paths.
282+
*
283+
* #10200: this drives a real `query()` session with a real turn budget, so it is real spend and reports an
284+
* `$ai_generation` through the host sink (ai-generation-sink.ts) on both the completed and the thrown path.
285+
* That also means folding the SDK's `result` frame, which this module previously discarded — it read only the
286+
* `assistant`/`user` messages it turns into wire events, so the session's own usage and cost were on the wire
287+
* and thrown away.
288+
*
289+
* The fail-closed provider path above deliberately emits NOTHING: no model was ever reached, so an
290+
* `$ai_generation` there would fabricate a generation that did not happen. That case is a request which produced
291+
* no generation — the shape the ORB side gives its own separate `selfhost_ai_degraded` event (#10186), which the
292+
* miner has no counterpart for yet.
260293
*/
261294
export async function* runChatGrounding(
262295
messages: ChatMessage[],
@@ -272,6 +305,9 @@ export async function* runChatGrounding(
272305

273306
const query = resolveChatQuery(options);
274307
const mcpServer = options.mcpServer ?? DEFAULT_MCP_SERVER;
308+
const model = resolveCodingAgentTelemetryModel(CHAT_GROUNDING_PROVIDER, env);
309+
const startedAtMs = Date.now();
310+
let resultMessage: Record<string, unknown> | null = null;
275311
try {
276312
const stream = query({
277313
prompt: buildChatPrompt(messages),
@@ -289,9 +325,33 @@ export async function* runChatGrounding(
289325
}
290326
if (message.type === "user") {
291327
yield* foldToolResultMessage(message);
328+
continue;
292329
}
330+
// Kept, not re-emitted: the result frame carries usage/cost, never conversational content, so it feeds
331+
// the capture below and never becomes a wire event.
332+
if (message.type === "result") resultMessage = message;
293333
}
334+
const { tokens, costUsd } = readAgentSdkResultUsage(resultMessage);
335+
const failure = chatResultFailureReason(resultMessage);
336+
emitMinerAiGeneration({
337+
provider: CHAT_GROUNDING_PROVIDER,
338+
model,
339+
latencyMs: Date.now() - startedAtMs,
340+
isError: failure !== undefined,
341+
totalTokens: tokens.tokensUsed,
342+
inputTokens: tokens.inputTokens,
343+
outputTokens: tokens.outputTokens,
344+
totalCostUsd: costUsd,
345+
...(failure === undefined ? {} : { error: new Error(failure) }),
346+
});
294347
} catch (error) {
348+
emitMinerAiGeneration({
349+
provider: CHAT_GROUNDING_PROVIDER,
350+
model,
351+
latencyMs: Date.now() - startedAtMs,
352+
isError: true,
353+
error,
354+
});
295355
yield {
296356
type: "error",
297357
code: "chat_grounding_failed",

packages/loopover-engine/src/miner/driver-factory.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
type AgentSdkHooks,
3030
type AgentSdkQueryFn,
3131
} from "./agent-sdk-driver.js";
32+
import { withCodingAgentGenerationCapture } from "./ai-generation-sink.js";
3233

3334
/** Provider names the factory resolves: the two concrete drivers from #4266/#4267 (`claude-cli`/`codex-cli`
3435
* spawn the respective CLI; `agent-sdk` runs in-process via the Agent SDK) plus the `noop` stub. All are
@@ -200,17 +201,27 @@ function createCliProvider(
200201
});
201202
}
202203

203-
/** Resolve a concrete driver for `providerName`. Throws on unknown/unconfigured providers (fail-closed). */
204-
export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions): CodingAgentDriver {
205-
if (options.driver) return options.driver;
206-
const name = options.providerName.trim().toLowerCase();
207-
const env = options.env ?? {};
208-
if (!isConfiguredCodingAgentDriver(name, env)) {
209-
throw new Error(`unconfigured_coding_agent_driver:${name}`);
210-
}
204+
/** The model id telemetry should report for `name`: the provider's configured model env var when it declares
205+
* one, else the provider name itself — `agent-sdk` declares none (its session uses the account/CLI default),
206+
* mirroring ai.ts's own "unconfigured → a sensible default" convention.
207+
*
208+
* Moved here from the miner's construction site (#10200) so every construction path names the model
209+
* identically instead of each one re-deriving it. Uses `firstConfiguredEnvValue`, the same reader
210+
* `createCliProvider` already applies to the same env var, so a whitespace-only value resolves to the provider
211+
* name rather than being passed through as a blank model id. */
212+
export function resolveCodingAgentTelemetryModel(name: string, env: Record<string, string | undefined>): string {
213+
const modelEnvKey = CODING_AGENT_DRIVER_CONFIG_ENV[name as CodingAgentDriverName]?.model;
214+
return (modelEnvKey ? firstConfiguredEnvValue(env[modelEnvKey]) : undefined) ?? name;
215+
}
216+
217+
/** The provider switch itself. Split out of {@link createCodingAgentDriver} so the capture wrapper below has
218+
* exactly one expression to wrap — a provider arm cannot return around it. */
219+
function createProviderDriver(
220+
name: string,
221+
options: CreateCodingAgentDriverOptions,
222+
env: Record<string, string | undefined>,
223+
): CodingAgentDriver {
211224
switch (name) {
212-
case "noop":
213-
return createNoopCodingAgentDriver();
214225
case "claude-cli":
215226
return createCliProvider("claude", "MINER_CODING_AGENT_CLAUDE_MODEL", options, env);
216227
case "codex-cli":
@@ -228,6 +239,34 @@ export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions)
228239
}
229240
}
230241

242+
/** Resolve a concrete driver for `providerName`. Throws on unknown/unconfigured providers (fail-closed).
243+
*
244+
* #10200: this is the ONE place a real coding-agent driver is constructed, so it is where `$ai_generation`
245+
* capture is attached — both `constructProductionCodingAgentDriver` (the miner CLI) and
246+
* `resolveDriverForAttempt` (`runCodingAgentAttempt`, below) reach a provider through here, and the second one
247+
* previously produced uncaptured attempts because the wrapper lived at only the first. The capture itself is
248+
* host-supplied (see ai-generation-sink.ts); with no host sink registered it is a no-op. */
249+
export function createCodingAgentDriver(options: CreateCodingAgentDriverOptions): CodingAgentDriver {
250+
// Test seam: an injected driver is returned verbatim and uncaptured. It never reaches a model, so wrapping it
251+
// would report generations that did not happen — the same reasoning the `noop` arm below rests on.
252+
if (options.driver) return options.driver;
253+
const name = options.providerName.trim().toLowerCase();
254+
const env = options.env ?? {};
255+
if (!isConfiguredCodingAgentDriver(name, env)) {
256+
throw new Error(`unconfigured_coding_agent_driver:${name}`);
257+
}
258+
// `noop` is a stub that makes no model call, so it has no generation to report. Capturing it would fabricate
259+
// an $ai_generation for an attempt that never reached a provider — #10207's never-fabricate rule applied to
260+
// the event itself rather than to its token fields. (`resolveDriverForAttempt` reaches the same conclusion
261+
// independently for dry-run/paused attempts, which bypass this factory entirely.)
262+
if (name === "noop") return createNoopCodingAgentDriver();
263+
return withCodingAgentGenerationCapture(
264+
name,
265+
resolveCodingAgentTelemetryModel(name, env),
266+
createProviderDriver(name, options, env),
267+
);
268+
}
269+
231270
export type RunCodingAgentAttemptOptions = {
232271
providerName: string;
233272
env?: Record<string, string | undefined> | undefined;

0 commit comments

Comments
 (0)