Skip to content

Commit f2952bb

Browse files
committed
fix(framework): assemble prompts per harness and record what was sent (AI-1034)
Prompt assembly moves out of `run-eval.ts` (an entry script that runs `main()` on import, so it cannot be unit-tested) into `harness/system-prompt.ts`, keyed on `exp.agent.id`. Every block is now ai-sdk-only — the task framing, the tool-surface addendum, the skills listing — so a CLI harness assembles to `''` and the engine stages no system prompt file. The two "end your turn with a short summary" sentences are gone from both modes: stopping behaviour is part of what is measured. `runOne` now returns the exact assembled `systemPrompt`, so it lands in `results/<experiment>/<eval>.json` and what an agent was told is verifiable from the artifacts. It was previously unrecorded for every CLI harness. `export-results.ts` builds an explicit whitelist, so it does not reach the published web data. `apps/framework` gains a `test` script (`vitest run harness`), wired into `check`, so the prompt-assembly tests have a runner. Refs AI-1034, #164 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5f9ELzsnwjydxea1h9EW7
1 parent 04fb22b commit f2952bb

4 files changed

Lines changed: 183 additions & 35 deletions

File tree

apps/framework/harness/run-eval.ts

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../lib/cli-args.js';
2828
import { bootPlatformBackend } from './platform-backend.js';
2929
import { viteBuild, vitestRun } from './project-runner.js';
30+
import { buildSystemPrompt } from './system-prompt.js';
3031
import {
3132
buildDocsResult,
3233
buildSkillResult,
@@ -245,9 +246,9 @@ function buildLoadSkillTool(skills: readonly ToolsSkill[]): ToolSet {
245246
}
246247

247248
/**
248-
* Local-stack skill sources: resolve each skill name to its host directory so
249-
* the sandbox can install it with Vercel's `skills` CLI; the agent then
250-
* discovers each skill by reading its SKILL.md with its file tools. The
249+
* Sandbox skill sources: resolve each skill name to its host directory so the
250+
* sandbox can install it with Vercel's `skills` CLI, which places it in every
251+
* CLI harness's native project scope for that harness to discover. The
251252
* `skills/` entries are symlinks into the agent-skills submodule; realpath them
252253
* so `docker cp` copies real files, not dangling links. Missing skills are
253254
* skipped with a warning.
@@ -306,31 +307,6 @@ function readSessionSeedArgs(ev: EvalManifest) {
306307
};
307308
}
308309

309-
function basePromptFor(mode: EvalMode): string {
310-
if (mode === 'local-stack') {
311-
return (
312-
'You are an agent solving a Supabase eval task in a Linux workspace. ' +
313-
'Use the provided tools to inspect and modify the workspace and run commands. ' +
314-
'When you are done, end your turn with a short summary of what you did.'
315-
);
316-
}
317-
return (
318-
'You are an agent solving a Supabase eval task. ' +
319-
'Use the provided tools to inspect and modify the project. ' +
320-
'When you are done, end your turn with a short summary of what you did ' +
321-
'(or for audit tasks, your findings).'
322-
);
323-
}
324-
325-
function buildSystemPrompt(
326-
mode: EvalMode,
327-
addendum?: string,
328-
skillContext?: string
329-
): string {
330-
const blocks = [basePromptFor(mode), addendum, skillContext].filter(Boolean);
331-
return blocks.join('\n\n');
332-
}
333-
334310
/**
335311
* Adapt a `{ close() }` resource to `AsyncDisposable` so it can be bound with
336312
* `await using` — cleanup then runs on scope exit (normal fall-through, `continue`,
@@ -360,6 +336,13 @@ async function runOne(
360336
transcript: TranscriptPart[];
361337
agentReport: string;
362338
stoppedReason: string;
339+
/**
340+
* The exact system prompt handed to the agent (`''` when it got none). CLI
341+
* harnesses receive theirs as a file in the sandbox scratch dir, outside the
342+
* exported workspace, so recording it here is the only way to verify from a
343+
* run artifact what the agent was actually told.
344+
*/
345+
systemPrompt: string;
363346
}
364347
> {
365348
const prompt = parseEvalMarkdown(
@@ -391,6 +374,7 @@ async function runOne(
391374
let lastTranscript: TranscriptPart[] = [];
392375
let lastAgentReport = '';
393376
let lastStoppedReason = 'not_started';
377+
let lastSystemPrompt = '';
394378

395379
for (let attempt = 1; attempt <= RUNS; attempt += 1) {
396380
if (ev.mode === 'local-stack') {
@@ -423,6 +407,7 @@ async function runOne(
423407
: undefined;
424408
await using session = disposable(
425409
await exp.localStack.startSession({
410+
agent: exp.agent.id,
426411
cliVersion: ev.metadata.cliVersion,
427412
localDir: ev.localDir,
428413
includeServices: ev.metadata.services,
@@ -443,8 +428,13 @@ async function runOne(
443428
})
444429
);
445430

431+
const systemPrompt = buildSystemPrompt(
432+
exp.agent.id,
433+
'local-stack',
434+
session.promptAddendum
435+
);
446436
const run = await exp.agent.run({
447-
systemPrompt: buildSystemPrompt('local-stack', session.promptAddendum),
437+
systemPrompt,
448438
userPrompt: prompt,
449439
tools: session.tools,
450440
sandbox: session.sandbox,
@@ -455,6 +445,7 @@ async function runOne(
455445
lastTranscript = run.transcript;
456446
lastAgentReport = run.agentReport;
457447
lastStoppedReason = run.stoppedReason;
448+
lastSystemPrompt = systemPrompt;
458449

459450
// Export the agent's workspace to the host so scorers can run host
460451
// tooling (vite/vitest from the repo root) against the produced files
@@ -496,6 +487,7 @@ async function runOne(
496487
transcript: run.transcript,
497488
agentReport: run.agentReport,
498489
stoppedReason: run.stoppedReason,
490+
systemPrompt,
499491
};
500492
}
501493
logRetryAttempt(expName, ev, attempt, last);
@@ -508,7 +500,12 @@ async function runOne(
508500
// platform-lite via host.docker.internal (so platform-lite binds 0.0.0.0).
509501
// An in-process agent runs host-side with no sandbox.
510502
await using cliSandbox = agentRunsInSandbox
511-
? disposable(await createBareSandbox({ skills: skillSources }))
503+
? disposable(
504+
await createBareSandbox({
505+
agent: exp.agent.id,
506+
skills: skillSources,
507+
})
508+
)
512509
: undefined;
513510
await using session = disposable(
514511
await exp.runtime.startSession({
@@ -517,14 +514,16 @@ async function runOne(
517514
})
518515
);
519516

520-
// CLI agents read their installed skills from disk (the bare sandbox folds
521-
// the discovery listing into its promptAddendum). In-process agents have
522-
// no filesystem, so their skills are advertised in the prompt and pulled
523-
// on demand via the load_skill tool.
517+
// CLI agents discover their installed skills themselves — the skills CLI
518+
// put them in every harness's native project scope, so each one advertises
519+
// and loads them in its own words and the bare sandbox contributes nothing
520+
// here. In-process agents have no filesystem, so their skills are advertised
521+
// in the prompt and pulled on demand via the load_skill tool.
524522
const skillsPrompt = agentRunsInSandbox
525523
? cliSandbox!.promptAddendum
526524
: buildToolsSkillsPrompt(toolsSkills);
527525
const systemPrompt = buildSystemPrompt(
526+
exp.agent.id,
528527
'tools',
529528
session.promptAddendum,
530529
skillsPrompt
@@ -541,6 +540,7 @@ async function runOne(
541540
lastTranscript = run.transcript;
542541
lastAgentReport = run.agentReport;
543542
lastStoppedReason = run.stoppedReason;
543+
lastSystemPrompt = systemPrompt;
544544
last = await (scorer as ToolScorer)({
545545
...session.scoringContext,
546546
toolCalls: run.toolCalls,
@@ -562,6 +562,7 @@ async function runOne(
562562
transcript: run.transcript,
563563
agentReport: run.agentReport,
564564
stoppedReason: run.stoppedReason,
565+
systemPrompt,
565566
};
566567
}
567568
logRetryAttempt(expName, ev, attempt, last);
@@ -576,6 +577,7 @@ async function runOne(
576577
transcript: lastTranscript,
577578
agentReport: lastAgentReport,
578579
stoppedReason: lastStoppedReason,
580+
systemPrompt: lastSystemPrompt,
579581
};
580582
}
581583

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { AgentHarnessId } from '@supabase-evals/core';
3+
import {
4+
buildSkillsPrompt,
5+
buildToolSurfaceAddendum,
6+
type SkillEntry,
7+
} from '@supabase-evals/sandbox';
8+
import { buildSystemPrompt } from './system-prompt.js';
9+
import type { EvalMode } from './types.js';
10+
11+
const CLI_AGENTS: AgentHarnessId[] = ['claude-code', 'codex', 'opencode'];
12+
const MODES: EvalMode[] = ['tools', 'local-stack'];
13+
14+
describe('buildSystemPrompt', () => {
15+
it('gives the ai-sdk agent task framing in both modes', () => {
16+
// ai-sdk is the one harness with no system prompt of its own, so it's the
17+
// one harness the framework has to supply one for.
18+
for (const mode of MODES) {
19+
expect(buildSystemPrompt('ai-sdk', mode)).toContain(
20+
'Use the provided tools'
21+
);
22+
}
23+
});
24+
25+
it('gives no framing of our own to any CLI harness', () => {
26+
// CLI harnesses ship their own system prompt; we're measuring that.
27+
for (const agent of CLI_AGENTS) {
28+
for (const mode of MODES) {
29+
expect(buildSystemPrompt(agent, mode)).toBe('');
30+
}
31+
}
32+
});
33+
34+
it('passes a CLI harness only the runtime blocks, with no base prompt', () => {
35+
for (const agent of CLI_AGENTS) {
36+
expect(
37+
buildSystemPrompt(agent, 'local-stack', 'Addendum.', 'Skills listing.')
38+
).toBe('Addendum.\n\nSkills listing.');
39+
}
40+
});
41+
42+
it('assembles to nothing at all for a CLI harness, even with skills', () => {
43+
// The real block producers, not stand-ins: with skills installed, a CLI
44+
// harness must still receive an entirely empty system prompt. Codex and
45+
// OpenCode find the skills through their own project-scope discovery and
46+
// describe them to the model themselves.
47+
const skills: SkillEntry[] = [
48+
{
49+
name: 'supabase',
50+
description: 'Use for Supabase tasks.',
51+
dir: '.claude/skills/supabase',
52+
},
53+
];
54+
for (const agent of CLI_AGENTS) {
55+
expect(
56+
buildSystemPrompt(
57+
agent,
58+
'local-stack',
59+
buildToolSurfaceAddendum(agent),
60+
buildSkillsPrompt(agent, skills)
61+
)
62+
).toBe('');
63+
}
64+
// ai-sdk has no such mechanism — it only learns about skills from us.
65+
const aiSdk = buildSystemPrompt(
66+
'ai-sdk',
67+
'local-stack',
68+
buildToolSurfaceAddendum('ai-sdk'),
69+
buildSkillsPrompt('ai-sdk', skills)
70+
);
71+
expect(aiSdk).toContain('## Available skills');
72+
expect(aiSdk).toContain('- supabase: Use for Supabase tasks.');
73+
});
74+
75+
it('never tells any agent how to end its turn', () => {
76+
// Stopping behaviour is part of what an eval measures, so the harness must
77+
// not coach it (e.g. "end your turn with a short summary").
78+
for (const agent of [...CLI_AGENTS, 'ai-sdk' as const]) {
79+
for (const mode of MODES) {
80+
const prompt = buildSystemPrompt(agent, mode);
81+
expect(prompt).not.toMatch(/summary/i);
82+
expect(prompt).not.toMatch(/end your turn/i);
83+
}
84+
}
85+
});
86+
87+
it('drops empty blocks instead of leaving blank gaps', () => {
88+
expect(
89+
buildSystemPrompt('claude-code', 'tools', '', 'Skills listing.')
90+
).toBe('Skills listing.');
91+
expect(buildSystemPrompt('ai-sdk', 'tools', '', '')).not.toMatch(/\n\n$/);
92+
});
93+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* System-prompt assembly, per agent harness.
3+
*
4+
* An eval measures out-of-the-box agent behaviour, so the harness injects as
5+
* little prompt of its own as it can get away with: only the ai-sdk agent gets
6+
* any base framing, because it is the only harness with no system prompt of its
7+
* own (`aiSdkAgent` hands `systemPrompt` straight to the model's `system`). CLI
8+
* agents ship their own coding-agent prompt, tool guidance, and stopping
9+
* behaviour — and codex/opencode have no system-prompt flag at all, so anything
10+
* we pass them lands on the *user* prompt.
11+
*/
12+
13+
import type { AgentHarnessId } from '@supabase-evals/core';
14+
import type { EvalMode } from './types.js';
15+
16+
/**
17+
* Base framing for the ai-sdk harness: what it can't infer on its own — that it
18+
* has tools, and what they act on. Deliberately silent on how to finish a turn
19+
* (no "end with a summary"): stopping behaviour is part of what's measured.
20+
* Empty for every CLI harness.
21+
*/
22+
function basePromptFor(agent: AgentHarnessId, mode: EvalMode): string {
23+
if (agent !== 'ai-sdk') return '';
24+
if (mode === 'local-stack') {
25+
return (
26+
'You are an agent solving a Supabase eval task in a Linux workspace. ' +
27+
'Use the provided tools to inspect and modify the workspace and run commands.'
28+
);
29+
}
30+
return (
31+
'You are an agent solving a Supabase eval task. ' +
32+
'Use the provided tools to inspect and modify the project.'
33+
);
34+
}
35+
36+
/**
37+
* Assemble the system prompt handed to the agent. Every block is optional, and
38+
* every one of them is ai-sdk-only (the base framing, the tool-surface addendum,
39+
* the skills listing), so a CLI harness ends up with `''` — the CLI engine then
40+
* stages no system-prompt file at all rather than an empty one.
41+
*/
42+
export function buildSystemPrompt(
43+
agent: AgentHarnessId,
44+
mode: EvalMode,
45+
addendum?: string,
46+
skillContext?: string
47+
): string {
48+
const blocks = [basePromptFor(agent, mode), addendum, skillContext].filter(
49+
Boolean
50+
);
51+
return blocks.join('\n\n');
52+
}

apps/framework/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
"version": "0.0.1",
55
"type": "module",
66
"scripts": {
7-
"check": "pnpm typecheck && pnpm test:framework",
7+
"check": "pnpm typecheck && pnpm test && pnpm test:framework",
88
"eval": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts",
99
"eval:dry": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts --dry",
1010
"eval:smoke": "node --env-file=../../.env --import tsx/esm harness/run-eval.ts --smoke",
1111
"typecheck": "tsc --noEmit",
12+
"test": "vitest run harness",
1213
"test:framework": "node --env-file-if-exists=../../.env --import tsx/esm scripts/smoke-framework.ts",
1314
"export-results": "node --import tsx/esm scripts/export-results.ts",
1415
"demo:mcp": "node --env-file=../../.env --import tsx/esm scripts/mcp-demo.ts",

0 commit comments

Comments
 (0)