Skip to content

Commit 6c99b5f

Browse files
fix: containerize OpenCode server for full isolation, add more verbose logging & agent logs passthrough (#378)
* feat: containerize opencode instance for full isolation * feat: agent logs & cleanup of containers on runner exit * chore: add agent verbose logging * fix: reset solver output dir before every attempt * chore: freeze OC version in Dockerfile * chore: revert timeout change * fix(test): fix tests * chore: factor out constants from opencode.ts * fix: passthrough needed env vars to opencode container * fix(test): requirements.test.ts * fix(runner): address PR 378 review feedback for opencode docker Handle docker log stream spawn errors, scope container cleanup to the current run via docker labels, install signal handlers lazily, and skip tracer session polling when session id extraction fails. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: prepend sigint listeners * feat: bind-mount project-level opencode config to container * chore: final CR, document passthrough * chore: pass verbose option to solver stage * docs: update docs --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a9172ff commit 6c99b5f

29 files changed

Lines changed: 2666 additions & 454 deletions

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ A benchmark suite for evaluating how coding models solve real React Native tasks
66

77
Groups map to top-level folders under `evals/`.
88

9-
| Group | Path | Status |
10-
| --- | --- | --- |
11-
| animation | `evals/animation` | Active |
12-
| async-state | `evals/async-state` | Active |
13-
| navigation | `evals/navigation` | Active |
9+
| Group | Path | Status |
10+
| ----------------- | ------------------------- | ------ |
11+
| animation | `evals/animation` | Active |
12+
| async-state | `evals/async-state` | Active |
13+
| navigation | `evals/navigation` | Active |
1414
| react-native-apis | `evals/react-native-apis` | Active |
15-
| expo-sdk | `evals/expo-sdk` | WIP |
16-
| brownfield | `evals/brownfield` | WIP |
17-
| nitro-modules | `evals/nitro-modules` | WIP |
18-
| lists | `evals/lists` | Active |
15+
| expo-sdk | `evals/expo-sdk` | WIP |
16+
| brownfield | `evals/brownfield` | WIP |
17+
| nitro-modules | `evals/nitro-modules` | WIP |
18+
| lists | `evals/lists` | Active |
1919

2020
> Want a group that is not listed here? [Open an issue](https://github.com/callstackincubator/evals/issues/new/choose) to request it. Contributions are also welcome.
2121
@@ -27,6 +27,10 @@ bun runner/run.ts --model openai/gpt-4.1-mini --output generated/my-generated
2727
bun runner/judge.ts --model openai/gpt-5.3-codex --input generated/my-generated
2828
```
2929

30+
OpenCode runs in Docker for solver and judge calls. Credentials reach containers via copied `~/.local/share/opencode/auth.json`, passthrough env vars (`OPENAI_*`, `CLOUDFLARE_*`, and others), and an optional repo-level `opencode.json`. See [docs/opencode-docker.md](./docs/opencode-docker.md) for the full mount and secrets reference.
31+
32+
For debugging OpenCode runs, pass `--agent-logs` to stream agent/session events, or `--verbose` for per-call session heartbeats and phase traces (`--verbose` also enables agent logs). Both flags work on `run.ts` and `judge.ts`.
33+
3034
For full command reference and workflows, see [docs](./docs) and [CONTRIBUTING.md](./CONTRIBUTING.md).
3135

3236
## Whitepaper

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ If you are starting fresh, read these in order:
1313
3. [`testing-your-evals.md`](./testing-your-evals.md) for focused verification before opening a PR.
1414
4. [`adding-new-category.md`](./adding-new-category.md) for category README and requirement-design workflow.
1515

16+
For OpenCode Docker credentials, env passthrough, and `--agent-logs` / `--verbose` debugging, see [`opencode-docker.md`](./opencode-docker.md).
17+
1618
For contribution workflow, command examples, and PR conventions, see [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../AGENTS.md`](../AGENTS.md).

docs/opencode-docker.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# OpenCode Docker Runtime
2+
3+
The runner starts OpenCode inside Docker for solver and judge model calls. This page describes how credentials and config reach the container, and how to turn on extra logging.
4+
5+
## What gets mounted
6+
7+
Each worker session bind-mounts three things into the container:
8+
9+
| Host source | Container path | Mode | Purpose |
10+
| --- | --- | --- | --- |
11+
| Eval workspace directory | `/workspace` | read-write | Files the agent reads and edits |
12+
| Isolated temp dir with copied `~/.local/share/opencode/auth.json` | `/root/.local/share/opencode` (`auth.json` inside) | read-write | OpenCode provider credentials |
13+
| Repo `opencode.json` or `opencode.jsonc` (if present) | `/root/.config/opencode/<filename>` | read-only | Provider/model config (no secrets) |
14+
15+
The host `auth.json` is copied into a per-container temp directory, and that directory is bind-mounted to `/root/.local/share/opencode` so concurrent workers do not share or overwrite the same host file.
16+
17+
Provider access depends on 3 optional sources:
18+
19+
1. `~/.local/share/opencode/auth.json`
20+
2. passthrough env vars
21+
3. config referenced from `opencode.json`/`opencode.jsonc` placed in this repo root
22+
23+
## Environment variable passthrough
24+
25+
The runner forwards matching host environment variables into the container with `docker run -e`.
26+
27+
A variable is passed when:
28+
29+
1. It is set and non-empty in the host process environment, and
30+
2. Its name matches one of the default prefixes:
31+
32+
- `OPENCODE_`
33+
- `OPENAI_`
34+
- `ANTHROPIC_`
35+
- `GOOGLE_`
36+
- `GEMINI_`
37+
- `AZURE_`
38+
- `PARASAIL_`
39+
- `CLOUDFLARE_`
40+
41+
Examples that are forwarded: `OPENAI_API_KEY`, `CLOUDFLARE_API_TOKEN`, `OPENCODE_SERVER_LOG_LEVEL`.
42+
43+
Unrelated variables (for example `PATH`, `HOME`, `GITHUB_TOKEN`) are not forwarded.
44+
45+
### Extra prefixes
46+
47+
To forward additional prefixes, set a comma-separated list before running:
48+
49+
```bash
50+
export OPENCODE_DOCKER_EXTRA_ENV_PREFIXES='MY_PROVIDER_,CUSTOM_GATEWAY_'
51+
bun runner/run.ts --model openai/gpt-4.1-mini --output generated/my-generated
52+
```
53+
54+
Extra prefixes are merged with the defaults above.
55+
56+
## Debugging flags
57+
58+
Both `bun runner/run.ts` and `bun runner/judge.ts` accept:
59+
60+
### `--agent-logs`
61+
62+
Streams OpenCode agent activity from the server SSE feed. Log lines are prefixed with `[opencode-docker][...][agent]` and include session lifecycle events, tool calls, and session errors.
63+
64+
This flag also:
65+
66+
- Sets the in-container server log level to `DEBUG`
67+
- Enables `--print-logs` on the OpenCode server process
68+
69+
Use this when you need provider or session error details that do not appear in default output.
70+
71+
### `--verbose`
72+
73+
Enables `[opencode-trace]` diagnostics for each OpenCode model call:
74+
75+
- Call start/stop with model, port, timeout, and workspace directory
76+
- Phase transitions (`structured-output`, `json-fallback`, and so on)
77+
- Session ID when available
78+
- Heartbeats every 3 seconds with session status, message count, and latest assistant activity (tool runs, text, errors)
79+
80+
`--verbose` also turns on agent logging (equivalent to passing `--agent-logs`).
81+
82+
Example:
83+
84+
```bash
85+
bun runner/run.ts \
86+
--model openai/gpt-4.1-mini \
87+
--output generated/my-generated \
88+
--agent-logs \
89+
--verbose
90+
```
91+
92+
For judge runs:
93+
94+
```bash
95+
bun runner/judge.ts \
96+
--model openai/gpt-5.3-codex \
97+
--input generated/my-generated \
98+
--agent-logs \
99+
--verbose
100+
```
101+
102+
## Related environment variables
103+
104+
| Variable | Default | Effect |
105+
| ------------------------------------ | ---------------------------------------- | ---------------------------------------------------------- |
106+
| `OPENCODE_STREAM_CONTAINER_LOGS` | enabled | Set to `0` to stop streaming `[container]` docker logs |
107+
| `OPENCODE_SERVER_LOG_LEVEL` | `INFO` | Server log level when `--agent-logs` is off |
108+
| `OPENCODE_SERVER_PRINT_LOGS` | off | Set to `1` to enable `--print-logs` without `--agent-logs` |
109+
| `OPENCODE_DOCKER_EXTRA_ENV_PREFIXES` | unset | Comma-separated extra env key prefixes to forward |
110+
111+
When a run starts, the runner prints a single line summarizing the effective logging settings (`serve log level`, `agent logs`, `verbose`).

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
"lint": "eslint .",
77
"format": "prettier --write .",
88
"bench:run": "bun runner/run.ts",
9-
"bench:judge": "bun runner/judge.ts",
10-
"bench:series": "bash ./scripts/bench-series.sh"
9+
"bench:judge": "bun runner/judge.ts"
1110
},
1211
"devDependencies": {
1312
"@types/node": "^25.2.2",

runner/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export function parseRunCliArgs(argv: string[] = Bun.argv.slice(2)) {
2020
const { values } = parseArgv({
2121
args: argv,
2222
options: {
23+
'agent-logs': { type: 'boolean', default: false },
24+
'verbose': { type: 'boolean', default: false },
2325
'concurrency': { type: 'string', default: '4' },
2426
'fail-fast': { type: 'boolean', default: false },
2527
'max-retries': { type: 'string', default: '1' },
@@ -38,6 +40,8 @@ export function parseRunCliArgs(argv: string[] = Bun.argv.slice(2)) {
3840
}
3941

4042
return {
43+
agentLogs: values['agent-logs'] ?? false,
44+
verbose: values.verbose ?? false,
4145
concurrency: parsePositiveInteger(values.concurrency, '--concurrency'),
4246
failFast: values['fail-fast'] ?? false,
4347
maxRetries: parsePositiveInteger(values['max-retries'], '--max-retries'),
@@ -56,6 +60,8 @@ export function parseJudgeCliArgs(argv: string[] = Bun.argv.slice(2)) {
5660
const { values } = parseArgv({
5761
args: argv,
5862
options: {
63+
'agent-logs': { type: 'boolean', default: false },
64+
'verbose': { type: 'boolean', default: false },
5965
'concurrency': { type: 'string', default: '4' },
6066
'debug': { type: 'boolean', default: false },
6167
'fail-fast': { type: 'boolean', default: false },
@@ -101,6 +107,8 @@ export function parseJudgeCliArgs(argv: string[] = Bun.argv.slice(2)) {
101107
}
102108

103109
return {
110+
agentLogs: values['agent-logs'] ?? false,
111+
verbose: values.verbose ?? false,
104112
concurrency: parsePositiveInteger(values.concurrency, '--concurrency'),
105113
debug: values.debug ?? false,
106114
failFast: values['fail-fast'] ?? false,

runner/docker/opencode/Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM debian:bookworm-slim
2+
3+
RUN apt-get update \
4+
&& apt-get install -y --no-install-recommends ca-certificates curl git \
5+
&& rm -rf /var/lib/apt/lists/*
6+
7+
ARG TARGETARCH
8+
ARG OPENCODE_VERSION=v1.15.10
9+
RUN ARCH="${TARGETARCH:-amd64}" \
10+
&& case "$ARCH" in \
11+
amd64) OPENCODE_ASSET=opencode-linux-x64.tar.gz ;; \
12+
arm64) OPENCODE_ASSET=opencode-linux-arm64.tar.gz ;; \
13+
*) echo "unsupported docker arch: $ARCH" >&2 && exit 1 ;; \
14+
esac \
15+
&& curl -fsSL "https://github.com/anomalyco/opencode/releases/download/${OPENCODE_VERSION}/${OPENCODE_ASSET}" \
16+
| tar -xz -C /usr/local/bin opencode
17+
18+
WORKDIR /workspace
19+
ENTRYPOINT ["opencode"]
20+
CMD ["serve", "--hostname=0.0.0.0", "--port=4096"]

runner/evaluators/llm/judge-client.ts

Lines changed: 93 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Output, generateText } from 'ai'
2-
import { createOpencode } from 'ai-sdk-provider-opencode-sdk'
32
import { z } from 'zod'
43
import {
54
collectOpencodeSessionSnapshot,
65
type OpencodeSessionSnapshot,
76
} from 'runner/utils/opencode-session'
8-
import { ensureOpencodeServerStarted } from 'runner/utils/opencode'
9-
7+
import { createIsolatedOpencodeModel } from 'runner/utils/opencode-model'
8+
import {
9+
logOpencodeTrace,
10+
runTracedOpencodeCall,
11+
startOpencodeCallTracer,
12+
} from 'runner/utils/opencode-trace'
1013
const JSON_FALLBACK_SYSTEM_PROMPT = `
1114
Return only valid JSON matching this shape:
1215
{
@@ -51,6 +54,8 @@ type RunJudgeCallOptions = {
5154
timeout: number
5255
port?: number
5356
directory?: string
57+
cwd?: string
58+
verbose?: boolean
5459
}
5560

5661
function asRecord(value: unknown) {
@@ -110,63 +115,101 @@ function parseJudgeOutputFromText(rawText: string) {
110115
export async function runJudgeCall(
111116
options: RunJudgeCallOptions
112117
): Promise<JudgeCallResult> {
113-
await ensureOpencodeServerStarted({ timeout: options.timeout, port: options.port })
118+
if (options.port === undefined) {
119+
throw new Error('runJudgeCall requires an opencode server port')
120+
}
114121

115-
const provider = createOpencode({
116-
autoStartServer: false,
122+
const promptBytes = Buffer.byteLength(options.prompt, 'utf8')
123+
const tracer = startOpencodeCallTracer({
124+
label: 'judge',
125+
model: options.model,
117126
port: options.port,
127+
directory: options.directory ?? options.cwd,
128+
timeoutMs: options.timeout,
129+
inputSummary: `promptBytes=${promptBytes}`,
118130
})
119131

120-
const judgeModel = provider(options.model, { createNewSession: true })
132+
const { model: judgeModel, dispose } = createIsolatedOpencodeModel(
133+
options.model,
134+
{
135+
port: options.port,
136+
cwd: options.cwd,
137+
}
138+
)
121139

122140
try {
123-
const response = await generateText({
124-
model: judgeModel,
125-
prompt: options.prompt,
126-
abortSignal: AbortSignal.timeout(options.timeout),
127-
output: Output.object({
128-
schema: structuredOutputSchema,
129-
name: 'eval_requirements_result',
130-
description: 'Requirement verdicts for a React Native eval',
131-
}),
132-
})
141+
try {
142+
const response = await runTracedOpencodeCall(
143+
tracer,
144+
'generateText:structured-output',
145+
() =>
146+
generateText({
147+
model: judgeModel,
148+
prompt: options.prompt,
149+
abortSignal: AbortSignal.timeout(options.timeout),
150+
output: Output.object({
151+
schema: structuredOutputSchema,
152+
name: 'eval_requirements_result',
153+
description: 'Requirement verdicts for a React Native eval',
154+
}),
155+
})
156+
)
133157

134-
return {
135-
summary: response.output.summary,
136-
requirements: response.output.requirements,
137-
opencodeSession: await collectOpencodeSessionSnapshot({
138-
sessionId: extractOpencodeSessionId(response),
139-
port: options.port,
140-
directory: options.directory,
141-
}),
142-
}
143-
} catch (structuredOutputError) {
144-
const fallbackResponse = await generateText({
145-
model: judgeModel,
146-
prompt: options.prompt,
147-
system: JSON_FALLBACK_SYSTEM_PROMPT,
148-
abortSignal: AbortSignal.timeout(options.timeout),
149-
})
158+
tracer.noteSessionId(extractOpencodeSessionId(response))
150159

151-
const parsedOutput = parseJudgeOutputFromText(fallbackResponse.text)
152-
if (!parsedOutput.success) {
153-
const originalMessage =
154-
structuredOutputError instanceof Error
155-
? structuredOutputError.message
156-
: String(structuredOutputError)
157-
throw new Error(
158-
`judge did not return valid requirement output (structured output failed: ${originalMessage})`
160+
return {
161+
summary: response.output.summary,
162+
requirements: response.output.requirements,
163+
opencodeSession: await collectOpencodeSessionSnapshot({
164+
sessionId: extractOpencodeSessionId(response),
165+
port: options.port,
166+
directory: options.directory,
167+
}),
168+
}
169+
} catch (structuredOutputError) {
170+
logOpencodeTrace(
171+
`structured output failed; trying JSON fallback: ${structuredOutputError instanceof Error ? structuredOutputError.message : String(structuredOutputError)}`
159172
)
160-
}
161173

162-
return {
163-
summary: parsedOutput.data.summary,
164-
requirements: parsedOutput.data.requirements,
165-
opencodeSession: await collectOpencodeSessionSnapshot({
166-
sessionId: extractOpencodeSessionId(fallbackResponse),
167-
port: options.port,
168-
directory: options.directory,
169-
}),
174+
const fallbackResponse = await runTracedOpencodeCall(
175+
tracer,
176+
'generateText:json-fallback',
177+
() =>
178+
generateText({
179+
model: judgeModel,
180+
prompt: options.prompt,
181+
system: JSON_FALLBACK_SYSTEM_PROMPT,
182+
abortSignal: AbortSignal.timeout(options.timeout),
183+
})
184+
)
185+
186+
tracer.noteSessionId(
187+
extractOpencodeSessionId(fallbackResponse)
188+
)
189+
190+
const parsedOutput = parseJudgeOutputFromText(fallbackResponse.text)
191+
if (!parsedOutput.success) {
192+
const originalMessage =
193+
structuredOutputError instanceof Error
194+
? structuredOutputError.message
195+
: String(structuredOutputError)
196+
throw new Error(
197+
`judge did not return valid requirement output (structured output failed: ${originalMessage})`
198+
)
199+
}
200+
201+
return {
202+
summary: parsedOutput.data.summary,
203+
requirements: parsedOutput.data.requirements,
204+
opencodeSession: await collectOpencodeSessionSnapshot({
205+
sessionId: extractOpencodeSessionId(fallbackResponse),
206+
port: options.port,
207+
directory: options.directory,
208+
}),
209+
}
170210
}
211+
} finally {
212+
tracer.stop()
213+
await dispose()
171214
}
172215
}

0 commit comments

Comments
 (0)