@zocomputer/agent-sdk 0.5.0

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
package/README.md ADDED
@@ -0,0 +1,673 @@
1
+ # @zocomputer/agent-sdk
2
+
3
+ A standard library for [eve](https://eve.dev) agents that work on a real
4
+ filesystem: the workspace toolset (`read`, `edit`, `write`, `glob`, `grep`,
5
+ `bash`, `webfetch`), background-task orchestration, and rich-filetype reads
6
+ (PDF, DOCX, spreadsheets), wired in one call.
7
+
8
+ We build [Zo](https://zo.computer), where published cloud agents run on eve —
9
+ this SDK is the toolset we give them, extracted from the coding agent we use
10
+ on our own repo. It's deliberately generic: nothing in it assumes Zo, and every
11
+ tool factory and helper module is exported à la carte for eve projects that
12
+ want a subset.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add @zocomputer/agent-sdk@github:zocomputer/agent-sdk#v0.4.0
18
+ ```
19
+
20
+ Each release is a `v<version>` tag on this repo; pin one. (The npm publish
21
+ isn't bootstrapped yet — until it is, this repo is the registry.)
22
+
23
+ `eve`, `zod`, and `ai` are peer dependencies. Runtime imports load built JS
24
+ from `dist/` (Node won't load raw TS out of `node_modules`); types resolve
25
+ straight from the TypeScript source shipped alongside it.
26
+
27
+ ## Quick start
28
+
29
+ eve auto-loads `agent/tools/*.ts` and `agent/instructions/*.ts` by filename —
30
+ the tool file's **name is the wire name** the model sees. So you build the
31
+ stdlib once, then add one tiny re-export file per tool. Steps 1–5 below are the
32
+ full prescription; copy it verbatim and you have the complete toolset.
33
+
34
+ ### 1. Build the stdlib once
35
+
36
+ ```ts
37
+ // agent/lib/stdlib.ts
38
+ import { createStdlib } from "@zocomputer/agent-sdk";
39
+
40
+ export const stdlib = createStdlib({
41
+ workspaceRoot: process.env.MY_WORKDIR ?? process.cwd(),
42
+ stateDir: ".agent", // tasks.json + spilled tool output — gitignore it
43
+ workspaceNoun: "repo", // what tool descriptions call the workspace
44
+ });
45
+ ```
46
+
47
+ ### 2. Re-export each tool as its own file
48
+
49
+ One file per tool; the filename is the name the model calls. Create all of
50
+ these under `agent/tools/`:
51
+
52
+ ```ts
53
+ // agent/tools/read.ts (repeat for edit, write, glob, grep, bash, webfetch)
54
+ import { stdlib } from "../lib/stdlib";
55
+ export default stdlib.tools.read;
56
+ ```
57
+
58
+ | file | export | model sees |
59
+ | ------------- | --------------------- | --------------------- |
60
+ | `read.ts` | `stdlib.tools.read` | `read` |
61
+ | `edit.ts` | `stdlib.tools.edit` | `edit` |
62
+ | `write.ts` | `stdlib.tools.write` | `write` |
63
+ | `glob.ts` | `stdlib.tools.glob` | `glob` |
64
+ | `grep.ts` | `stdlib.tools.grep` | `grep` |
65
+ | `bash.ts` | `stdlib.tools.bash` | `bash` |
66
+ | `webfetch.ts` | `stdlib.tools.webfetch` | `webfetch` |
67
+ | `tasks.ts`\* | `stdlib.tools.tasks` | `run_async`, `check_tasks`, `await_task` |
68
+
69
+ \* The task tools are a **bundle** — one file exports all three, so its own
70
+ filename is free (rib calls it `parallel.ts`):
71
+
72
+ ```ts
73
+ // agent/tools/tasks.ts
74
+ import { stdlib } from "../lib/stdlib";
75
+ export default stdlib.tools.tasks; // run_async + check_tasks + await_task
76
+ ```
77
+
78
+ ### 3. Vacate the eve built-ins you're replacing
79
+
80
+ eve injects every built-in tool whose name you don't override or disable. The
81
+ rule:
82
+
83
+ - **Same name → automatic override.** `bash.ts` above already replaces eve's
84
+ built-in `bash`; nothing else to do.
85
+ - **Different name → disable the built-in** so the model doesn't see two file
86
+ readers/writers. The stdlib uses the Claude Code / opencode names (`read`,
87
+ `write`), so shim out eve's `read_file` and `write_file`:
88
+
89
+ ```ts
90
+ // agent/tools/read_file.ts (and agent/tools/write_file.ts)
91
+ import { disableTool } from "eve/tools";
92
+ export default disableTool();
93
+ ```
94
+
95
+ ### 4. Register the instructions
96
+
97
+ The stdlib ships the operational prose alongside the tools — the workflow,
98
+ communication, and HITL contracts that make a coding agent behave well, not
99
+ just the file operations. One re-export file per instruction under
100
+ `agent/instructions/`:
101
+
102
+ ```ts
103
+ // agent/instructions/workflow.ts — explore→read→edit→verify + the end-of-turn check
104
+ import { stdlib } from "../lib/stdlib";
105
+ export default stdlib.instructions.workflow;
106
+ ```
107
+
108
+ | file | export | teaches |
109
+ | --------------------- | --------------------------------- | --------------------------------------------------- |
110
+ | `workflow.ts` | `stdlib.instructions.workflow` | explore before edit, read before edit, verify, todo tracking, finish before ending the turn |
111
+ | `communication.ts` | `stdlib.instructions.communication` | lead with the outcome, readable over brief, report-don't-fix, act without permission-seeking |
112
+ | `hitl.ts` | `stdlib.instructions.hitl` | the `ask_question` playbook — options, `style: "primary"`, `allowFreeform`, ask independent questions together |
113
+ | `parallel-tools.ts` | `stdlib.instructions.parallelTools` | background tasks, `notify` watchers, await-before-ending |
114
+ | `repo-conventions.ts` | `stdlib.instructions.repoConventions` | injects the workspace's root `AGENTS.md` |
115
+ | `subagents.ts` | `stdlib.instructions.subagents` | delegation with eve's built-in `agent` tool |
116
+
117
+ Persona stays yours: the stdlib ships operational contracts, not voice — write
118
+ your agent's identity as your own instruction file (see the example's
119
+ `coder.ts`).
120
+
121
+ ### 5. Register the park-delivery hook
122
+
123
+ One hook file makes `read` media actually reach the model (see
124
+ [Media reads](#media-reads-images-video-audio)) and delivers background-task
125
+ notifications (see [Tool behavior](#tool-behavior)):
126
+
127
+ ```ts
128
+ // agent/hooks/park-delivery.ts
129
+ import { createParkDeliveryHook } from "@zocomputer/agent-sdk";
130
+ export default createParkDeliveryHook();
131
+ ```
132
+
133
+ (If you enable [Steering](#steering-mid-turn-messages), pass the same inbox dir
134
+ here: `createParkDeliveryHook({ steer: { dir } })`.)
135
+
136
+ That's the whole setup. Everything is also exported à la carte
137
+ (`createReadTool`, `createCommandRunner`, …) if you'd rather compose a subset.
138
+
139
+ ### 6. Optional: declare model-tier task subagents
140
+
141
+ A generic delegation worker: a full-capability copy of your agent pinned to a
142
+ model the *caller* chooses. Eve has no per-call model parameter — a subagent
143
+ tool's input is fixed at `{ message, outputSchema? }` and its model compiles
144
+ from its `agent.ts` — so the model knob is **one declared subagent per tier**
145
+ (`task_fast`, `task_deep`, …): the parent picks a model by picking a tool, and
146
+ each tool's description carries that model's identity and routing guidance.
147
+
148
+ ```ts
149
+ // agent/subagents/task_fast/agent.ts — the tier's identity + pinned model
150
+ import { createTaskAgent } from "@zocomputer/agent-sdk";
151
+ export default createTaskAgent({
152
+ model: "anthropic/claude-sonnet-5",
153
+ modelName: "Claude Sonnet 5",
154
+ modelBlurb: "…", // the model's catalog description, checked in (see below)
155
+ use: "Prefer it for quick, well-scoped subtasks — exploration, focused questions, mechanical edits — where a fast, cheap model is enough.",
156
+ workspaceNoun: "repo",
157
+ });
158
+
159
+ // agent/subagents/task_fast/instructions/task.ts — the child's operating contract
160
+ import { createTaskInstruction } from "@zocomputer/agent-sdk";
161
+ export default createTaskInstruction({ workspaceNoun: "repo" });
162
+
163
+ // agent/subagents/task_fast/tools/bash.ts — one re-export per PARENT tool
164
+ export { default } from "../../../tools/bash";
165
+
166
+ // agent/subagents/task_fast/tools/read.ts — EXCEPT read/webfetch, which use
167
+ // attach-disabled child instances: no park-delivery hook runs in a child, so
168
+ // the parent's attachment-enabled tools would promise media that never arrives
169
+ import { taskChildTools } from "../lib/child-tools"; // your createTaskChildTools(...) instance
170
+ export default taskChildTools.read;
171
+ ```
172
+
173
+ **The critical part: a declared subagent inherits nothing from the root.** An
174
+ absent `tools/` slot falls back to eve's *framework defaults*, not your
175
+ authored tools — so "same tools as the parent" must be constructed: one
176
+ re-export file per parent tool (parent disable shims included), minus any
177
+ parent-session-coupled tools you exclude, plus a `disableTool()` shim per
178
+ `TASK_DISABLED_BUILTINS` entry (just `ask_question`: a parked child parks the
179
+ parent's turn, so the task contract is decide-and-report). Do **not** shim the
180
+ `agent` clone tool: eve injects it at the harness layer rather than as a
181
+ framework tool, so a shim for it fails runtime agent-graph resolution and
182
+ breaks every session; the task instruction bounds onward delegation instead
183
+ (see the maintainers notes below):
184
+
185
+ ```ts
186
+ // agent/subagents/task_fast/tools/ask_question.ts
187
+ import { disableTool } from "eve/tools";
188
+ export default disableTool();
189
+ ```
190
+
191
+ Add a test that diffs each tier's `tools/` directory against
192
+ `expectedTaskToolNames({ parentToolNames, excludedParentTools })`, so a parent
193
+ tool added without a re-export (or a forgotten shim) fails CI instead of
194
+ shipping a child whose tool surface differs from what its description says.
195
+
196
+ Model blurbs come from the AI Gateway model catalog — the same public catalog
197
+ the AI SDK's `gateway.getAvailableModels()` reads — via
198
+ `fetchGatewayModelCatalog()` in a **one-shot refresh script**, and are checked
199
+ in. Never fetch them at agent build time: tool descriptions are part of the
200
+ cached prompt prefix and must be static and offline-safe.
201
+
202
+ Finally, tell the parent when to route to each tier — pass a roster to the
203
+ stdlib and the `subagents` instruction grows a "Choosing a subagent" section:
204
+
205
+ ```ts
206
+ const stdlib = createStdlib({
207
+ // …
208
+ subagentRoster: [
209
+ { name: "task_fast", when: "quick, well-scoped subtasks on a fast, cheap model" },
210
+ { name: "task_deep", when: "reasoning-heavy subtasks worth frontier-model cost" },
211
+ ],
212
+ });
213
+ ```
214
+
215
+ Instructions aren't inherited either — re-export the stdlib instructions the
216
+ child needs (`repoConventions`, `workflow`, `parallelTools`) beside the task
217
+ contract. Same for hooks: if your agent logs sessions via a hook, re-export it
218
+ under `agent/subagents/task_fast/hooks/` or child sessions won't be recorded.
219
+
220
+ ## Example
221
+
222
+ [`examples/coder`](./examples/coder) is a complete, minimal eve coding agent
223
+ built on this stdlib — the six steps above as real files: the full toolset,
224
+ the `read_file`/`write_file` shims, the six instructions, the park-delivery
225
+ hook, a `task_fast` model-tier subagent, and a one-file coder persona. Point
226
+ it at a project and run it:
227
+
228
+ ```sh
229
+ cd examples/coder
230
+ bun install
231
+ CODER_WORKDIR=/path/to/project AI_GATEWAY_API_KEY=… bun dev
232
+ ```
233
+
234
+ The coder is also this package's **end-to-end test agent**: `bun run eval`
235
+ (in `examples/coder`) runs [`evals-mock/`](./examples/coder/evals-mock) — ten
236
+ deterministic evals that drive the prescribed wiring through a real eve server
237
+ on the mock model (see [Mock model](#mock-model-credential-free-testing)),
238
+ with zero credentials. Park/resume on `ask_question`, two parallel questions
239
+ pending on one park, the todo write/update order, real task_fast delegation, a
240
+ visible `turn.failed` on an injected stream error, and the stream-shape
241
+ scenarios. Copy the pattern (`scripts/eval.ts` + `evals-mock/`) to give your
242
+ own agent the same CI-friendly suite.
243
+
244
+ ## Mock model (credential-free testing)
245
+
246
+ `createMockStoryModel()` is a scripted `LanguageModelV4` that turns the whole
247
+ eve stack into a deterministic test rig: session routes, the harness, framework
248
+ tools (`ask_question`, `todo`), declared subagents, and durable streams all run
249
+ REAL — only inference is canned. Gate it behind an env flag in `agent.ts` and
250
+ never set that flag in a normal run:
251
+
252
+ ```ts
253
+ // agent/agent.ts
254
+ import { defineAgent } from "eve";
255
+ import { createMockStoryModel } from "@zocomputer/agent-sdk";
256
+
257
+ export default function agent() {
258
+ if (process.env.MY_AGENT_MOCK_MODEL === "1") {
259
+ return defineAgent({ model: createMockStoryModel() });
260
+ }
261
+ return defineAgent({ model: "anthropic/claude-opus-4.8" });
262
+ }
263
+ ```
264
+
265
+ (The coder example wires this as `CODER_MOCK_MODEL=1`; rib as
266
+ `RIB_MOCK_MODEL=1`.)
267
+
268
+ A turn with no directive streams a long, paced deterministic story — a turn
269
+ that stays in-flight exactly as long as your test needs (`chunkCount` ×
270
+ `chunkDelayMs`), with the asking prompt echoed into the output so parallel
271
+ chats stay distinguishable. A `[mock:<scenario>]` directive in the user
272
+ message scripts the turn instead:
273
+
274
+ | Directive | What it drives |
275
+ | --- | --- |
276
+ | `[mock:hitl]` | One `ask_question` call (styled options + freeform) → park → answer → wrap-up. |
277
+ | `[mock:parallel]` | TWO `ask_question` calls in one response — both pend on a single park; one respond resumes. |
278
+ | `[mock:todo]` | Writes a 4-item todo list, then updates it (completed/cancelled), then wraps up. |
279
+ | `[mock:delegate]` | Delegates to a declared subagent (default tool name `task_fast` — requires one; see step 6). |
280
+ | `[mock:fail]` | A few deltas, then a terminal stream error — the deterministic failed-turn trigger. |
281
+ | `[mock:burst]` | `burstChunks` unpaced deltas — the renderer-throughput probe. |
282
+ | `[mock:markdown]` | Structure-heavy markdown split across deltas (fences, tables, unicode) — streaming-renderer stability. |
283
+ | `[mock:interleave]` | Alternating reasoning and text blocks in one message, like extended-thinking models stream. |
284
+ | `[mock:empty]` | A completion with zero content parts. |
285
+
286
+ Scripted tool inputs stream as fragmented `tool-input-delta` parts (like a
287
+ real model), each scripted step opens with a reasoning burst so "Thinking…"
288
+ renders, and every stream — including aborted ones — is grammatical (blocks
289
+ close, a terminal part ends the stream; pinned by the package's conformance
290
+ tests, which also validate the scripted `ask_question`/`todo` inputs against
291
+ the installed eve's own framework-tool schemas). Inject `now` for
292
+ byte-deterministic streams. Because the mock is credential-free, `eve eval`
293
+ suites built on it can run end-to-end in CI — the coder example's
294
+ [`evals-mock/`](./examples/coder/evals-mock) suite (run via its
295
+ `scripts/eval.ts`) is the reference setup.
296
+
297
+ ## Tool behavior
298
+
299
+ The names are deliberately boring; the behavior behind them is the point:
300
+
301
+ - **`read`** is multi-format — line-numbered text windows plus content-sniffed
302
+ PDF (PDFium via `clawpdf`), DOCX (`mammoth`), and spreadsheet (`.xlsx`/
303
+ `.xlsm`/`.xls`/`.ods` via SheetJS, TSV per sheet) → text, and UTF-16 BOM
304
+ decode. Reading an **image** returns metadata and queues the pixels to appear
305
+ as a viewable attachment on the next turn; **video/audio** reads return
306
+ metadata (format, MIME type, bytes) and can queue the same way where the
307
+ model supports it (see [Media reads](#media-reads-images-video-audio)).
308
+ No-extractor formats fail with a named, actionable error; extraction is cached
309
+ by path + stat. The first read under a directory with its own `AGENTS.md`
310
+ attaches that file to the result (`directory_conventions`), **once per
311
+ directory per session** — nested conventions arrive exactly when the model
312
+ enters the directory, instead of hoping it remembers to read them. The root
313
+ file is excluded (`instructions.repoConventions` already injects it); riders
314
+ are result content, so the prompt prefix stays byte-stable. Opt out with
315
+ `injectDirConventions: false`; rename the file with `conventionsFileName`.
316
+ - **`glob` / `grep`** prefer git-tracked candidates (`git ls-files`), falling
317
+ back to a filesystem walk outside a repo, with bounded result counts.
318
+ - **`bash`** waits briefly, then auto-backgrounds a still-running command
319
+ (returns a `task_id`); oversized output spills to `stateDir` instead of
320
+ flooding the context window.
321
+ - **`webfetch`** returns a page as markdown (default), plain text, or raw
322
+ HTML. HTML is reduced to its **main content** under a title/byline header
323
+ (`defuddle` extraction, with a guard that falls back to the full page when
324
+ extraction over-prunes), and the result is honest about failure: a page that
325
+ yields almost no text gets a note saying so (with a hint for known
326
+ client-rendered/login-walled domains like X or Reddit), and a conversion
327
+ that leaves raw HTML flags itself. Fetched PDFs/DOCX/spreadsheets route
328
+ through the same extractors as `read` (`.pdf` URLs get a longer default
329
+ timeout); images return metadata and attach to the chat like `read`;
330
+ oversized bodies spill to `stateDir`.
331
+ - **`run_async` / `check_tasks` / `await_task`** persist the task registry
332
+ across restarts (tasks running across a restart report as `lost`); any
333
+ `defineOp` op becomes `run_async`-able via `extraBackgroundables`.
334
+ - **Background notifications**: `bash` and `run_async` take an optional
335
+ `notify` watcher (`{ pattern, reason }`) — output lines matching the regex
336
+ (debounced, capped) are delivered to the model as a message while the
337
+ session is idle, instead of it polling `check_tasks`; `run_async` also takes
338
+ `notify_on_complete` for a settle notice. Delivery rides the park-delivery
339
+ hook (Quick start step 5): notifications queue until the session parks and
340
+ then start its next turn, exactly like a user message.
341
+
342
+ ## Sandbox-backed file tools (split topologies)
343
+
344
+ `createStdlib`'s file tools do `node:fs` against the process's own disk —
345
+ right when the eve process and the workspace share a machine (a local coding
346
+ agent, the coder example). On a **split topology** — eve on a serverless
347
+ function, the workspace in a remote sandbox (`ctx.getSandbox()`) — that would
348
+ read the harness's filesystem, not the workspace. For that case the same
349
+ tools run over the sandbox session:
350
+
351
+ ```ts
352
+ // agent/lib/file-tools.ts
353
+ import { createSandboxFileTools } from "@zocomputer/agent-sdk";
354
+
355
+ export const fileTools = createSandboxFileTools({
356
+ workspaceRoot: "/workspace", // absolute path INSIDE the sandbox
357
+ spillDir: "/workspace/.agent/tool-outputs", // grep overflow, readable by `read`
358
+ });
359
+ // then re-export fileTools.tools.read / edit / write / glob / grep per file,
360
+ // with disableTool() shims for eve's read_file/write_file (glob/grep shadow
361
+ // the built-ins by name), exactly like the Quick start.
362
+ ```
363
+
364
+ Every effect routes through the session sandbox, resolved per tool call:
365
+ bytes over `readBinaryFile`/`writeBinaryFile`, stat/list/search executed
366
+ remotely via `run` (ripgrep when present, POSIX grep fallback) so a search
367
+ never pulls file contents over the wire. The rich-read pipeline (extraction,
368
+ media detection, attachments, `AGENTS.md` riders) is byte-identical to the
369
+ local backend — a shared conformance suite pins the two together. `bash` and
370
+ the task machinery stay host-side by design: on a sandboxed runtime, keep
371
+ eve's built-in `bash` (already sandbox-native).
372
+
373
+ Under the hood this is one seam: every file tool takes an `io:
374
+ WorkspaceIoProvider` (default local `node:fs`), and `createSandboxIo` /
375
+ `sandboxIoProvider` implement it over a structural `SandboxSessionLike` that
376
+ eve's `SandboxSession` satisfies. A custom backend (e.g. a bootstrap step
377
+ before first use) plugs in via `resolveSession`.
378
+
379
+ One default flips versus `createStdlib`: `attachImagesToChat` is **false**
380
+ here. The attachment path needs the park-delivery hook and a runtime that can
381
+ send itself the next-turn message over loopback — unvalidated on hosted
382
+ serverless runtimes — so until a consumer wires and verifies that leg, image
383
+ reads return the honest metadata-only note instead of a "queued" promise that
384
+ never delivers.
385
+
386
+ ## Design rules
387
+
388
+ The full rationale — each foundational decision, why we made it, and the
389
+ prior art it came from (Claude Code, opencode, openclaw, hermes, pi, Cursor,
390
+ and Zo v1's hostagent) — lives in
391
+ [`design/foundation/`](./design/foundation/00-overview.md). The short
392
+ version:
393
+
394
+ - **Prompt-cache stability.** Tool descriptions and dynamic instructions are
395
+ built once per session (`"session.started"`) and stay byte-identical
396
+ thereafter; live state rides tool results, never a description. Options like
397
+ `workspaceNoun` interpolate at build time.
398
+ - **Prior-aligned naming.** Tool names and parameters follow what models
399
+ already know from Claude Code and opencode: lowercase `read`/`edit`/`write`/
400
+ `glob`/`grep`/`bash`, snake_case params, `path` not `file_path`. Echo-back
401
+ keys mirror the params that consume them (`task_id`).
402
+ - **Workspace-scoped.** Every file tool resolves paths inside
403
+ `workspaceRoot` and refuses escapes.
404
+ - **No house types.** The package imports nothing repo-specific — plain
405
+ discriminated unions, `eve` + `zod` as peers, WASM/pure-JS extraction deps
406
+ (no native postinstalls).
407
+
408
+ ## Media reads (images, video, audio)
409
+
410
+ eve tool results are text/json only, so `read` can't hand the model an image
411
+ directly. The workaround: for media under the inline cap, `read` embeds the
412
+ bytes as a `data:` URL on its **raw** result under a model-hidden field, and
413
+ its `toModelOutput` strips that field. The model sees only metadata + a note;
414
+ the **park-delivery hook** (`createParkDeliveryHook`, one file in
415
+ `agent/hooks/` — Quick start step 5) watches the runtime stream from inside
416
+ the agent's own server process and, when the session parks, sends the media
417
+ back into the session as a real user turn over loopback. The model sees the
418
+ pixels on its next turn with no browser, cockpit, or user action involved.
419
+ (The same hook delivers background-task notifications — see
420
+ [Tool behavior](#tool-behavior).)
421
+
422
+ - eve hooks are observe-only for model context, so the hook doesn't mutate the
423
+ current turn — it starts the next one, exactly like a user hitting send.
424
+ Delivery is deduped per tool call, retried briefly on a racing send, and
425
+ re-queued for the next park if it still fails.
426
+ - The contract + a dependency-free reader live at
427
+ **`@zocomputer/agent-sdk/attachments`** (`readChatAttachment(output)` →
428
+ `ChatAttachment | null`, kinds `image`/`video`/`audio`), so UI clients that
429
+ want to render or track the attachments import it without the extraction
430
+ deps. The pure decision core (`redeliveryFromEvent`, `createRedeliveryState`,
431
+ `buildRedeliveryMessage`) is exported for hosts that would rather run
432
+ delivery elsewhere.
433
+ - **Images** attach by default: `attachImagesToChat` (default `true`) and
434
+ `maxInlineImageBytes` (default 3 MiB — eve's attachment staging inlines
435
+ images up to that size at model-call time and text-stubs bigger ones, so the
436
+ cap keeps the "queued" promise truthful; larger images fall back to the
437
+ metadata-only "ask the user" note).
438
+ - **Video/audio are opt-in**: `attachVideoToChat` / `attachAudioToChat`
439
+ (default `false`) and `maxInlineMediaBytes` (default 10 MB, read's stat
440
+ guard). Two gates must hold before enabling them: your **model** takes that
441
+ medium (Gemini accepts video/audio file parts; Claude and most others don't
442
+ — an unsupported part fails the delivery turn), and your **runtime** passes
443
+ them through (eve's attachment staging currently hydrates only images ≤3 MiB
444
+ and PDFs ≤20 MiB back into the model call; anything else becomes an
445
+ "Attached file …" text stub — see the eve-maintainer notes below). Until
446
+ both hold, video/audio reads return honest metadata + a note steering to
447
+ bash extraction (e.g. ffmpeg frames read back as images).
448
+ - `createParkDeliveryHook`'s `serverUrl` (defaults to loopback on `$PORT`, eve
449
+ dev's 2000 otherwise) and `log`. An agent that skips the hook simply gets
450
+ the metadata note (the bytes ride the stream unused — turn inlining off with
451
+ `attachImagesToChat: false`).
452
+
453
+ ## Steering (mid-turn messages)
454
+
455
+ eve queues a message sent to a busy session until the turn ends — hooks are
456
+ observe-only and a mid-turn `send()` is rejected, so there's no framework
457
+ channel into a running turn. The SDK's channel rides the tool results:
458
+
459
+ - **Enable it with `createStdlib({ steer: { dir } })`.** The stdlib builds a
460
+ steer **inbox** — one NDJSON file per session under `dir` (exposed as
461
+ `stdlib.steerInbox`) — and wraps every stdlib tool so a completing call
462
+ drains the inbox and attaches the queued messages to its result under
463
+ `user_steer`, with a note telling the model to adjust course now. On a long
464
+ turn, `await_task` is the highest-value delivery window.
465
+ - **A UI queues a steer by appending to the inbox**:
466
+ `createSteerInbox({ dir }).append(sessionId, text)`
467
+ (`@zocomputer/agent-sdk/steer-inbox`), typically behind a small HTTP route.
468
+ Drain-vs-append races are safe (rename-first drain).
469
+ - **Wrap your own tools** with `createSteerWrapper(stdlib.steerInbox)` (or
470
+ `withSteerDelivery(tool, inbox)`) so they deliver steers too.
471
+ - **Messages that miss every tool window drain on park**: with
472
+ `createParkDeliveryHook({ steer: { dir } })` (Quick start step 5), leftovers
473
+ start the session's next turn — delivered first, verbatim.
474
+ - **The wire contract is dependency-free at `@zocomputer/agent-sdk/steer`**
475
+ (`STEER_FIELD`, `SteerMessage`, `readSteerMessages`, …), so UI clients can
476
+ project delivered steers into user-message bubbles without pulling in the
477
+ extraction deps.
478
+
479
+ ## Gateway stream guards (surviving a dead connection)
480
+
481
+ Neither eve's `defineAgent` nor the AI SDK's gateway provider exposes
482
+ per-attempt timeouts, so a model call that hangs — response headers never
483
+ arrive, or the SSE body goes quiet mid-stream on a dropped connection —
484
+ hangs the turn forever. The one seam the provider does expose is `fetch`;
485
+ `withStreamGuards` (`@zocomputer/agent-sdk/gateway-fetch`) wraps it with the
486
+ two guards a streaming call needs:
487
+
488
+ - **first byte** — abort when response headers don't arrive in time;
489
+ - **idle** — abort when the response body goes quiet between chunks (a dead
490
+ connection the TCP stack never surfaces).
491
+
492
+ A guard firing rejects like any network failure, so the AI SDK's normal
493
+ retry-with-backoff takes over instead of waiting on a dead socket:
494
+
495
+ ```ts
496
+ import { createGateway } from "ai";
497
+ import { withStreamGuards } from "@zocomputer/agent-sdk/gateway-fetch";
498
+
499
+ const gateway = createGateway({ fetch: withStreamGuards(fetch) });
500
+ export default defineAgent({ model: gateway("anthropic/claude-sonnet-5") });
501
+ ```
502
+
503
+ The defaults (60s to first byte, 180s idle) are deliberately generous: the
504
+ point is converting a *dead* connection into a retryable error, not racing a
505
+ slow-but-alive reasoning model. Override via the second argument
506
+ (`{ firstByteMs, idleMs }`).
507
+
508
+ ## Zo platform modules (`platform/`)
509
+
510
+ Everything above is the generic stdlib — nothing in it assumes Zo. The
511
+ published artifact additionally vendors the Zo platform packages under
512
+ `platform/`, exposed as subpath exports, so an agent deployed on Zo installs
513
+ its whole harness from this one dependency:
514
+
515
+ | Import | What it is |
516
+ | --- | --- |
517
+ | `@zocomputer/agent-sdk/sandbox` | `zoSandbox()` — the Zo sandbox backend for eve's `agent/sandbox.ts` slot. The runtime holds no provider key; it asks the Zo control plane (`ZO_API_URL`, authenticated by `ZO_AGENT_TOKEN`) for a scoped, short-lived SSH session. |
518
+ | `@zocomputer/agent-sdk/ai` (+ `/ai/gateway`, `/ai/register`, `/ai/session-fetch`) | The Zo AI provider layer. `import "@zocomputer/agent-sdk/ai/register"` (first in `agent.ts`) points the AI SDK's default provider at Zo's metering gateway so bare catalog model slugs work. |
519
+ | `@zocomputer/agent-sdk/cloud-tools` (+ `/image`, `/web-search`) | The default Zo cloud tools: `generate_image` and the Exa web-search factory, built on the gateway. |
520
+ | `@zocomputer/agent-sdk/runtime-auth` | The agent-token contract (header/env names, mint/verify) shared with the Zo control plane. |
521
+
522
+ These modules assume Zo's control plane and are inert elsewhere; `ai` joins
523
+ `eve`/`zod` as a peer dependency (the `/ai/register` side effect must mutate
524
+ *your* `ai` instance), and `platform/agent-sandbox` brings `ssh2` (its native
525
+ addon is optional — the pure-JS fallback is fine).
526
+
527
+ In the [Zo monorepo](https://github.com/zocomputer/zov2-code) these are
528
+ separate workspace packages; the mirror sync composes them into this one
529
+ package so a deployed agent's `package.json` needs a single
530
+ `github:zocomputer/agent-sdk#<ref>` (or npm) dependency.
531
+
532
+ ## Notes for the eve maintainers
533
+
534
+ Gaps we hit building this — each is something we'd rather see upstream than
535
+ keep working around:
536
+
537
+ - **Multimodal tool results.** `ToolModelOutput` is `text | json` only, so
538
+ `read` can't return an image directly — we work around it by smuggling the
539
+ bytes past on the raw result and having a hook send them back as the next
540
+ user turn (see [Media reads](#media-reads-images-video-audio)), which costs
541
+ an extra turn and an extra copy of the bytes in the durable stream.
542
+ `@workflow/ai`'s DurableAgent already merged multimodal tool-result
543
+ pass-through (`type: "content"`, vercel/workflow#848 → #1385); exposing that
544
+ through eve's tool surface would let `read` return real image blocks and
545
+ delete the whole workaround. We've worked the design to change-list
546
+ precision — including the storage-independent persistence policy (stub-first
547
+ history + an in-process byte cache, so the model sees media the turn it read
548
+ them and degrades gracefully to a text stub across process boundaries) and
549
+ the per-provider degrade table it needs (Anthropic's tool-result converter
550
+ warn-drops non-image/PDF media; OpenAI chat completions would stringify
551
+ base64 — the opencode blowup) — in
552
+ [`design/proposals/eve-content-tool-results.md`](./design/proposals/eve-content-tool-results.md).
553
+ - **Attachment hydration is image/PDF-only.** The staging pipeline
554
+ (`attachment-staging.ts`) stages every inbound file part to the sandbox, but
555
+ `shouldInlineSandboxRefAsBytes` re-inlines only `image/*` ≤3 MiB and
556
+ `application/pdf` ≤20 MiB at model-call time — every other media type
557
+ (video, audio) hydrates as an `Attached file …` text stub even when the
558
+ session's model accepts it. That silently blocks video/audio delivery on any
559
+ sandboxed runtime (the user-turn workaround included), while the AI SDK
560
+ underneath is ready: `@ai-sdk/google` converts any file part to `inlineData`
561
+ and Gemini natively takes video/audio (Anthropic's converter throws
562
+ `UnsupportedFunctionalityError` on them, so a *blind* widening would turn
563
+ today's stub into a failed model call). The fix that threads that needle is
564
+ model-aware hydration — detect the provider family from the already-resolved
565
+ model (the `detectPromptCachePath` idiom) and inline video/audio ≤20 MiB for
566
+ the google family only, keeping the text-stub fallback for everyone else. We
567
+ built and tested exactly that patch against `vercel/eve` (all suites green):
568
+ the PR-grade writeup is
569
+ [`design/proposals/eve-hydrate-model-aware-media.md`](./design/proposals/eve-hydrate-model-aware-media.md)
570
+ and the DCO-signed patch sits beside it
571
+ (`eve-hydrate-media-support.patch`). Filed upstream as
572
+ [vercel/eve#543](https://github.com/vercel/eve/issues/543); the PR follows
573
+ on maintainer go-ahead. It would make `read`'s opt-in video/audio
574
+ attachments (`attachVideoToChat`/`attachAudioToChat`) work end-to-end.
575
+ - **HITL replay.** eve persists `input.requested` but not the client's
576
+ `input.responded`, so a replayed session reopens answered prompts as
577
+ pending. We append synthetic responded-events from client-side storage;
578
+ persisting the response (or accepting it into the durable stream) would fix
579
+ every client at once.
580
+ - **`ask_question` multi-select.** The input-request contract carries rich
581
+ options (`id`/`label`/`description`/`style`) but the response carries a
582
+ single `optionId` — there's no way to ask "pick all that apply."
583
+ `allowMultiple` on the request plus `optionIds` on the response would
584
+ complete the surface; clients render checkboxes instead of buttons when
585
+ it's set.
586
+ - **Tool naming + config-level disable.** The built-ins ship off-prior names
587
+ (`read_file`, `write_file`), and vacating one requires a `disableTool()`
588
+ shim file per name. Prior-aligned defaults — or a config switch to disable
589
+ built-ins wholesale — would remove the shims.
590
+ - **No per-tool `strict` / `providerOptions` passthrough.** Anthropic's
591
+ `strict: true` (grammar-constrained sampling — the documented fix for
592
+ newer Claude models garbling off-prior tool schemas) is a per-tool flag
593
+ the AI SDK already forwards to providers, but `buildToolSet` constructs
594
+ `tool()` without it, so no eve agent can enable it. Accepting `strict`
595
+ (and per-tool `providerOptions`) on `defineTool` and passing them through
596
+ would make it a one-line opt-in; the change-list design is
597
+ [`design/proposals/eve-strict-tool-passthrough.md`](./design/proposals/eve-strict-tool-passthrough.md).
598
+ Same seam: `experimental_repairToolCall` is unwired.
599
+ - **Invalid tool calls never reach the event stream.** When a call fails
600
+ schema validation the AI SDK marks it `invalid` and feeds the error back
601
+ to the model, but `emitStreamContent` and `emitStepActions` both skip
602
+ invalid calls — no event is emitted, so clients can't render the retry
603
+ and harnesses can't measure schema-misuse rates (the regression newer
604
+ Anthropic models show on off-prior schemas). An `action.invalid` event
605
+ with the tool name and error class closes the gap. We built and tested
606
+ exactly that patch against `vercel/eve` (all suites green): the PR-grade
607
+ writeup is
608
+ [`design/proposals/eve-invalid-tool-call-events.md`](./design/proposals/eve-invalid-tool-call-events.md)
609
+ and the DCO-signed patch sits beside it
610
+ (`eve-invalid-tool-call-events.patch`). Filed upstream as
611
+ [vercel/eve#542](https://github.com/vercel/eve/issues/542); the PR follows
612
+ on maintainer go-ahead.
613
+ - **`ask_question`'s options are the off-prior nested shape.** Its `options`
614
+ array of `.strict()` option objects is exactly the shape newer Claude
615
+ models garble (invented trailing keys after long strings), and it has no
616
+ Claude Code analog to ride. Flattening it — or at least dropping
617
+ `.strict()` so the advertised contract matches the (unvalidated) lenient
618
+ runtime — would cut the schema-slop surface every HITL agent presents.
619
+ - **The `agent` clone tool can't be disabled.** It's injected at the harness
620
+ layer (`createNodeHarnessTools`), not as a framework tool, so a
621
+ `disableTool()` shim for it fails runtime agent-graph resolution — every
622
+ session create 500s. A read-only child that should answer one question and
623
+ return has no way to vacate recursive delegation; we fall back to
624
+ instruction text. Either registering `agent` as a disableable framework
625
+ tool or honoring the shim at the harness layer would close the gap.
626
+ - **Concurrent tool calls race shared resources.** The AI SDK loop runs a
627
+ step's tool calls with `Promise.all`, so two mutating calls against the same
628
+ file in one step race their read-modify-write sections — both report
629
+ success and the second write silently drops the first edit. Models batch
630
+ same-file edits because batching independent work is exactly what we teach
631
+ them, and the lost update is invisible at the tool boundary (a live sweep
632
+ burned ~8 steps rediscovering one through a lint error). Our `edit`/`write`
633
+ serialize per resolved path with a `globalThis`-anchored FIFO lock
634
+ ([`src/path-locks.ts`](./src/path-locks.ts)), but every toolset author has
635
+ to rediscover this; a declarative "serialize calls sharing this key" seam
636
+ on the tool contract — eve already knows the set of calls it's about to run
637
+ concurrently — would queue same-key calls FIFO while keeping distinct keys
638
+ parallel (zocomputer/zov2-code#337).
639
+ - **Streaming events are quadratic.** `message.appended`/`reasoning.appended`
640
+ carry the full text-so-far alongside each delta, so one turn's events sum
641
+ to O(n²) bytes — a 3,000-delta turn measures ~330 MB of payloads, and
642
+ late deltas re-send ~36 KB of prefix each. Clients that store or replay
643
+ streams must compact/thin app-side (chat-core's `stream-thinning`);
644
+ delta-only stream events would fix storage, replay, and live-wire
645
+ throughput at the source.
646
+ - **An aborted stream resets the client session.** `advanceSession` carries
647
+ the session forward only when the consumed stream ends on
648
+ `session.waiting`; an abort (Stop/Esc) therefore erases the sessionId, and
649
+ the next `send` silently creates a fresh session — forking the
650
+ conversation. Preserving the session state across aborts (the id was
651
+ known!) would remove a whole class of client-side recovery code.
652
+ - **No turn cancellation.** Aborting the client stream leaves the server-side
653
+ turn running to completion; there's no API to actually stop it. Stop
654
+ buttons today can only detach and re-attach.
655
+ - **Continuation-token scoping.** A hook's `ctx.channel.continuationToken` is
656
+ the runtime-namespaced form (`eve:eve:<uuid>`), but `ClientSession.send`
657
+ needs the client-facing token (`eve:<uuid>`) — and posting the namespaced
658
+ form doesn't error, it **silently creates a new session** (the continue
659
+ route's get-or-create). We strip the namespace (`clientContinuationToken`)
660
+ and assert the echoed session id; either surfacing the client token on
661
+ `HookContext` or rejecting unknown tokens on continue would remove the trap.
662
+ - **AGENTS.md ingestion.** eve injects no repo conventions; every other
663
+ harness (Claude Code, Cursor, Codex) reads `AGENTS.md` natively. Our
664
+ `repoConventions` instruction covers the root file, but first-class support
665
+ belongs in the framework.
666
+ - **In-history tool-output pruning.** Old tool results stay in the model
667
+ prompt verbatim for a session's lifetime. We bound tool output at the source
668
+ (spill files, result caps); a framework-level pruning/compaction hook would
669
+ do better.
670
+
671
+ ## License
672
+
673
+ [MIT](./LICENSE)