@workos/quickstudy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/examples/harbor-notes/README.md +40 -0
- package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
- package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
- package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
- package/examples/harbor-notes/experiments/scripted.ts +6 -0
- package/examples/harbor-notes/package.json +6 -0
- package/examples/harbor-notes/quickstudy.identity.json +1 -0
- package/examples/harbor-notes/runtime.ts +48 -0
- package/examples/harbor-notes/semantic-example.ts +21 -0
- package/images/agent-runtime/Dockerfile +58 -0
- package/images/egress-proxy/Dockerfile +28 -0
- package/images/mcp-proxy/Dockerfile +30 -0
- package/package.json +53 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +107 -0
- package/src/adapters/echo.ts +57 -0
- package/src/adapters/parse.ts +117 -0
- package/src/adapters/types.ts +152 -0
- package/src/build-info.generated.ts +12 -0
- package/src/cli.ts +787 -0
- package/src/completeness.ts +104 -0
- package/src/diagnose/excerpt.ts +106 -0
- package/src/diagnose/prompt.ts +175 -0
- package/src/diagnose/render.ts +55 -0
- package/src/diagnose/run.ts +290 -0
- package/src/diagnose/select.ts +110 -0
- package/src/diagnose/types.ts +88 -0
- package/src/evals/discovery.ts +173 -0
- package/src/evals/prompt.ts +190 -0
- package/src/evals/result.ts +10 -0
- package/src/evals/types.ts +115 -0
- package/src/execution-policy.ts +71 -0
- package/src/experiments/discovery.ts +76 -0
- package/src/experiments/groups.ts +119 -0
- package/src/experiments/types.ts +116 -0
- package/src/export-types.ts +127 -0
- package/src/export.ts +381 -0
- package/src/hash.ts +74 -0
- package/src/identity-diff.ts +30 -0
- package/src/ids.ts +30 -0
- package/src/index.ts +58 -0
- package/src/isolation/docker.ts +639 -0
- package/src/isolation/image-contexts.generated.ts +927 -0
- package/src/isolation/images.ts +138 -0
- package/src/isolation/mcp-proxy/server.ts +260 -0
- package/src/isolation/mcp.ts +144 -0
- package/src/isolation/proxy/allowlist.ts +148 -0
- package/src/isolation/proxy/server.ts +382 -0
- package/src/llm.ts +132 -0
- package/src/manifest.ts +228 -0
- package/src/model-identity.ts +12 -0
- package/src/plan.ts +55 -0
- package/src/probe.ts +426 -0
- package/src/report/pass-at-k.ts +76 -0
- package/src/report/report.ts +731 -0
- package/src/runner/context.ts +96 -0
- package/src/runner/deadline.ts +37 -0
- package/src/runner/execute.ts +992 -0
- package/src/runner/run-lock.ts +32 -0
- package/src/runner/scheduler.ts +62 -0
- package/src/runner/score-worker.ts +107 -0
- package/src/runner/scorer-worker.ts +61 -0
- package/src/runtime/types.ts +89 -0
- package/src/secrets.ts +151 -0
- package/src/semantic.ts +185 -0
- package/src/serve.ts +52 -0
- package/src/source-identity.ts +76 -0
- package/src/store/artifacts.ts +146 -0
- package/src/store/db.ts +318 -0
- package/src/store/schema.ts +39 -0
- package/src/surface-usage.ts +297 -0
- package/src/ui-bundle.generated.ts +12 -0
- package/ui/dist/index.html +32 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code adapter: drives the `claude` CLI headless inside the attempt
|
|
3
|
+
* container.
|
|
4
|
+
*
|
|
5
|
+
* `--dangerously-skip-permissions` is acceptable here ONLY because the
|
|
6
|
+
* container is the security boundary — a fresh filesystem, one write-only
|
|
7
|
+
* results mount, and force-removal after grading.
|
|
8
|
+
*
|
|
9
|
+
* Treatment differences are environmental, never prompt-level: the MCP
|
|
10
|
+
* treatment places `.mcp.json` into /workspace before start; the CLI
|
|
11
|
+
* treatment relies on the runtime-exposed PATH directory; the web treatment
|
|
12
|
+
* leaves native web tools enabled. The task prompt stays byte-identical
|
|
13
|
+
* across experiments.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { McpServerConfig } from "../isolation/mcp.ts";
|
|
17
|
+
import { parseClaudeStream } from "./parse.ts";
|
|
18
|
+
import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Container path of the MCP config Claude Code auto-discovers. */
|
|
21
|
+
export const CLAUDE_MCP_CONFIG_PATH = "/workspace/.mcp.json";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Project-scope `.mcp.json` rendered from the runtime's MCP servers
|
|
25
|
+
* (streamable HTTP). The configs arrive credential-free — authed servers
|
|
26
|
+
* already point at the token-proxy sidecar — so only the URL is rendered.
|
|
27
|
+
*/
|
|
28
|
+
function renderMcpJson(servers: Readonly<Record<string, McpServerConfig>> = {}): string {
|
|
29
|
+
const mcpServers = Object.fromEntries(
|
|
30
|
+
Object.entries(servers).map(([name, spec]) => [name, { type: "http", url: spec.url }]),
|
|
31
|
+
);
|
|
32
|
+
return `${JSON.stringify({ mcpServers }, null, 2)}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ClaudeCodeAdapter implements ContainerAgentAdapter {
|
|
36
|
+
readonly kind = "container" as const;
|
|
37
|
+
readonly name: string;
|
|
38
|
+
readonly identity;
|
|
39
|
+
readonly versionCommand = ["claude", "--version"] as const;
|
|
40
|
+
readonly requiredHostEnv = ["ANTHROPIC_API_KEY"] as const;
|
|
41
|
+
/**
|
|
42
|
+
* Egress-proxy allowlist (harness-default layer). Claude Code documents
|
|
43
|
+
* corporate-proxy support via HTTPS_PROXY/HTTP_PROXY; api.anthropic.com is
|
|
44
|
+
* the LLM endpoint, statsig/sentry are its telemetry (allowed so denial
|
|
45
|
+
* logs stay signal, not noise). Verified live by the egress-proxy regression
|
|
46
|
+
* check (`bun scripts/dev-attempt.ts --experiment <id> --egress-proxy`).
|
|
47
|
+
*/
|
|
48
|
+
readonly egressHosts = ["api.anthropic.com", "statsig.anthropic.com", "*.sentry.io"] as const;
|
|
49
|
+
|
|
50
|
+
constructor(name = "claude", config: { model?: string; reasoning?: string } = {}) {
|
|
51
|
+
this.name = name;
|
|
52
|
+
this.identity = {
|
|
53
|
+
provider: "anthropic",
|
|
54
|
+
model: config.model ?? "UNPINNED",
|
|
55
|
+
reasoning: config.reasoning ?? "UNPINNED",
|
|
56
|
+
cliVersion: "unknown",
|
|
57
|
+
surfacePolicies: {
|
|
58
|
+
docs: "native WebSearch/WebFetch allowed",
|
|
59
|
+
mcp: "--disallowedTools WebSearch,WebFetch; MCP configured",
|
|
60
|
+
cli: "--disallowedTools WebSearch,WebFetch",
|
|
61
|
+
},
|
|
62
|
+
nativeWebControl: "controlled" as const,
|
|
63
|
+
reproducible: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
|
|
68
|
+
return {
|
|
69
|
+
url: "https://api.anthropic.com/v1/models",
|
|
70
|
+
headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "anthropic-version": "2023-06-01" },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
|
|
75
|
+
if (ctx.mcpServers === undefined) return {};
|
|
76
|
+
return { [CLAUDE_MCP_CONFIG_PATH]: renderMcpJson(ctx.mcpServers) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
command(ctx: ContainerAttemptContext): string[] {
|
|
80
|
+
const args = [
|
|
81
|
+
"claude",
|
|
82
|
+
"-p",
|
|
83
|
+
ctx.prompt,
|
|
84
|
+
// stream-json is the parseable telemetry source; it requires --verbose
|
|
85
|
+
// in print mode.
|
|
86
|
+
"--output-format",
|
|
87
|
+
"stream-json",
|
|
88
|
+
"--verbose",
|
|
89
|
+
"--dangerously-skip-permissions",
|
|
90
|
+
"--model",
|
|
91
|
+
this.identity.model,
|
|
92
|
+
"--effort",
|
|
93
|
+
this.identity.reasoning,
|
|
94
|
+
];
|
|
95
|
+
if (ctx.mcpServers !== undefined) {
|
|
96
|
+
// Explicit config beats auto-discovery: headless runs must not depend
|
|
97
|
+
// on project-trust prompts.
|
|
98
|
+
args.push("--mcp-config", CLAUDE_MCP_CONFIG_PATH);
|
|
99
|
+
}
|
|
100
|
+
if (ctx.webPolicy !== "native-web-allowed") args.push("--disallowedTools", "WebSearch,WebFetch");
|
|
101
|
+
return args;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
parseStream(raw: string): AttemptMetrics {
|
|
105
|
+
return parseClaudeStream(raw);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI adapter: drives `codex exec --json` headless inside the attempt
|
|
3
|
+
* container.
|
|
4
|
+
*
|
|
5
|
+
* Sandbox/approval bypass is acceptable ONLY because the container is the
|
|
6
|
+
* security boundary (same rationale as the Claude adapter). MCP treatment
|
|
7
|
+
* config lives in `~/.codex/config.toml` in-container — Codex has no
|
|
8
|
+
* per-project MCP file. Auth: the OPENAI_API_KEY env var is injected at
|
|
9
|
+
* container create, but codex >= 0.144 no longer reads it directly — the
|
|
10
|
+
* command wrapper pipes it into `codex login --with-api-key` (stdin, never
|
|
11
|
+
* argv) to write the auth.json the CLI requires.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { McpServerConfig } from "../isolation/mcp.ts";
|
|
15
|
+
import { parseCodexStream } from "./parse.ts";
|
|
16
|
+
import type { AttemptMetrics, ContainerAgentAdapter, ContainerAttemptContext } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** Container path of the Codex config (containers run as root). */
|
|
19
|
+
export const CODEX_CONFIG_PATH = "/root/.codex/config.toml";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `config.toml` MCP table rendered from the runtime's MCP servers (url-based,
|
|
23
|
+
* credential-free — authed servers already point at the token-proxy sidecar).
|
|
24
|
+
* Server names outside TOML's bare-key charset get quoted.
|
|
25
|
+
*/
|
|
26
|
+
function renderCodexMcpToml(servers: Readonly<Record<string, McpServerConfig>> = {}): string {
|
|
27
|
+
const blocks = Object.entries(servers).map(([name, spec]) => {
|
|
28
|
+
const key = /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
|
|
29
|
+
return `[mcp_servers.${key}]\nurl = ${JSON.stringify(spec.url)}\n`;
|
|
30
|
+
});
|
|
31
|
+
return blocks.length > 0 ? blocks.join("\n") : "[mcp_servers]\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class CodexAdapter implements ContainerAgentAdapter {
|
|
35
|
+
readonly kind = "container" as const;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly identity;
|
|
38
|
+
readonly versionCommand = ["codex", "--version"] as const;
|
|
39
|
+
readonly requiredHostEnv = ["OPENAI_API_KEY"] as const;
|
|
40
|
+
/**
|
|
41
|
+
* Egress-proxy allowlist (harness-default layer). Codex is reqwest-based;
|
|
42
|
+
* reqwest honors HTTP_PROXY/HTTPS_PROXY env by default (system proxies) —
|
|
43
|
+
* verified live by the egress-proxy regression check (the CONNECT tunnel
|
|
44
|
+
* to api.openai.com round-tripped). chatgpt.com came from that run's
|
|
45
|
+
* denial log: codex probes its ChatGPT-auth endpoints even under key auth.
|
|
46
|
+
* (Its github.com update-check denials are left denied on purpose.)
|
|
47
|
+
*/
|
|
48
|
+
readonly egressHosts = ["api.openai.com", "auth.openai.com", "chatgpt.com", "*.chatgpt.com"] as const;
|
|
49
|
+
|
|
50
|
+
constructor(name = "codex", config: { model?: string; reasoning?: string } = {}) {
|
|
51
|
+
this.name = name;
|
|
52
|
+
this.identity = {
|
|
53
|
+
provider: "openai",
|
|
54
|
+
model: config.model ?? "UNPINNED",
|
|
55
|
+
reasoning: config.reasoning ?? "UNPINNED",
|
|
56
|
+
cliVersion: "unknown",
|
|
57
|
+
surfacePolicies: {
|
|
58
|
+
docs: 'web_search="live"',
|
|
59
|
+
mcp: 'web_search="disabled"; MCP configured',
|
|
60
|
+
cli: 'web_search="disabled"',
|
|
61
|
+
},
|
|
62
|
+
nativeWebControl: "controlled" as const,
|
|
63
|
+
reproducible: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
keyProbe(env: Record<string, string>): { url: string; headers: Record<string, string> } {
|
|
68
|
+
return {
|
|
69
|
+
url: "https://api.openai.com/v1/models",
|
|
70
|
+
headers: { Authorization: `Bearer ${env["OPENAI_API_KEY"] ?? ""}` },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setupFiles(ctx: ContainerAttemptContext): Record<string, string> {
|
|
75
|
+
if (ctx.mcpServers === undefined) return {};
|
|
76
|
+
return { [CODEX_CONFIG_PATH]: renderCodexMcpToml(ctx.mcpServers) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
command(ctx: ContainerAttemptContext): string[] {
|
|
80
|
+
return [
|
|
81
|
+
"sh",
|
|
82
|
+
"-c",
|
|
83
|
+
// codex 0.144+ ignores the OPENAI_API_KEY env var; mint auth.json from
|
|
84
|
+
// it in-container. Login chatter is silenced so stdout stays pure JSONL
|
|
85
|
+
// for parseStream; the key reaches login via stdin, never argv.
|
|
86
|
+
'printenv OPENAI_API_KEY | codex login --with-api-key >/dev/null 2>&1 && exec "$@"',
|
|
87
|
+
"sh",
|
|
88
|
+
"codex",
|
|
89
|
+
"exec",
|
|
90
|
+
"--json",
|
|
91
|
+
"--model",
|
|
92
|
+
this.identity.model,
|
|
93
|
+
"--config",
|
|
94
|
+
`model_reasoning_effort=${JSON.stringify(this.identity.reasoning)}`,
|
|
95
|
+
"--config",
|
|
96
|
+
`web_search=${JSON.stringify(ctx.webPolicy === "native-web-allowed" ? "live" : "disabled")}`,
|
|
97
|
+
// The container is the boundary; codex's own sandbox would fight the
|
|
98
|
+
// fixture's build tooling for no additional isolation.
|
|
99
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
100
|
+
ctx.prompt,
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
parseStream(raw: string): AttemptMetrics {
|
|
105
|
+
return parseCodexStream(raw);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The echo adapter: a no-op agent for pipeline testing.
|
|
3
|
+
*
|
|
4
|
+
* It copies the fixture into the workspace, writes a canned transcript, and
|
|
5
|
+
* makes exactly one trivial file edit (NOTES.md). This proves the full
|
|
6
|
+
* run -> grade -> persist pipeline end-to-end without Docker or LLM spend,
|
|
7
|
+
* and gives every later phase a fast integration-test target.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cp, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { AgentAdapter, AttemptContext, AttemptOutcome, TranscriptEvent } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export class EchoAdapter implements AgentAdapter {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly identity = {
|
|
17
|
+
provider: "local", model: "echo-v1", reasoning: "none", cliVersion: "built-in",
|
|
18
|
+
surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
|
|
19
|
+
nativeWebControl: "controlled" as const,
|
|
20
|
+
reproducible: true,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
constructor(name = "echo") {
|
|
24
|
+
this.name = name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async runAttempt(ctx: AttemptContext): Promise<AttemptOutcome> {
|
|
28
|
+
await cp(ctx.fixtureDir, ctx.workspaceDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const notes =
|
|
31
|
+
`# Notes\n\n` +
|
|
32
|
+
`Echo adapter placeholder: no-op pipeline smoke for attempt ${ctx.attemptId} ` +
|
|
33
|
+
`(trial ${ctx.trialIndex}).\n`;
|
|
34
|
+
await writeFile(join(ctx.workspaceDir, "NOTES.md"), notes, "utf8");
|
|
35
|
+
|
|
36
|
+
const transcript: TranscriptEvent[] = [
|
|
37
|
+
{ role: "user", content: ctx.prompt, at: new Date().toISOString() },
|
|
38
|
+
{
|
|
39
|
+
role: "assistant",
|
|
40
|
+
content: `Copied the fixture and wrote NOTES.md (echo pipeline smoke).`,
|
|
41
|
+
at: new Date().toISOString(),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const noteLines = notes.trimEnd().split("\n");
|
|
46
|
+
const diff =
|
|
47
|
+
`--- /dev/null\n+++ b/NOTES.md\n@@ -0,0 +1,${noteLines.length} @@\n` +
|
|
48
|
+
noteLines.map((line) => `+${line}`).join("\n") +
|
|
49
|
+
"\n";
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
transcript,
|
|
53
|
+
diff,
|
|
54
|
+
metrics: { tokensIn: ctx.prompt.length, tokensOut: notes.length, turns: 1, costUsd: 0 },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsers for the agents' headless output streams, reducing each raw
|
|
3
|
+
* JSONL stream to telemetry: {tokensIn, tokensOut, turns, costUsd}.
|
|
4
|
+
*
|
|
5
|
+
* Tolerance is the design constraint: vendor CLIs drift between releases
|
|
6
|
+
* (versions are pinned in images/agent-runtime/Dockerfile, and these parsers
|
|
7
|
+
* are tested against checked-in fixture streams), so a malformed or
|
|
8
|
+
* unrecognized stream must degrade gracefully — never a crash, and never a
|
|
9
|
+
* lost transcript (the raw stream is persisted before parsing).
|
|
10
|
+
*
|
|
11
|
+
* Degradation records NULL token counts, not zeros: a stream that carried no
|
|
12
|
+
* usage data is "telemetry unavailable", and zeros would poison cost stats
|
|
13
|
+
* (the store's telemetry columns are nullable for exactly this). Turns stay
|
|
14
|
+
* numeric — visible turns are countable even from a truncated stream.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AttemptMetrics } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** Parse a JSONL stream leniently: non-JSON lines are skipped, never fatal. */
|
|
20
|
+
export function parseJsonlLines(raw: string): unknown[] {
|
|
21
|
+
const objects: unknown[] = [];
|
|
22
|
+
for (const line of raw.split("\n")) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed === "" || !trimmed.startsWith("{")) continue;
|
|
25
|
+
try {
|
|
26
|
+
objects.push(JSON.parse(trimmed));
|
|
27
|
+
} catch {
|
|
28
|
+
// Tolerated: agents interleave non-JSON noise (progress bars, warnings).
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return objects;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function num(value: unknown): number {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A token field that is absent is "unavailable" (null) — never coerced to 0. */
|
|
39
|
+
function numOrNull(value: unknown): number | null {
|
|
40
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function record(value: unknown): Record<string, unknown> {
|
|
44
|
+
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Claude Code `--output-format stream-json`: one JSON event per line, ending
|
|
49
|
+
* (on success) with a `type: "result"` event that carries authoritative
|
|
50
|
+
* usage, num_turns, and total_cost_usd. Cache reads/writes count as input —
|
|
51
|
+
* they are billed tokens the attempt consumed. A stream that died before its
|
|
52
|
+
* result event has no usage data: token counts degrade to null.
|
|
53
|
+
*/
|
|
54
|
+
export function parseClaudeStream(raw: string): AttemptMetrics {
|
|
55
|
+
const metrics: AttemptMetrics = { tokensIn: null, tokensOut: null, turns: 0 };
|
|
56
|
+
let assistantEvents = 0;
|
|
57
|
+
for (const event of parseJsonlLines(raw)) {
|
|
58
|
+
const obj = record(event);
|
|
59
|
+
if (obj["type"] === "assistant") assistantEvents += 1;
|
|
60
|
+
if (obj["type"] !== "result") continue;
|
|
61
|
+
const usage = record(obj["usage"]);
|
|
62
|
+
metrics.tokensIn =
|
|
63
|
+
num(usage["input_tokens"]) + num(usage["cache_creation_input_tokens"]) + num(usage["cache_read_input_tokens"]);
|
|
64
|
+
metrics.tokensOut = num(usage["output_tokens"]);
|
|
65
|
+
metrics.turns = num(obj["num_turns"]);
|
|
66
|
+
const cost = obj["total_cost_usd"];
|
|
67
|
+
if (typeof cost === "number" && Number.isFinite(cost)) metrics.costUsd = cost;
|
|
68
|
+
}
|
|
69
|
+
// A stream that died before its result event still counts visible turns.
|
|
70
|
+
if (metrics.turns === 0) metrics.turns = assistantEvents;
|
|
71
|
+
return metrics;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Codex `exec --json`: one JSON event per line. Two shapes exist across
|
|
76
|
+
* pinned-version history, and both are handled:
|
|
77
|
+
* - thread events: `{"type":"turn.completed","usage":{...}}` per turn;
|
|
78
|
+
* - legacy protocol events: `{"msg":{"type":"token_count",...}}` with
|
|
79
|
+
* cumulative totals (last one wins) and `agent_message` turns.
|
|
80
|
+
*
|
|
81
|
+
* Unlike Anthropic usage (where cache tokens are separate counters), OpenAI
|
|
82
|
+
* usage reports `input_tokens` as the total with cached tokens as a SUBSET —
|
|
83
|
+
* adding `cached_input_tokens` on top would double-count. A stream with no
|
|
84
|
+
* usage events at all degrades to null token counts.
|
|
85
|
+
*/
|
|
86
|
+
export function parseCodexStream(raw: string): AttemptMetrics {
|
|
87
|
+
const metrics: AttemptMetrics = { tokensIn: null, tokensOut: null, turns: 0 };
|
|
88
|
+
let legacyTurns = 0;
|
|
89
|
+
for (const event of parseJsonlLines(raw)) {
|
|
90
|
+
const obj = record(event);
|
|
91
|
+
|
|
92
|
+
if (obj["type"] === "turn.completed") {
|
|
93
|
+
const usage = record(obj["usage"]);
|
|
94
|
+
const input = numOrNull(usage["input_tokens"]);
|
|
95
|
+
const output = numOrNull(usage["output_tokens"]);
|
|
96
|
+
if (input !== null) metrics.tokensIn = (metrics.tokensIn ?? 0) + input;
|
|
97
|
+
if (output !== null) metrics.tokensOut = (metrics.tokensOut ?? 0) + output;
|
|
98
|
+
metrics.turns += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const msg = record(obj["msg"]);
|
|
103
|
+
if (msg["type"] === "agent_message") legacyTurns += 1;
|
|
104
|
+
if (msg["type"] === "token_count") {
|
|
105
|
+
// Cumulative totals: the last token_count event is authoritative.
|
|
106
|
+
const info = record(msg["info"]);
|
|
107
|
+
const total = record(info["total_token_usage"]);
|
|
108
|
+
const tokensIn = num(total["input_tokens"]) || num(msg["input_tokens"]);
|
|
109
|
+
const tokensOut = num(total["output_tokens"]) || num(msg["output_tokens"]);
|
|
110
|
+
if (tokensIn > 0) metrics.tokensIn = tokensIn;
|
|
111
|
+
if (tokensOut > 0) metrics.tokensOut = tokensOut;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (metrics.turns === 0) metrics.turns = legacyTurns;
|
|
115
|
+
return metrics;
|
|
116
|
+
}
|
|
117
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent adapter contract.
|
|
3
|
+
*
|
|
4
|
+
* The v3 runner depends only on these interfaces: it prepares the attempt's
|
|
5
|
+
* starting state from the eval's `local/`, hands the adapter the eval's
|
|
6
|
+
* prompt plus the experiment's treatment context, and expects back a
|
|
7
|
+
* transcript, a diff, and metrics. Host adapters run in a scratch directory
|
|
8
|
+
* (the echo pipeline smoke); container adapters compose a headless CLI
|
|
9
|
+
* invocation the runner executes inside the attempt container.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { McpServerConfig } from "../isolation/mcp.ts";
|
|
13
|
+
import type { WebPolicy } from "../runtime/types.ts";
|
|
14
|
+
|
|
15
|
+
/** Immutable agent configuration recorded in every experiment manifest. */
|
|
16
|
+
export interface AdapterIdentityConfig {
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
reasoning: string;
|
|
20
|
+
/** Filled from the executable in the selected image when possible. */
|
|
21
|
+
cliVersion: string;
|
|
22
|
+
surfacePolicies: Record<string, string>;
|
|
23
|
+
/** Whether non-web treatments can disable native/vendor web tools. This is not network isolation. */
|
|
24
|
+
nativeWebControl: "controlled" | "uncontrolled";
|
|
25
|
+
reproducible: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One event in an attempt's transcript (persisted as JSONL). */
|
|
29
|
+
export interface TranscriptEvent {
|
|
30
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
31
|
+
content: string;
|
|
32
|
+
/** ISO-8601 timestamp. */
|
|
33
|
+
at: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AttemptMetrics {
|
|
37
|
+
/**
|
|
38
|
+
* `null` means telemetry unavailable — the vendor stream carried no usage
|
|
39
|
+
* data (or died before it). Never conflate with 0: zeros poison cost
|
|
40
|
+
* comparisons, nulls render as "unavailable". The store's telemetry
|
|
41
|
+
* columns have been nullable from the start for exactly this case.
|
|
42
|
+
*/
|
|
43
|
+
tokensIn: number | null;
|
|
44
|
+
tokensOut: number | null;
|
|
45
|
+
turns: number;
|
|
46
|
+
costUsd?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Everything a host adapter gets for one attempt. */
|
|
50
|
+
export interface AttemptContext {
|
|
51
|
+
/** Aborted when the current stage expires. Implementations must stop work. */
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
runId: string;
|
|
54
|
+
attemptId: string;
|
|
55
|
+
/** Absolute path to the directory holding the attempt's starting state. */
|
|
56
|
+
fixtureDir: string;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path to an empty scratch directory. The adapter populates it
|
|
59
|
+
* from the fixture and performs its edits there; the runner exports
|
|
60
|
+
* it into the artifacts store afterwards.
|
|
61
|
+
*/
|
|
62
|
+
workspaceDir: string;
|
|
63
|
+
/** The eval's task prompt, byte-identical across experiments. */
|
|
64
|
+
prompt: string;
|
|
65
|
+
trialIndex: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What an adapter returns for a completed attempt. */
|
|
69
|
+
export interface AttemptOutcome {
|
|
70
|
+
transcript: TranscriptEvent[];
|
|
71
|
+
/** Unified diff of the workspace against the fixture. */
|
|
72
|
+
diff: string;
|
|
73
|
+
metrics: AttemptMetrics;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AgentAdapter {
|
|
77
|
+
/** The adapter name experiments select via `agent.adapter` (e.g. "echo"). */
|
|
78
|
+
name: string;
|
|
79
|
+
identity?: AdapterIdentityConfig;
|
|
80
|
+
runAttempt(ctx: AttemptContext): Promise<AttemptOutcome>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What a container adapter gets when composing its invocation. Host paths are
|
|
85
|
+
* deliberately absent — a container adapter never touches the host
|
|
86
|
+
* filesystem; the runner owns the container lifecycle so scoring can reach
|
|
87
|
+
* the live sandbox before teardown.
|
|
88
|
+
*/
|
|
89
|
+
export interface ContainerAttemptContext {
|
|
90
|
+
runId: string;
|
|
91
|
+
attemptId: string;
|
|
92
|
+
/** The eval's task prompt, byte-identical across experiments. */
|
|
93
|
+
prompt: string;
|
|
94
|
+
trialIndex: number;
|
|
95
|
+
/**
|
|
96
|
+
* The experiment runtime's MCP servers, credential-free (authed entries
|
|
97
|
+
* already point at the token-proxy sidecar) — present only when the
|
|
98
|
+
* runtime declares servers. The adapter renders these into its vendor's
|
|
99
|
+
* config format via setupFiles.
|
|
100
|
+
*/
|
|
101
|
+
mcpServers?: Readonly<Record<string, McpServerConfig>>;
|
|
102
|
+
/** The runtime's native web-tool policy (default: blocked). */
|
|
103
|
+
webPolicy: WebPolicy;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A real agent driven headless inside the attempt container. The adapter is
|
|
108
|
+
* pure composition + parsing: which files to place (treatment config),
|
|
109
|
+
* which command to run in /workspace, which host env vars must exist, and
|
|
110
|
+
* how to turn the raw output stream into telemetry. The raw stream itself is
|
|
111
|
+
* the transcript artifact — parsing failures can never lose evidence.
|
|
112
|
+
*/
|
|
113
|
+
export interface ContainerAgentAdapter {
|
|
114
|
+
kind: "container";
|
|
115
|
+
/** The adapter name experiments select via `agent.adapter` (e.g. "claude", "codex"). */
|
|
116
|
+
name: string;
|
|
117
|
+
identity?: AdapterIdentityConfig;
|
|
118
|
+
/** Command used during preflight to record the executable's exact version. */
|
|
119
|
+
versionCommand?: readonly string[];
|
|
120
|
+
/**
|
|
121
|
+
* Cheap authenticated request composed from the collected provider keys,
|
|
122
|
+
* probed at run preflight so a present-but-rejected key aborts BEFORE any
|
|
123
|
+
* container spend. A 401/403 response stops the run; network failures only
|
|
124
|
+
* warn — an unreachable probe must not gate a run the agent might complete.
|
|
125
|
+
*/
|
|
126
|
+
keyProbe?(env: Record<string, string>): { url: string; headers: Record<string, string> };
|
|
127
|
+
/**
|
|
128
|
+
* Host env vars that must be present before any container is created
|
|
129
|
+
* (shared provider keys — see src/secrets.ts).
|
|
130
|
+
*/
|
|
131
|
+
requiredHostEnv: readonly string[];
|
|
132
|
+
/**
|
|
133
|
+
* Hosts this agent's CLI must reach (its LLM/vendor endpoints) — the
|
|
134
|
+
* harness-default layer of the egress allowlist when the run uses the
|
|
135
|
+
* egress proxy (see isolation/proxy/allowlist.ts). Adapters that omit it
|
|
136
|
+
* contribute nothing, which under the proxy means the agent cannot reach
|
|
137
|
+
* any endpoint the runtime's own egress hosts don't list.
|
|
138
|
+
*/
|
|
139
|
+
egressHosts?: readonly string[];
|
|
140
|
+
/**
|
|
141
|
+
* Files to write inside the container before the agent starts, keyed by
|
|
142
|
+
* absolute container path. This is where treatments differ: the MCP
|
|
143
|
+
* treatment places MCP config, others place nothing.
|
|
144
|
+
*/
|
|
145
|
+
setupFiles(ctx: ContainerAttemptContext): Record<string, string>;
|
|
146
|
+
/** The headless invocation, executed in /workspace inside the container. */
|
|
147
|
+
command(ctx: ContainerAttemptContext): string[];
|
|
148
|
+
/** Parse the raw output stream into telemetry. Must tolerate malformed streams. */
|
|
149
|
+
parseStream(raw: string): AttemptMetrics;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type AnyAgentAdapter = AgentAdapter | ContainerAgentAdapter;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Populated temporarily by scripts/build-binary.ts for compiled releases. */
|
|
2
|
+
export const EMBEDDED_BUILD_INFO: {
|
|
3
|
+
version: string;
|
|
4
|
+
revision: string | null;
|
|
5
|
+
dirty: boolean | null;
|
|
6
|
+
sourceHash: string | null;
|
|
7
|
+
} = {
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
revision: null,
|
|
10
|
+
dirty: null,
|
|
11
|
+
sourceHash: null,
|
|
12
|
+
};
|