Skip to content

Commit 263b324

Browse files
committed
feat(dashboard-agent): give the golden runs their own eval sample rate
The CI lane reads its own variable and defaults to every turn, so neither lane can change the other's rate.
1 parent a4b4ef1 commit 263b324

3 files changed

Lines changed: 78 additions & 25 deletions

File tree

internal-packages/dashboard-agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"typecheck": "tsc --noEmit",
2929
"test": "vitest run",
3030
"test:watch": "vitest",
31-
"test:evals": "vitest run --config vitest.eval.config.ts",
31+
"test:evals": "DASHBOARD_AGENT_EVAL_CONTEXT=ci vitest run --config vitest.eval.config.ts",
3232
"dev": "trigger dev",
3333
"deploy": "trigger deploy"
3434
}

internal-packages/dashboard-agent/src/dashboard-agent.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import {
1919
dashboardAgentModelKey,
2020
dashboardAgentStoreKey,
2121
dashboardAgentToolsKey,
22+
DEFAULT_CI_EVAL_SAMPLE_RATE,
2223
DEFAULT_EVAL_SAMPLE_RATE,
2324
evalSampleRate,
2425
extractToolActivity,
26+
isCiEvalContext,
2527
MAX_EVAL_TOOL_OUTPUT_CHARS,
2628
sanitizeReplayedToolInputs,
2729
truncateEvalToolOutput,
@@ -515,6 +517,63 @@ describe("per-turn eval sampling", () => {
515517
});
516518
});
517519

520+
describe("the CI sample rate", () => {
521+
const original = {
522+
context: process.env.DASHBOARD_AGENT_EVAL_CONTEXT,
523+
ci: process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI,
524+
production: process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE,
525+
};
526+
527+
function restore(name: string, value: string | undefined) {
528+
if (value === undefined) delete process.env[name];
529+
else process.env[name] = value;
530+
}
531+
532+
afterEach(() => {
533+
restore("DASHBOARD_AGENT_EVAL_CONTEXT", original.context);
534+
restore("DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI", original.ci);
535+
restore("DASHBOARD_AGENT_EVAL_SAMPLE_RATE", original.production);
536+
});
537+
538+
it("judges every turn in the CI lane", () => {
539+
process.env.DASHBOARD_AGENT_EVAL_CONTEXT = "ci";
540+
delete process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI;
541+
expect(isCiEvalContext()).toBe(true);
542+
expect(evalSampleRate()).toBe(DEFAULT_CI_EVAL_SAMPLE_RATE);
543+
expect(DEFAULT_CI_EVAL_SAMPLE_RATE).toBe(1);
544+
});
545+
546+
it("does not let the CI rate reach production", () => {
547+
delete process.env.DASHBOARD_AGENT_EVAL_CONTEXT;
548+
delete process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE;
549+
process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI = "1";
550+
expect(isCiEvalContext()).toBe(false);
551+
expect(evalSampleRate()).toBe(DEFAULT_EVAL_SAMPLE_RATE);
552+
});
553+
554+
it("does not let the production rate de-sample CI", () => {
555+
process.env.DASHBOARD_AGENT_EVAL_CONTEXT = "ci";
556+
process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE = "0";
557+
delete process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI;
558+
expect(evalSampleRate()).toBe(1);
559+
});
560+
561+
it("honours a parseable CI rate, and falls back to full on a bad one", () => {
562+
process.env.DASHBOARD_AGENT_EVAL_CONTEXT = "ci";
563+
process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI = "0.25";
564+
expect(evalSampleRate()).toBe(0.25);
565+
process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE_CI = "nope";
566+
expect(evalSampleRate()).toBe(DEFAULT_CI_EVAL_SAMPLE_RATE);
567+
});
568+
569+
it("only the exact context value selects the CI lane", () => {
570+
for (const raw of ["CI", "true", "1", "golden", ""]) {
571+
process.env.DASHBOARD_AGENT_EVAL_CONTEXT = raw;
572+
expect(isCiEvalContext()).toBe(false);
573+
}
574+
});
575+
});
576+
518577
describe("the per-org opt-out", () => {
519578
let harness: MockChatAgentHarness | undefined;
520579
const originalRate = process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE;

internal-packages/dashboard-agent/src/dashboard-agent.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
type ToolSet,
99
type UIMessage,
1010
} from "ai";
11-
import { orgAllowsTurnEvals, redactEvalToolValue, turnReadSource } from "./eval-policy";
11+
import {
12+
orgAllowsTurnEvals,
13+
redactEvalToolValue,
14+
shouldEvalTurn,
15+
turnReadSource,
16+
} from "./eval-policy";
1217
import type { EvalTurnPayload, evalTurn } from "./eval-turn";
1318
import {
1419
buildTurnTools,
@@ -41,6 +46,18 @@ export {
4146
sanitizeReplayedToolInputs,
4247
type DashboardAgentStore,
4348
} from "./agent-runtime";
49+
// The eval's data-handling policy lives in `eval-policy.ts`; re-exported so every
50+
// existing import path still resolves.
51+
export {
52+
DEFAULT_CI_EVAL_SAMPLE_RATE,
53+
DEFAULT_EVAL_SAMPLE_RATE,
54+
evalSampleRate,
55+
isCiEvalContext,
56+
orgAllowsTurnEvals,
57+
redactEvalToolValue,
58+
shouldEvalTurn,
59+
turnReadSource,
60+
} from "./eval-policy";
4461
export {
4562
dashboardAgentActionSchema,
4663
wakeStartsInvestigation,
@@ -132,29 +149,6 @@ function getEvalTrigger(): DashboardAgentEvalTrigger {
132149
);
133150
}
134151

135-
/**
136-
* Fraction of turns to eval, from DASHBOARD_AGENT_EVAL_SAMPLE_RATE. Read per turn
137-
* so the rate can change without a redeploy.
138-
*
139-
* The judge is a full model call per turn, and nothing reads `chat_turn_evals`
140-
* yet, so the default samples a tenth — including when the value is unparseable,
141-
* where the old fallback of 1 quietly turned a typo into full-rate billing.
142-
*/
143-
export const DEFAULT_EVAL_SAMPLE_RATE = 0.1;
144-
145-
export function evalSampleRate(): number {
146-
const raw = process.env.DASHBOARD_AGENT_EVAL_SAMPLE_RATE;
147-
if (raw === undefined || raw.trim() === "") return DEFAULT_EVAL_SAMPLE_RATE;
148-
const parsed = Number(raw);
149-
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) return DEFAULT_EVAL_SAMPLE_RATE;
150-
return parsed;
151-
}
152-
153-
// `Math.random()` is in [0, 1), so rate 0 never samples and rate 1 always does.
154-
function shouldEvalTurn(): boolean {
155-
return Math.random() < evalSampleRate();
156-
}
157-
158152
function extractText(message: UIMessage): string {
159153
return (message.parts ?? [])
160154
.flatMap((part) => (part.type === "text" ? [part.text] : []))

0 commit comments

Comments
 (0)