Skip to content
Open
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ Skills come from [`supabase/agent-skills`](https://github.com/supabase/agent-ski

To use a skill in an experiment, reference its directory name in the experiment's `skills` array.

Both runtimes load skills lazily ([progressive disclosure](https://ai-sdk.dev/cookbook/guides/agent-skills)): only each skill's name+description is in the system prompt, and the agent pulls a skill's full instructions on demand. They differ only in how the body is fetched, because the tools-mode agent has no filesystem:
Skills are always loaded lazily ([progressive disclosure](https://ai-sdk.dev/cookbook/guides/agent-skills))a skill's full instructions are pulled on demand, never preloaded. How that happens depends on the harness:

- **Local-stack (sandbox) mode:** skills are installed into the workspace with [Vercel's `skills` CLI](https://github.com/vercel-labs/skills) (baked into the sandbox image, sourced from the local `skills/` directory — never the network), into every project scope the CLI harnesses discover natively: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex and OpenCode. Each CLI discovers the scope it reads and surfaces those skills to the model itself. The framework also still injects a listing naming them, so a CLI harness hears about them twice; removing that is a separate change.
- **Tools mode:** no filesystem, so a `load_skill` tool returns a skill's full instructions when the agent calls it with the skill's name.
- **CLI harnesses (Claude Code, Codex, OpenCode)** use their own built-in skills mechanism. Skills are installed into the sandbox workspace with [Vercel's `skills` CLI](https://github.com/vercel-labs/skills) (baked into the sandbox image, sourced from the local `skills/` directory — never the network), for each harness's own project scope: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex and OpenCode. Each CLI then discovers, advertises and loads the skills itself. The framework injects nothing — an agent's real-world skill-following behaviour is part of what an eval measures.
- **The in-process `ai-sdk` harness** has no such mechanism, so the framework supplies one. In local-stack mode it lists each skill's name+description in the system prompt and the agent reads `.claude/skills/<name>/SKILL.md` with its file tools. In tools mode there is no filesystem at all, so a `load_skill` tool returns a skill's full instructions when the agent calls it with the skill's name.

## Framework Checks

Expand Down
84 changes: 43 additions & 41 deletions apps/framework/harness/run-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '../lib/cli-args.js';
import { bootPlatformBackend } from './platform-backend.js';
import { viteBuild, vitestRun } from './project-runner.js';
import { buildSystemPrompt } from './system-prompt.js';
import {
buildDocsResult,
buildSkillResult,
Expand Down Expand Up @@ -252,9 +253,9 @@ function buildLoadSkillTool(skills: readonly ToolsSkill[]): ToolSet {
}

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

function basePromptFor(mode: EvalMode): string {
if (mode === 'local-stack') {
return (
'You are an agent solving a Supabase eval task in a Linux workspace. ' +
'Use the provided tools to inspect and modify the workspace and run commands. ' +
'When you are done, end your turn with a short summary of what you did.'
);
}
return (
'You are an agent solving a Supabase eval task. ' +
'Use the provided tools to inspect and modify the project. ' +
'When you are done, end your turn with a short summary of what you did ' +
'(or for audit tasks, your findings).'
);
}

function buildSystemPrompt(
mode: EvalMode,
addendum?: string,
skillContext?: string
): string {
const blocks = [basePromptFor(mode), addendum, skillContext].filter(Boolean);
return blocks.join('\n\n');
}

/**
* Adapt a `{ close() }` resource to `AsyncDisposable` so it can be bound with
* `await using` — cleanup then runs on scope exit (normal fall-through, `continue`,
Expand Down Expand Up @@ -367,6 +343,13 @@ async function runOne(
transcript: TranscriptPart[];
agentReport: string;
stoppedReason: string;
/**
* The exact system prompt handed to the agent (`''` when it got none). CLI

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we end up keeping this for terminal agents, I'd clarify here that it's more of an optional thing appended to the system prompt, not replacing it with this exact string alone.

* harnesses receive theirs as a file in the sandbox scratch dir, outside the
* exported workspace, so recording it here is the only way to verify from a
* run artifact what the agent was actually told.
*/
systemPrompt: string;
}
> {
const prompt = parseEvalMarkdown(
Expand Down Expand Up @@ -398,6 +381,7 @@ async function runOne(
let lastTranscript: TranscriptPart[] = [];
let lastAgentReport = '';
let lastStoppedReason = 'not_started';
let lastSystemPrompt = '';

for (let attempt = 1; attempt <= RUNS; attempt += 1) {
if (ev.mode === 'local-stack') {
Expand Down Expand Up @@ -452,8 +436,13 @@ async function runOne(
})
);

const systemPrompt = buildSystemPrompt(
exp.agent.id,
'local-stack',
session.promptAddendum
);
const run = await exp.agent.run({
systemPrompt: buildSystemPrompt('local-stack', session.promptAddendum),
systemPrompt,
userPrompt: prompt,
tools: session.tools,
sandbox: session.sandbox,
Expand All @@ -464,6 +453,7 @@ async function runOne(
lastTranscript = run.transcript;
lastAgentReport = run.agentReport;
lastStoppedReason = run.stoppedReason;
lastSystemPrompt = systemPrompt;

// Export the agent's workspace to the host so scorers can run host
// tooling (vite/vitest from the repo root) against the produced files
Expand Down Expand Up @@ -505,6 +495,7 @@ async function runOne(
transcript: run.transcript,
agentReport: run.agentReport,
stoppedReason: run.stoppedReason,
systemPrompt,
};
}
logRetryAttempt(expName, ev, attempt, last);
Expand All @@ -519,7 +510,6 @@ async function runOne(
await using cliSandbox = agentRunsInSandbox
? disposable(
await createBareSandbox({
agent: exp.agent.id,
skills: skillSources,
mounts: supabaseMcpServerMounts(),
})
Expand All @@ -532,14 +522,16 @@ async function runOne(
})
);

// CLI agents read their installed skills from disk (the bare sandbox folds
// the discovery listing into its promptAddendum). In-process agents have
// no filesystem, so their skills are advertised in the prompt and pulled
// on demand via the load_skill tool.
// CLI agents discover their installed skills themselves — the skills CLI
// put them in every harness's native project scope, so each one advertises
// and loads them in its own words and the bare sandbox contributes nothing
// here. In-process agents have no filesystem, so their skills are advertised
// in the prompt and pulled on demand via the load_skill tool.
const skillsPrompt = agentRunsInSandbox
? cliSandbox!.promptAddendum
? undefined
: buildToolsSkillsPrompt(toolsSkills);
const systemPrompt = buildSystemPrompt(
exp.agent.id,
'tools',
session.promptAddendum,
skillsPrompt
Expand All @@ -556,6 +548,7 @@ async function runOne(
lastTranscript = run.transcript;
lastAgentReport = run.agentReport;
lastStoppedReason = run.stoppedReason;
lastSystemPrompt = systemPrompt;
last = await (scorer as ToolScorer)({
...session.scoringContext,
toolCalls: run.toolCalls,
Expand All @@ -577,6 +570,7 @@ async function runOne(
transcript: run.transcript,
agentReport: run.agentReport,
stoppedReason: run.stoppedReason,
systemPrompt,
};
}
logRetryAttempt(expName, ev, attempt, last);
Expand All @@ -591,6 +585,7 @@ async function runOne(
transcript: lastTranscript,
agentReport: lastAgentReport,
stoppedReason: lastStoppedReason,
systemPrompt: lastSystemPrompt,
};
}

Expand Down Expand Up @@ -847,9 +842,16 @@ async function main() {
}
}

main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
// Only when this file is the entry point. Importing it (a unit test reaching for
// one of its helpers) must not dispatch a run or call process.exit.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main()
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
}
111 changes: 111 additions & 0 deletions apps/framework/harness/system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import type { AgentHarnessId } from '@supabase-evals/core';
import {
buildSkillsPrompt,
buildToolSurfaceAddendum,
type SkillEntry,
} from '@supabase-evals/sandbox';
import { buildSystemPrompt } from './system-prompt.js';
import type { EvalMode } from './types.js';

const CLI_AGENTS: AgentHarnessId[] = ['claude-code', 'codex', 'opencode'];
const MODES: EvalMode[] = ['tools', 'local-stack'];

describe('buildSystemPrompt', () => {
it('gives the ai-sdk agent task framing in both modes', () => {
// ai-sdk is the one harness with no system prompt of its own, so it's the
// one harness the framework has to supply one for.
for (const mode of MODES) {
expect(buildSystemPrompt('ai-sdk', mode)).toContain(
'Use the provided tools'
);
}
});

it('gives no framing of our own to any CLI harness', () => {
// CLI harnesses ship their own system prompt; we're measuring that.
for (const agent of CLI_AGENTS) {
for (const mode of MODES) {
expect(buildSystemPrompt(agent, mode)).toBe('');
}
}
});

it('refuses blocks a caller hands it for a CLI harness', () => {
// The producers gate their own output, so a non-empty block here means an
// experiment is misconfigured. Dropping it would be as silent as injecting
// it, and the block may be load-bearing: `executorMcpServer`'s addendum is
// the pause/resume protocol its tools require.
for (const agent of CLI_AGENTS) {
for (const mode of MODES) {
expect(() => buildSystemPrompt(agent, mode, 'Addendum.')).toThrow(
/must receive no system prompt/
);
expect(() =>
buildSystemPrompt(agent, mode, undefined, 'Skills listing.')
).toThrow(/must receive no system prompt/);
// Empty and absent blocks are the normal case, not a misconfiguration.
expect(buildSystemPrompt(agent, mode, '', '')).toBe('');
}
}
});

it('keeps the runtime blocks for ai-sdk, in order, after the base prompt', () => {
const base = buildSystemPrompt('ai-sdk', 'local-stack');
expect(
buildSystemPrompt('ai-sdk', 'local-stack', 'Addendum.', 'Skills listing.')
).toBe(`${base}\n\nAddendum.\n\nSkills listing.`);
});

it('assembles to nothing at all for a CLI harness, even with skills', () => {
// The real block producers, not stand-ins: with skills installed, a CLI
// harness must still receive an entirely empty system prompt. Codex and
// OpenCode find the skills through their own project-scope discovery and
// describe them to the model themselves.
const skills: SkillEntry[] = [
{
name: 'supabase',
description: 'Use for Supabase tasks.',
dir: '.claude/skills/supabase',
},
];
for (const agent of CLI_AGENTS) {
expect(
buildSystemPrompt(
agent,
'local-stack',
buildToolSurfaceAddendum(agent),
buildSkillsPrompt(agent, skills)
)
).toBe('');
}
// ai-sdk has no such mechanism — it only learns about skills from us.
const aiSdk = buildSystemPrompt(
'ai-sdk',
'local-stack',
buildToolSurfaceAddendum('ai-sdk'),
buildSkillsPrompt('ai-sdk', skills)
);
expect(aiSdk).toContain('## Available skills');
expect(aiSdk).toContain('- supabase: Use for Supabase tasks.');
});

it('never tells any agent how to end its turn', () => {
// Stopping behaviour is part of what an eval measures, so the harness must
// not coach it (e.g. "end your turn with a short summary").
for (const agent of [...CLI_AGENTS, 'ai-sdk' as const]) {
for (const mode of MODES) {
const prompt = buildSystemPrompt(agent, mode);
expect(prompt).not.toMatch(/summary/i);
expect(prompt).not.toMatch(/end your turn/i);
}
}
});

it('drops empty blocks instead of leaving blank gaps', () => {
expect(buildSystemPrompt('ai-sdk', 'tools', '', 'Skills listing.')).toBe(
`${buildSystemPrompt('ai-sdk', 'tools')}\n\nSkills listing.`
);
expect(buildSystemPrompt('ai-sdk', 'tools', '', '')).not.toMatch(/\n\n$/);
});
});
76 changes: 76 additions & 0 deletions apps/framework/harness/system-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* System-prompt assembly, per agent harness.
*
* An eval measures out-of-the-box agent behaviour, so the harness injects as
* little prompt of its own as it can get away with: only the ai-sdk agent gets
* any base framing, because it is the only harness with no system prompt of its
* own (`aiSdkAgent` hands `systemPrompt` straight to the model's `system`). CLI
* agents ship their own coding-agent prompt, tool guidance, and stopping
* behaviour — and codex/opencode have no system-prompt flag at all, so anything
* we pass them lands on the *user* prompt.
*/

import type { AgentHarnessId } from '@supabase-evals/core';
import type { EvalMode } from './types.js';

/**
* Base framing for the ai-sdk harness: what it can't infer on its own — that it
* has tools, and what they act on. Deliberately silent on how to finish a turn
* (no "end with a summary"): stopping behaviour is part of what's measured.
* Empty for every CLI harness.
*/
function basePromptFor(agent: AgentHarnessId, mode: EvalMode): string {
if (agent !== 'ai-sdk') return '';
if (mode === 'local-stack') {
return (
'You are an agent solving a Supabase eval task in a Linux workspace. ' +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also tie in the fix for #164 here? I.e. we shouldn't mention that this is a Supabase eval or that there is a "project" needing to be modified, as that could give an unfair advantage to the non terminal-agent experiments.

I recognize it's a minor issue at this point, since only the AI SDK harness is affected here, which is no longer used in our benchmarks / regression runs. Might end up taking it out entirely. But also easy to clean up while we're here.

'Use the provided tools to inspect and modify the workspace and run commands.'
);
}
return (
'You are an agent solving a Supabase eval task. ' +
'Use the provided tools to inspect and modify the project.'
);
}

/**
* Assemble the system prompt handed to the agent. Every block is ai-sdk-only —
* the base framing, the tool-surface addendum, the skills listing — so a CLI
* harness ends up with `''`, and the CLI engine then stages no system-prompt
* file at all rather than an empty one.
*
* The callers' blocks are already gated by their producers, so a non-empty one
* arriving here means an experiment is misconfigured. Throw rather than drop it:
* dropping is as silent as injecting, and the block may be load-bearing. An MCP
* server carrying a `promptAddendum` is the live path in. Only
* `executorMcpServer` has one, and its text is the pause/resume protocol its
* tools require, not a tool description. A CLI harness paired with it would get
* the tools and none of the protocol, then stall on the first paused execution
* with a recorded prompt of `''` explaining nothing.
*/
export function buildSystemPrompt(
agent: AgentHarnessId,
mode: EvalMode,
addendum?: string,
skillContext?: string
): string {
if (agent !== 'ai-sdk') {
for (const [arg, block] of [
['addendum', addendum],
['skillContext', skillContext],
] as const) {
if (block) {
throw new Error(
`buildSystemPrompt got a non-empty ${arg} for '${agent}', which must receive ` +
'no system prompt. Whichever runtime or MCP server produced it is not gated ' +
'on the agent harness.'
);
}
}
return '';
}
const blocks = [basePromptFor(agent, mode), addendum, skillContext].filter(
Boolean
);
return blocks.join('\n\n');
}
Loading
Loading