Skip to content

Commit 6fefe5d

Browse files
authored
feat(autoresearch): report and render the metric's unit with every dashboard value
The report protocol gains a structured unit: line (e.g. kB, ms, %) and the name: guidance drops "with units", so the dashboard title stays a clean label while every value carries the unit. The first report with a unit sets run.metricUnit (first-wins, persisted, like the name); units longer than 16 chars are ignored as prose, and unitless metrics render bare as before. Rendered via a shared withMetricUnit helper (space-separated, % hugs the number) on: the Best/Last/Target stat cards, the chart's y-axis extremes, target label and point tooltips, and the iteration table's Value and delta columns. Continuation prompts include the unit in the history block too, keeping the agent grounded in the units it reports. Tests: 136 autoresearch core tests (unit parsing, too-long-unit guard, first-unit-wins adoption, unit in history prompts, protocol example); full core/ui/app suites green. Generated-By: PostHog Code Task-Id: 41d083af-f0ef-49c1-bce2-9d4a34046981
1 parent 6c64d9e commit 6fefe5d

11 files changed

Lines changed: 129 additions & 17 deletions

File tree

packages/core/src/autoresearch/autoresearch.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ function makeRun(
247247
config: { ...config, ...configOverrides },
248248
status: "running",
249249
metricName: null,
250+
metricUnit: null,
250251
phase: null,
251252
originalModel: null,
252253
originalEffort: null,
@@ -526,6 +527,18 @@ describe("AutoresearchService", () => {
526527
expect(activeRun().metricName).toBe("bundle size (kB)");
527528
});
528529

530+
it("adopts the metric unit from the first report that carries one", () => {
531+
service.startRun(baseConfig);
532+
runTurn(
533+
"```autoresearch\nmetric: 412\nname: bundle size\nunit: kB\nsummary: baseline\n```",
534+
);
535+
expect(activeRun().metricUnit).toBe("kB");
536+
537+
// First unit wins, like the name — a stable unit keeps values readable.
538+
runTurn("```autoresearch\nmetric: 400000\nunit: bytes\n```");
539+
expect(activeRun().metricUnit).toBe("kB");
540+
});
541+
529542
it("runs unnamed until a report carries a name", () => {
530543
service.startRun(baseConfig);
531544
runTurn(reportText(10));

packages/core/src/autoresearch/autoresearch.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export class AutoresearchService {
148148
config,
149149
status: "running",
150150
metricName: null,
151+
metricUnit: null,
151152
phase: null,
152153
originalModel: session ? currentSessionModel(session) : null,
153154
originalEffort: session ? currentSessionEffort(session) : null,
@@ -450,11 +451,16 @@ export class AutoresearchService {
450451
// the recovery backoff from scratch.
451452
this.recoveryAttempts.delete(run.id);
452453
this.recordIteration(run, report);
453-
if (report.name) {
454+
if (report.name || report.unit) {
454455
const current = autoresearchStore.getState().runs[run.id];
455456
// First named report wins; a stable label keeps the dashboard steady.
456-
if (current && current.metricName === null) {
457-
autoresearchStoreActions.setMetricName(run.id, report.name);
457+
if (current) {
458+
if (report.name && current.metricName === null) {
459+
autoresearchStoreActions.setMetricName(run.id, report.name);
460+
}
461+
if (report.unit && current.metricUnit === null) {
462+
autoresearchStoreActions.setMetricUnit(run.id, report.unit);
463+
}
458464
}
459465
}
460466
this.persist(run.id);

packages/core/src/autoresearch/autoresearchStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ export const autoresearchStoreActions = {
5555
updateRun(runId, (run) => ({ ...run, metricName }));
5656
},
5757

58+
/** Record the metric unit the agent chose in its reports. */
59+
setMetricUnit(runId: string, metricUnit: string): void {
60+
updateRun(runId, (run) => ({ ...run, metricUnit }));
61+
},
62+
5863
setPhase(runId: string, phase: AutoresearchPhase | null): void {
5964
updateRun(runId, (run) => ({ ...run, phase }));
6065
},

packages/core/src/autoresearch/prompts.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function makeRun(
5050
config: makeConfig(configOverrides),
5151
status: "running",
5252
metricName: "requests per second",
53+
metricUnit: null,
5354
phase: null,
5455
originalModel: null,
5556
originalEffort: null,
@@ -74,6 +75,7 @@ describe("parseMetricReport", () => {
7475
expect(parseMetricReport(text)).toEqual({
7576
value: 1234.5,
7677
name: null,
78+
unit: null,
7779
summary: "swapped JSON parser",
7880
});
7981
});
@@ -82,6 +84,7 @@ describe("parseMetricReport", () => {
8284
expect(parseMetricReport(report("42"))).toEqual({
8385
value: 42,
8486
name: null,
87+
unit: null,
8588
summary: null,
8689
});
8790
});
@@ -99,6 +102,7 @@ describe("parseMetricReport", () => {
99102
expect(parseMetricReport(text)).toEqual({
100103
value: 2,
101104
name: null,
105+
unit: null,
102106
summary: "final",
103107
});
104108
});
@@ -108,6 +112,7 @@ describe("parseMetricReport", () => {
108112
expect(parseMetricReport(text)).toEqual({
109113
value: 7,
110114
name: null,
115+
unit: null,
111116
summary: "good",
112117
});
113118
});
@@ -125,6 +130,7 @@ describe("parseMetricReport", () => {
125130
expect(parseMetricReport(text)).toEqual({
126131
value: 99,
127132
name: null,
133+
unit: null,
128134
summary: "tidy",
129135
});
130136
});
@@ -306,6 +312,42 @@ describe("buildResumePrompt", () => {
306312
});
307313
});
308314

315+
describe("unit parsing and rendering", () => {
316+
it("parses the unit line", () => {
317+
const text =
318+
"```autoresearch\nmetric: 412\nname: bundle size\nunit: kB\nsummary: trimmed deps\n```";
319+
expect(parseMetricReport(text)).toEqual({
320+
value: 412,
321+
name: "bundle size",
322+
unit: "kB",
323+
summary: "trimmed deps",
324+
});
325+
});
326+
327+
it("ignores a unit that is too long to be a unit", () => {
328+
const text =
329+
"```autoresearch\nmetric: 5\nunit: kilobytes of gzipped production javascript\n```";
330+
expect(parseMetricReport(text)?.unit).toBeNull();
331+
});
332+
333+
it("includes the unit in continuation history once known", () => {
334+
const run = {
335+
...makeRun([makeIteration(1, 100, "baseline")]),
336+
metricUnit: "ms",
337+
};
338+
const prompt = buildContinuationPrompt(run);
339+
expect(prompt).toContain("- Iteration 1: 100 ms — baseline");
340+
expect(prompt).toContain("Best so far: 100 ms (iteration 1)");
341+
expect(prompt).toContain("Last: 100 ms");
342+
});
343+
344+
it("mentions the unit line in the protocol example", () => {
345+
expect(buildKickoffPreamble(makeConfig())).toContain(
346+
"unit: <the metric's unit",
347+
);
348+
});
349+
});
350+
309351
describe("countPromptRequests", () => {
310352
it("counts only session/prompt requests", () => {
311353
expect(countPromptRequests([])).toBe(0);

packages/core/src/autoresearch/prompts.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { computeBest } from "./stats";
1717
const REPORT_BLOCK_EXAMPLE = [
1818
"```autoresearch",
1919
"metric: <number>",
20-
"name: <short metric label with units, e.g. bundle size (kB) — keep it identical every time>",
20+
"name: <short metric label, e.g. bundle size — keep it identical every time>",
21+
"unit: <the metric's unit, e.g. kB, ms, % — omit for unitless counts>",
2122
"summary: <one line describing what you changed>",
2223
"```",
2324
].join("\n");
@@ -71,19 +72,20 @@ function historyBlock(run: AutoresearchRun): string {
7172
const { config, iterations } = run;
7273
const best = computeBest(iterations, config.direction);
7374
const last = iterations[iterations.length - 1];
75+
const unit = run.metricUnit ? ` ${run.metricUnit}` : "";
7476

7577
const recent = iterations
7678
.slice(-5)
7779
.map(
7880
(iteration) =>
79-
`- Iteration ${iteration.index}: ${iteration.value}${iteration.summary ? ` — ${iteration.summary}` : ""}`,
81+
`- Iteration ${iteration.index}: ${iteration.value}${unit}${iteration.summary ? ` — ${iteration.summary}` : ""}`,
8082
)
8183
.join("\n");
8284

8385
return `Recent iterations:
8486
${recent}
8587
86-
Best so far: ${best ? `${best.value} (iteration ${best.index})` : "none"}. Last: ${last ? last.value : "none"}.${targetLine(config)}`;
88+
Best so far: ${best ? `${best.value}${unit} (iteration ${best.index})` : "none"}. Last: ${last ? `${last.value}${unit}` : "none"}.${targetLine(config)}`;
8789
}
8890

8991
export function buildContinuationPrompt(run: AutoresearchRun): string {
@@ -179,9 +181,13 @@ export function parseMetricReport(text: string): AutoresearchReport | null {
179181
return report;
180182
}
181183

184+
/** Anything longer is prose, not a unit; ignore it rather than blow up the UI. */
185+
const MAX_UNIT_LENGTH = 16;
186+
182187
function parseReportBody(body: string): AutoresearchReport | null {
183188
let value: number | null = null;
184189
let name: string | null = null;
190+
let unit: string | null = null;
185191
let summary: string | null = null;
186192
for (const line of body.split("\n")) {
187193
const separator = line.indexOf(":");
@@ -193,11 +199,17 @@ function parseReportBody(body: string): AutoresearchReport | null {
193199
if (Number.isFinite(numeric)) value = numeric;
194200
} else if (key === "name" && raw.length > 0) {
195201
name = raw;
202+
} else if (
203+
key === "unit" &&
204+
raw.length > 0 &&
205+
raw.length <= MAX_UNIT_LENGTH
206+
) {
207+
unit = raw;
196208
} else if (key === "summary" && raw.length > 0) {
197209
summary = raw;
198210
}
199211
}
200-
return value === null ? null : { value, name, summary };
212+
return value === null ? null : { value, name, unit, summary };
201213
}
202214

203215
interface AgentMessageChunkUpdate {

packages/core/src/autoresearch/schemas.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ export const autoresearchRunSchema = z.object({
118118
* "bundle size (kB)". Null until the first named report arrives.
119119
*/
120120
metricName: z.string().nullable().default(null),
121+
/**
122+
* The metric's unit as reported by the agent (the `unit:` line), e.g.
123+
* "kB", "ms", "%". Rendered after every value; null for unitless counts.
124+
*/
125+
metricUnit: z.string().nullable().default(null),
121126
phase: autoresearchPhaseSchema.nullable().default(null),
122127
/**
123128
* The session model/effort selected when the run started, captured so
@@ -167,7 +172,9 @@ export function parseStoredAutoresearchRun(
167172
/** A metric report parsed from the agent's reply. */
168173
export interface AutoresearchReport {
169174
value: number;
170-
/** The agent's short label for the metric, e.g. "bundle size (kB)". */
175+
/** The agent's short label for the metric, e.g. "bundle size". */
171176
name: string | null;
177+
/** The metric's unit, e.g. "kB", "ms", "%"; null for unitless counts. */
178+
unit: string | null;
172179
summary: string | null;
173180
}

packages/core/src/autoresearch/stats.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function makeRun(overrides: {
4545
},
4646
status: "running",
4747
metricName: "score",
48+
metricUnit: null,
4849
phase: null,
4950
originalModel: null,
5051
originalEffort: null,

packages/ui/src/features/autoresearch/AutoresearchPanel.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { usePendingPermissionsForTask } from "../sessions/useSession";
2424
import { AutoresearchConfigDialog } from "./AutoresearchConfigDialog";
2525
import { IterationsTable } from "./IterationsTable";
2626
import { MetricChart } from "./MetricChart";
27+
import { withMetricUnit } from "./metricFormat";
2728
import {
2829
type AutoresearchModelOption,
2930
stageValueLabel,
@@ -174,10 +175,12 @@ export function AutoresearchPanel({ taskId }: AutoresearchPanelProps) {
174175
direction={selectedRun.config.direction}
175176
targetValue={selectedRun.config.targetValue}
176177
metricName={selectedRun.metricName ?? "the metric"}
178+
unit={selectedRun.metricUnit}
177179
/>
178180
<IterationsTable
179181
iterations={selectedRun.iterations}
180182
direction={selectedRun.config.direction}
183+
unit={selectedRun.metricUnit}
181184
/>
182185
</Flex>
183186
<AutoresearchConfigDialog
@@ -377,6 +380,7 @@ function PendingPermissionNotice({
377380

378381
function RunStats({ run }: { run: AutoresearchRun }) {
379382
const summary = useMemo(() => summarizeRun(run), [run]);
383+
const unit = run.metricUnit;
380384

381385
return (
382386
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -385,7 +389,7 @@ function RunStats({ run }: { run: AutoresearchRun }) {
385389
value={
386390
summary.best ? (
387391
<>
388-
{numberFormat.format(summary.best.value)}
392+
{withMetricUnit(numberFormat.format(summary.best.value), unit)}
389393
<Text size="1" color="gray">
390394
{" "}
391395
(iter {summary.best.index})
@@ -398,7 +402,11 @@ function RunStats({ run }: { run: AutoresearchRun }) {
398402
/>
399403
<StatCard
400404
label="Last"
401-
value={summary.last ? numberFormat.format(summary.last.value) : "—"}
405+
value={
406+
summary.last
407+
? withMetricUnit(numberFormat.format(summary.last.value), unit)
408+
: "—"
409+
}
402410
/>
403411
<StatCard
404412
label="Iterations"
@@ -409,7 +417,7 @@ function RunStats({ run }: { run: AutoresearchRun }) {
409417
value={
410418
run.config.targetValue === null
411419
? "—"
412-
: numberFormat.format(run.config.targetValue)
420+
: withMetricUnit(numberFormat.format(run.config.targetValue), unit)
413421
}
414422
/>
415423
</div>

packages/ui/src/features/autoresearch/IterationsTable.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import type {
55
import { computeBest, isImprovement } from "@posthog/core/autoresearch/stats";
66
import { Badge, Table, Text } from "@radix-ui/themes";
77
import { useMemo } from "react";
8+
import { withMetricUnit } from "./metricFormat";
89

910
interface IterationsTableProps {
1011
iterations: AutoresearchIteration[];
1112
direction: AutoresearchDirection;
13+
unit: string | null;
1214
}
1315

1416
const numberFormat = new Intl.NumberFormat("en-US", {
@@ -31,6 +33,7 @@ function deltaColor(
3133
export function IterationsTable({
3234
iterations,
3335
direction,
36+
unit,
3437
}: IterationsTableProps) {
3538
const best = useMemo(
3639
() => computeBest(iterations, direction),
@@ -63,7 +66,7 @@ export function IterationsTable({
6366
<Table.Cell>{iteration.index}</Table.Cell>
6467
<Table.Cell>
6568
<span className="flex items-center gap-1 tabular-nums">
66-
{numberFormat.format(iteration.value)}
69+
{withMetricUnit(numberFormat.format(iteration.value), unit)}
6770
{best?.index === iteration.index && (
6871
<Badge color="amber" size="1">
6972
best
@@ -79,7 +82,10 @@ export function IterationsTable({
7982
>
8083
{iteration.delta === null
8184
? "—"
82-
: `${iteration.delta > 0 ? "+" : ""}${numberFormat.format(iteration.delta)}`}
85+
: withMetricUnit(
86+
`${iteration.delta > 0 ? "+" : ""}${numberFormat.format(iteration.delta)}`,
87+
unit,
88+
)}
8389
</Text>
8490
</Table.Cell>
8591
<Table.Cell className="max-w-[320px]">

0 commit comments

Comments
 (0)