@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
@@ -0,0 +1,241 @@
1
+ import { defineDynamic, defineTool } from "eve/tools";
2
+ import { z } from "zod";
3
+ import type { Task, TaskRegistry } from "../async-tasks";
4
+ import type { BackgroundableOp } from "../backgroundable";
5
+ import { postParkNotification } from "../park-delivery";
6
+ import { createSteerWrapper, type SteerSource } from "../steer-tool";
7
+ import {
8
+ createOutputWatcher,
9
+ formatCompletionNotification,
10
+ formatWatchNotification,
11
+ } from "../watch-output";
12
+
13
+ // Async tools over the task registry. eve blocks a step until every tool call
14
+ // resolves, so run_async launches backgroundable work and returns a task id
15
+ // immediately; check_tasks peeks at progress; await_task blocks for the result
16
+ // when the agent's next step depends on it. The workflow guidance is injected
17
+ // as a dynamic instruction (createParallelToolsInstruction).
18
+
19
+ const DEFAULT_WAIT_MS = 120_000;
20
+
21
+ function elapsedMs(task: Task): number {
22
+ const end = task.status === "running" ? Date.now() : task.finishedAt;
23
+ return end - task.startedAt;
24
+ }
25
+
26
+ // Compact, non-blocking view for check_tasks — status and timing, no output.
27
+ // The id key is `task_id` to match run_async's return and await_task's param.
28
+ function peek(task: Task) {
29
+ return {
30
+ task_id: task.id,
31
+ tool: task.tool,
32
+ label: task.label,
33
+ status: task.status,
34
+ elapsedMs: elapsedMs(task),
35
+ ...(task.progress !== undefined ? { progress: task.progress } : {}),
36
+ ...(task.status === "error" || task.status === "lost" ? { error: task.error } : {}),
37
+ };
38
+ }
39
+
40
+ // Full view for await_task, including the op's result (or error).
41
+ function full(task: Task) {
42
+ const base = {
43
+ task_id: task.id,
44
+ tool: task.tool,
45
+ label: task.label,
46
+ status: task.status,
47
+ elapsedMs: elapsedMs(task),
48
+ };
49
+ if (task.status === "done") return { ...base, result: task.result };
50
+ if (task.status === "error" || task.status === "lost") return { ...base, error: task.error };
51
+ return { ...base, ...(task.progress !== undefined ? { progress: task.progress } : {}) };
52
+ }
53
+
54
+ /**
55
+ * Build the {run_async, check_tasks, await_task} toolset. Exported with its
56
+ * concrete types for direct testing; agents wire it through createTasksTools
57
+ * (a `defineDynamic` erases entry types on the wire-facing surface).
58
+ */
59
+ export function buildTasksToolset(opts: {
60
+ registry: TaskRegistry;
61
+ backgroundables: readonly BackgroundableOp[];
62
+ /** When set, steered messages ride these tools' results (see ../steer-tool). */
63
+ steerInbox?: SteerSource | null;
64
+ }) {
65
+ const { registry, backgroundables } = opts;
66
+ const [firstOp, ...restOps] = backgroundables;
67
+ if (!firstOp) return null;
68
+ const toolNames: [string, ...string[]] = [firstOp.name, ...restOps.map((o) => o.name)];
69
+ const catalog = backgroundables
70
+ .map((o) => `- ${o.name}: ${o.description}\n input: ${JSON.stringify(o.inputJsonSchema)}`)
71
+ .join("\n");
72
+ // await_task is the highest-value steer window: it's where the agent blocks
73
+ // on long work, exactly when a user most wants to redirect.
74
+ const wrap = createSteerWrapper(opts.steerInbox ?? null);
75
+
76
+ return {
77
+ run_async: wrap(defineTool({
78
+ description:
79
+ "Start a tool running in the BACKGROUND and return immediately with a task id, instead of blocking until it finishes. Use it for long work whose result your next step doesn't need yet (tests, builds, installs) so you can keep working in parallel; poll with check_tasks and collect the result with await_task. If your very next step needs the output, just call the tool directly instead. For work where you only care about a specific output signal, pass notify — matching lines are delivered to you as a message while you're idle, instead of you polling.\n\nBackgroundable tools (pass `input` matching the tool's own schema):\n" +
80
+ catalog,
81
+ inputSchema: z.object({
82
+ tool: z.enum(toolNames).describe("Which backgroundable tool to run."),
83
+ input: z
84
+ .record(z.string(), z.unknown())
85
+ .describe("Arguments for that tool — the same object you'd pass calling it directly."),
86
+ notify: z
87
+ .object({
88
+ pattern: z
89
+ .string()
90
+ .min(1)
91
+ .describe("Regex matched against complete output lines."),
92
+ reason: z
93
+ .string()
94
+ .min(1)
95
+ .describe("Short phrase naming what you're watching for, e.g. 'build errors'."),
96
+ debounce_ms: z
97
+ .number()
98
+ .int()
99
+ .positive()
100
+ .optional()
101
+ .describe("Minimum ms between match notifications (default 5000)."),
102
+ })
103
+ .optional()
104
+ .describe(
105
+ "Watch the task's output: matching lines are delivered to you as a message while you're idle.",
106
+ ),
107
+ notify_on_complete: z
108
+ .boolean()
109
+ .optional()
110
+ .describe(
111
+ "Also deliver a message when the task settles (default false; await_task remains the primary way to collect results).",
112
+ ),
113
+ }),
114
+ execute({ tool, input, notify, notify_on_complete }, ctx) {
115
+ const op = backgroundables.find((o) => o.name === tool);
116
+ if (!op) throw new Error(`Unknown backgroundable tool: ${tool}`);
117
+ const sessionId = ctx?.session?.id;
118
+ // Built before start() so an invalid regex fails as a normal tool
119
+ // error instead of after the work is already running.
120
+ const watcher = notify
121
+ ? createOutputWatcher({ pattern: notify.pattern, debounceMs: notify.debounce_ms })
122
+ : null;
123
+ // The task id doesn't exist until after start(), so watcher posts
124
+ // buffer through this indirection until it's known.
125
+ let post: ((lines: readonly string[] | null) => void) | null = null;
126
+ const early: (readonly string[])[] = [];
127
+ // start() parses input against the op's schema and throws on bad input,
128
+ // so we validate before registering a task.
129
+ const { label, work, progress } = op.start(
130
+ input,
131
+ watcher
132
+ ? {
133
+ onOutput: (chunk) => {
134
+ const matches = watcher.feed(chunk);
135
+ if (!matches) return;
136
+ if (post) post(matches);
137
+ else early.push(matches);
138
+ },
139
+ }
140
+ : undefined,
141
+ );
142
+ const taskId = registry.spawnTask(tool, label, work);
143
+ if (watcher && notify && sessionId) {
144
+ let matchCount = 0;
145
+ post = (lines) => {
146
+ if (!lines || lines.length === 0) return;
147
+ matchCount += 1;
148
+ postParkNotification(sessionId, {
149
+ key: `${taskId}#watch${matchCount}`,
150
+ text: formatWatchNotification({ taskId, label, reason: notify.reason, lines }),
151
+ });
152
+ };
153
+ for (const batch of early.splice(0)) post(batch);
154
+ void work.finally(() => post?.(watcher.flush())).catch(() => undefined);
155
+ }
156
+ if (notify_on_complete && sessionId) {
157
+ void work.then(
158
+ () =>
159
+ postParkNotification(sessionId, {
160
+ key: `${taskId}#done`,
161
+ text: formatCompletionNotification({ taskId, label, status: "done" }),
162
+ }),
163
+ (err: unknown) =>
164
+ postParkNotification(sessionId, {
165
+ key: `${taskId}#done`,
166
+ text: formatCompletionNotification({
167
+ taskId,
168
+ label,
169
+ status: "error",
170
+ error: err instanceof Error ? err.message : String(err),
171
+ }),
172
+ }),
173
+ );
174
+ }
175
+ if (progress) {
176
+ registry.updateTaskProgress(taskId, progress());
177
+ const interval = setInterval(() => registry.updateTaskProgress(taskId, progress()), 500);
178
+ void work.finally(() => clearInterval(interval)).catch(() => undefined);
179
+ }
180
+ return {
181
+ task_id: taskId,
182
+ tool,
183
+ status: "running" as const,
184
+ ...(watcher ? { watching: notify?.pattern } : {}),
185
+ note: "Started in the background. If your next actions don't depend on this, keep working and call check_tasks / await_task later; otherwise call await_task now.",
186
+ };
187
+ },
188
+ })),
189
+
190
+ check_tasks: wrap(defineTool({
191
+ description:
192
+ "List background tasks and their status without blocking; returns `runningCount` plus the task list. For tasks that support progress (notably bash), includes a live stdout/stderr preview. Call await_task to collect a task's final result.",
193
+ inputSchema: z.object({}),
194
+ execute() {
195
+ const tasks = registry.listTasks().map(peek);
196
+ return { runningCount: tasks.filter((t) => t.status === "running").length, tasks };
197
+ },
198
+ })),
199
+
200
+ await_task: wrap(defineTool({
201
+ description:
202
+ "Block until a background task finishes (up to wait_ms), then return its full result. Use it when your next step needs the task's final output. If the wait elapses while it's still running, returns the running status plus any live progress so you can decide to keep waiting or move on.",
203
+ inputSchema: z.object({
204
+ task_id: z
205
+ .string()
206
+ .min(1)
207
+ .describe("Task id returned by run_async or a backgrounded bash call."),
208
+ wait_ms: z
209
+ .number()
210
+ .int()
211
+ .positive()
212
+ .optional()
213
+ .describe(`Max time to block in ms (default ${DEFAULT_WAIT_MS}).`),
214
+ }),
215
+ async execute({ task_id, wait_ms }) {
216
+ const task = await registry.awaitTask(task_id, wait_ms ?? DEFAULT_WAIT_MS);
217
+ if (!task) throw new Error(`No such task: ${task_id}`);
218
+ return full(task);
219
+ },
220
+ })),
221
+ };
222
+ }
223
+
224
+ /**
225
+ * The toolset as one dynamic definition. Session-scoped on purpose: tool
226
+ * definitions sit in the model's cached prompt prefix, so the run_async
227
+ * catalog is built once per session and stays byte-identical thereafter —
228
+ * live task state rides check_tasks' RESULT, never a description.
229
+ */
230
+ export function createTasksTools(opts: {
231
+ registry: TaskRegistry;
232
+ backgroundables: readonly BackgroundableOp[];
233
+ /** When set, steered messages ride these tools' results (see ../steer-tool). */
234
+ steerInbox?: SteerSource | null;
235
+ }) {
236
+ return defineDynamic({
237
+ events: {
238
+ "session.started": () => buildTasksToolset(opts),
239
+ },
240
+ });
241
+ }
@@ -0,0 +1,368 @@
1
+ import { defineTool } from "eve/tools";
2
+ import { z } from "zod";
3
+ import { basename, join } from "node:path";
4
+ import {
5
+ CHAT_ATTACHMENT_FIELD,
6
+ DEFAULT_MAX_INLINE_MEDIA_BYTES,
7
+ type ChatAttachment,
8
+ } from "../attachments";
9
+ import { createBoundedCapture } from "../bounded-output";
10
+ import { audioMediaType, detectFileKind, imageMediaType, videoMediaType } from "../file-kind";
11
+ import { loadFileContent } from "../read-file-content";
12
+ import { buildMediaHint } from "./read";
13
+ import {
14
+ fetchWebResource,
15
+ renderWebText,
16
+ resolveWebFetchTimeoutMs,
17
+ WEB_FETCH_DEFAULT_TIMEOUT_SECONDS,
18
+ WEB_FETCH_MAX_TIMEOUT_SECONDS,
19
+ WEB_FETCH_PDF_DEFAULT_TIMEOUT_SECONDS,
20
+ type FetchLike,
21
+ type WebFetchFormat,
22
+ } from "../web-fetch";
23
+ import type { Workspace } from "../workspace";
24
+
25
+ // Web fetch under the opencode name `webfetch`, built to beat both eve's
26
+ // framework `web_fetch` and opencode's tool by reusing the stdlib's own
27
+ // machinery: bounded output spills the complete page to the tool-outputs dir
28
+ // (eve's web_fetch discards everything past its cap), fetched PDFs/DOCX/
29
+ // spreadsheets route through the same extractors as `read`, and images ride
30
+ // the chat-attachment contract instead of decoding to garbage. The fetch/
31
+ // render core lives in ../web-fetch.ts; this wrapper owns routing + bounding.
32
+
33
+ const SPILL_EXTENSION: Record<WebFetchFormat, string> = {
34
+ markdown: "md",
35
+ text: "txt",
36
+ html: "html",
37
+ };
38
+
39
+ function spillFilename(format: WebFetchFormat, kind: "text" | "extracted"): string {
40
+ const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
41
+ const ext = kind === "extracted" ? "txt" : SPILL_EXTENSION[format];
42
+ return `webfetch-${runId}.${ext}`;
43
+ }
44
+
45
+ function fetchedFilename(finalUrl: string, fallback: string): string {
46
+ try {
47
+ const name = basename(new URL(finalUrl).pathname);
48
+ return name === "" || name === "/" ? fallback : name;
49
+ } catch {
50
+ return fallback;
51
+ }
52
+ }
53
+
54
+ // ZIP magic bytes (PK\x03\x04)
55
+ const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04];
56
+
57
+ function startsWithBytes(buf: Buffer, magic: number[]): boolean {
58
+ if (buf.length < magic.length) return false;
59
+ for (let i = 0; i < magic.length; i++) {
60
+ if (buf[i] !== magic[i]) return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ // Map Content-Type to file extension for Office/document formats that need it
66
+ // for ZIP/CFB container disambiguation when the URL path has no extension.
67
+ const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
68
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
69
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
70
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
71
+ "application/msword": ".doc",
72
+ "application/vnd.ms-excel": ".xls",
73
+ "application/vnd.ms-powerpoint": ".ppt",
74
+ "application/vnd.oasis.opendocument.text": ".odt",
75
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods",
76
+ "application/vnd.oasis.opendocument.presentation": ".odp",
77
+ "application/pdf": ".pdf",
78
+ // Media types matter only for formats whose magic bytes are ambiguous
79
+ // without a path hint (EBML webm/mkv, bare-frame-sync mp3); listed broadly
80
+ // so extensionless media URLs still label sensibly.
81
+ "video/mp4": ".mp4",
82
+ "video/quicktime": ".mov",
83
+ "video/webm": ".webm",
84
+ "video/x-matroska": ".mkv",
85
+ "video/x-msvideo": ".avi",
86
+ "audio/mpeg": ".mp3",
87
+ "audio/wav": ".wav",
88
+ "audio/x-wav": ".wav",
89
+ "audio/ogg": ".ogg",
90
+ "audio/flac": ".flac",
91
+ "audio/mp4": ".m4a",
92
+ };
93
+
94
+ // Legacy MIME types that servers sometimes use for modern OpenXML files
95
+ const LEGACY_TO_MODERN_EXT: Record<string, string> = {
96
+ ".doc": ".docx",
97
+ ".xls": ".xlsx",
98
+ ".ppt": ".pptx",
99
+ };
100
+
101
+ /**
102
+ * Build a path-like label for file-kind detection and extraction caching.
103
+ * Uses the URL pathname when it has an extension, but upgrades legacy Office
104
+ * extensions (.xls, .doc) to modern OpenXML (.xlsx, .docx) when ZIP magic
105
+ * bytes are detected. For extensionless URLs, synthesizes an extension from
106
+ * Content-Type, again preferring modern formats when ZIP bytes are present.
107
+ */
108
+ function pathLabelForFetch(finalUrl: string, contentType: string, body: Buffer): string {
109
+ const pathname = new URL(finalUrl).pathname;
110
+ const extMatch = pathname.match(/(\.[a-z0-9]+)$/i);
111
+
112
+ if (extMatch) {
113
+ // Pathname has an extension; upgrade legacy to modern if ZIP magic present
114
+ const captured = extMatch[1];
115
+ if (!captured) return pathname;
116
+ const currentExt = captured.toLowerCase();
117
+ if (startsWithBytes(body, ZIP_MAGIC)) {
118
+ const modernExt = LEGACY_TO_MODERN_EXT[currentExt];
119
+ if (modernExt) {
120
+ return pathname.slice(0, -currentExt.length) + modernExt;
121
+ }
122
+ }
123
+ return pathname;
124
+ }
125
+
126
+ // No extension in pathname; synthesize from Content-Type
127
+ const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
128
+ let ext = CONTENT_TYPE_EXTENSIONS[mime];
129
+ if (!ext) return pathname;
130
+ // Upgrade legacy MIME-derived extensions if ZIP magic present
131
+ if (startsWithBytes(body, ZIP_MAGIC)) {
132
+ ext = LEGACY_TO_MODERN_EXT[ext] ?? ext;
133
+ }
134
+ return pathname + ext;
135
+ }
136
+
137
+ export function createWebFetchTool(opts: {
138
+ workspace: Workspace;
139
+ spillDir: string;
140
+ attachImagesToChat: boolean;
141
+ maxInlineImageBytes: number;
142
+ /** Attach fetched video the way images attach — see `createReadTool`. Default false. */
143
+ attachVideoToChat?: boolean;
144
+ /** Attach fetched audio. Same gating as video. Default false. */
145
+ attachAudioToChat?: boolean;
146
+ /** Max video/audio bytes to inline; the 5 MB response cap bites first. */
147
+ maxInlineMediaBytes?: number;
148
+ /**
149
+ * The "what to do instead" sentence in the image result note when the
150
+ * pixels can't be delivered (attach disabled or over the size cap).
151
+ * Defaults to asking the user to attach the image; agents without HITL
152
+ * (e.g. task subagents) substitute advice that's actually actionable.
153
+ */
154
+ imageUnavailableHint?: string;
155
+ /** Injectable for tests; defaults to global fetch. */
156
+ fetchImpl?: FetchLike | undefined;
157
+ }) {
158
+ const { workspace, spillDir, attachImagesToChat, maxInlineImageBytes, fetchImpl } = opts;
159
+ const imageUnavailableHint =
160
+ opts.imageUnavailableHint ??
161
+ "If you need to see this image, ask the user to attach it to the chat.";
162
+ const attachVideoToChat = opts.attachVideoToChat ?? false;
163
+ const attachAudioToChat = opts.attachAudioToChat ?? false;
164
+ const maxInlineMediaBytes = opts.maxInlineMediaBytes ?? DEFAULT_MAX_INLINE_MEDIA_BYTES;
165
+
166
+ const bounded = (text: string, format: WebFetchFormat, kind: "text" | "extracted") => {
167
+ const spillPath = join(spillDir, spillFilename(format, kind));
168
+ const capture = createBoundedCapture({
169
+ spillPath,
170
+ spillLabel: workspace.relativize(spillPath),
171
+ });
172
+ capture.append(text);
173
+ const snapshot = capture.snapshot();
174
+ return {
175
+ content: snapshot.text,
176
+ totalChars: snapshot.totalChars,
177
+ truncated: snapshot.truncated,
178
+ };
179
+ };
180
+
181
+ const mediaHint = buildMediaHint(
182
+ { image: attachImagesToChat, video: attachVideoToChat, audio: attachAudioToChat },
183
+ "fetching",
184
+ );
185
+
186
+ return defineTool({
187
+ description:
188
+ `Fetch a URL and return its content. HTML pages are reduced to their main content (boilerplate stripped, title/author/date header) and converted to readable markdown by default (set format to "text" for plain text or "html" for the raw page). Fetched PDF, DOCX, and spreadsheet files are converted to plain text; ${mediaHint}. Content over the in-context budget is truncated head+tail and the complete output is spilled to a file named in the truncation marker — read or grep that file instead of re-fetching. Default timeout ${WEB_FETCH_DEFAULT_TIMEOUT_SECONDS}s (${WEB_FETCH_PDF_DEFAULT_TIMEOUT_SECONDS}s for PDFs), max ${WEB_FETCH_MAX_TIMEOUT_SECONDS}s; responses over 5 MB error. Read-only: one HTTP GET, no side effects.`,
189
+ inputSchema: z.object({
190
+ url: z
191
+ .string()
192
+ .min(1)
193
+ .describe("The URL to fetch. Must start with http:// or https://."),
194
+ format: z
195
+ .enum(["markdown", "text", "html"])
196
+ .optional()
197
+ .describe(
198
+ 'How to render HTML responses: "markdown" (default), "text", or "html" (raw). Non-HTML content is unaffected.',
199
+ ),
200
+ timeout: z
201
+ .number()
202
+ .int()
203
+ .positive()
204
+ .optional()
205
+ .describe(
206
+ `Timeout in seconds (default ${WEB_FETCH_DEFAULT_TIMEOUT_SECONDS}, max ${WEB_FETCH_MAX_TIMEOUT_SECONDS}).`,
207
+ ),
208
+ }),
209
+ async execute({ url, format, timeout }) {
210
+ const renderFormat: WebFetchFormat = format ?? "markdown";
211
+ const fetched = await fetchWebResource({
212
+ url,
213
+ format: renderFormat,
214
+ timeoutMs: resolveWebFetchTimeoutMs(timeout, url),
215
+ // Without an explicit timeout, let the deadline extend to the PDF
216
+ // default when the response (not the request URL) turns out to be a
217
+ // PDF — a redirect to `.pdf` or an extensionless PDF URL.
218
+ ...(timeout === undefined
219
+ ? { pdfTimeoutMs: WEB_FETCH_PDF_DEFAULT_TIMEOUT_SECONDS * 1000 }
220
+ : {}),
221
+ ...(fetchImpl !== undefined ? { fetchImpl } : {}),
222
+ });
223
+ const { body, contentType, finalUrl } = fetched;
224
+ // Surface cross-URL redirects so the model knows where the content
225
+ // actually came from; same-URL responses stay quiet.
226
+ const redirect = finalUrl !== url ? { finalUrl } : {};
227
+ const meta = { url, ...redirect, contentType };
228
+
229
+ // Route by sniffed bytes, not the content-type header — servers lie.
230
+ // The path label comes from the URL pathname, with a Content-Type-derived
231
+ // extension appended when the pathname lacks one (DOCX/XLSX/etc. are ZIP
232
+ // containers that need extension disambiguation). Sniff for ZIP magic to
233
+ // prefer modern OpenXML extensions when servers send legacy MIME types.
234
+ const label = pathLabelForFetch(finalUrl, contentType, body);
235
+ const detected = detectFileKind(body, label);
236
+ if (detected.kind === "binary") {
237
+ throw new Error(
238
+ `Fetched content is ${detected.description} — webfetch returns text and media metadata only. ` +
239
+ "Use bash (curl -o) to download it if needed.",
240
+ );
241
+ }
242
+ const content = await loadFileContent(body, label, {
243
+ // The extraction cache keys on path + identity; fetch time makes each
244
+ // fetch its own entry, so two fetches of a changed URL never collide.
245
+ mtimeMs: Date.now(),
246
+ size: body.byteLength,
247
+ });
248
+ switch (content.kind) {
249
+ case "text": {
250
+ const rendered = renderWebText(content.text, contentType, renderFormat, finalUrl);
251
+ return {
252
+ ...meta,
253
+ format: renderFormat,
254
+ ...(rendered.note === undefined ? {} : { note: rendered.note }),
255
+ ...bounded(rendered.text, renderFormat, "text"),
256
+ };
257
+ }
258
+ case "pdf":
259
+ return {
260
+ ...meta,
261
+ source: "pdf" as const,
262
+ pages: content.pages,
263
+ ...bounded(content.text, renderFormat, "extracted"),
264
+ };
265
+ case "docx":
266
+ return {
267
+ ...meta,
268
+ source: "docx" as const,
269
+ ...bounded(content.text, renderFormat, "extracted"),
270
+ };
271
+ case "sheet":
272
+ return {
273
+ ...meta,
274
+ source: "sheet" as const,
275
+ sheetFormat: content.format,
276
+ sheets: content.sheets,
277
+ ...bounded(content.text, renderFormat, "extracted"),
278
+ };
279
+ case "image": {
280
+ const imageMeta = {
281
+ ...meta,
282
+ source: "image" as const,
283
+ imageFormat: content.format,
284
+ width: content.width,
285
+ height: content.height,
286
+ bytes: body.byteLength,
287
+ };
288
+ if (!attachImagesToChat || body.byteLength > maxInlineImageBytes) {
289
+ const why =
290
+ attachImagesToChat && body.byteLength > maxInlineImageBytes
291
+ ? `too large to attach automatically (${body.byteLength} bytes, max ${maxInlineImageBytes})`
292
+ : "cannot be returned as a tool result (text/json only), and image attachments are not enabled for this agent";
293
+ return {
294
+ ...imageMeta,
295
+ note: `Image content ${why}. ${imageUnavailableHint}`,
296
+ };
297
+ }
298
+ const attachment: ChatAttachment = {
299
+ kind: "image",
300
+ dataUrl: `data:${imageMediaType(content.format)};base64,${body.toString("base64")}`,
301
+ mediaType: imageMediaType(content.format),
302
+ filename: fetchedFilename(finalUrl, "image"),
303
+ width: content.width,
304
+ height: content.height,
305
+ };
306
+ return {
307
+ ...imageMeta,
308
+ note: "This image is queued and will be attached to your next message as a viewable image — no need to ask the user to attach it.",
309
+ [CHAT_ATTACHMENT_FIELD]: attachment,
310
+ };
311
+ }
312
+ case "video":
313
+ case "audio": {
314
+ const kind = content.kind;
315
+ const mediaType =
316
+ kind === "video"
317
+ ? videoMediaType(content.format)
318
+ : audioMediaType(content.format);
319
+ // `format` names the render format on text results (and imageFormat/
320
+ // sheetFormat theirs), so media keeps the same disambiguation.
321
+ const mediaMeta = {
322
+ ...meta,
323
+ source: kind,
324
+ mediaFormat: content.format,
325
+ mediaType,
326
+ bytes: body.byteLength,
327
+ };
328
+ const label = kind === "video" ? "Video" : "Audio";
329
+ const enabled = kind === "video" ? attachVideoToChat : attachAudioToChat;
330
+ if (!enabled || body.byteLength > maxInlineMediaBytes) {
331
+ const why =
332
+ enabled && body.byteLength > maxInlineMediaBytes
333
+ ? `too large to attach automatically (${body.byteLength} bytes, max ${maxInlineMediaBytes})`
334
+ : `cannot be returned as a tool result (text/json only), and ${kind} attachments are not enabled for this agent`;
335
+ return {
336
+ ...mediaMeta,
337
+ note: `${label} content ${why}. Use bash (curl -o) to download it if you need to process it.`,
338
+ };
339
+ }
340
+ const attachment: ChatAttachment = {
341
+ kind,
342
+ dataUrl: `data:${mediaType};base64,${body.toString("base64")}`,
343
+ mediaType,
344
+ filename: fetchedFilename(finalUrl, kind),
345
+ };
346
+ return {
347
+ ...mediaMeta,
348
+ note: `This ${kind} file is queued and will be attached to your next message — no need to ask the user to attach it.`,
349
+ [CHAT_ATTACHMENT_FIELD]: attachment,
350
+ };
351
+ }
352
+ }
353
+ },
354
+ // Same contract as `read`: the attachment bytes ride the raw result for a
355
+ // connected client; the model sees metadata + the note only.
356
+ toModelOutput(output) {
357
+ if (
358
+ typeof output === "object" &&
359
+ output !== null &&
360
+ CHAT_ATTACHMENT_FIELD in output
361
+ ) {
362
+ const { [CHAT_ATTACHMENT_FIELD]: _omitted, ...rest } = output;
363
+ return { type: "json", value: rest };
364
+ }
365
+ return { type: "json", value: output };
366
+ },
367
+ });
368
+ }
@@ -0,0 +1,42 @@
1
+ import { defineTool } from "eve/tools";
2
+ import { z } from "zod";
3
+ import { withPathLock } from "../path-locks";
4
+ import type { Workspace } from "../workspace";
5
+ import { localIoProvider, type WorkspaceIoProvider } from "../workspace-io";
6
+
7
+ // Replaces eve's sandbox `write_file` (vacate the framework name with a
8
+ // disable shim), writing to the workspace under the Claude Code /
9
+ // opencode name `write`.
10
+ export function createWriteTool(opts: {
11
+ workspace: Workspace;
12
+ noun: string;
13
+ /** Per-call I/O backend (../workspace-io.ts). Defaults to local node:fs. */
14
+ io?: WorkspaceIoProvider;
15
+ }) {
16
+ const { workspace, noun } = opts;
17
+ const io = opts.io ?? localIoProvider(workspace.root);
18
+ return defineTool({
19
+ description: `Write a complete file to the ${noun}, creating parent directories and overwriting any existing file. For a small change to an existing file, prefer edit so you don't have to reproduce the whole file.`,
20
+ inputSchema: z.object({
21
+ path: z.string().min(1).describe(`File path, relative to the ${noun} root.`),
22
+ content: z.string().describe("The full contents to write."),
23
+ }),
24
+ async execute({ path, content }, ctx) {
25
+ const abs = workspace.resolve(path);
26
+ const fio = io(ctx);
27
+ // Same per-path serialization as edit: a write racing an edit to the
28
+ // same file in one concurrent step must not interleave with its
29
+ // read-modify-write (see ../path-locks.ts).
30
+ return withPathLock(abs, async () => {
31
+ const created = (await fio.stat(abs)) === null;
32
+ await fio.writeFile(abs, content);
33
+ return {
34
+ ok: true,
35
+ path: workspace.relativize(abs),
36
+ created,
37
+ bytes: Buffer.byteLength(content),
38
+ };
39
+ });
40
+ },
41
+ });
42
+ }