@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,188 @@
1
+ import { defineTool } from "eve/tools";
2
+ import { z } from "zod";
3
+ import { join } from "node:path";
4
+ import type { Workspace } from "../workspace";
5
+ import {
6
+ localIoProvider,
7
+ type IoSearchMatch,
8
+ type WorkspaceIoProvider,
9
+ } from "../workspace-io";
10
+
11
+ // Overrides eve's sandbox `grep`, searching workspace file contents through
12
+ // the I/O seam. The scan runs backend-native (../workspace-io.ts): locally an
13
+ // in-process line-by-line scan over `git ls-files` candidates with bounded
14
+ // reads (size cap + binary sniff), so a stray artifact can't stall a search;
15
+ // on a sandbox, ripgrep/grep executed remotely so bytes never cross the wire.
16
+ // For large/heavy searches the model can still use `bash` with ripgrep.
17
+ //
18
+ // When more lines match than fit in-context, the scan keeps going and spills
19
+ // the complete match list to the tool-outputs dir (same recovery shape as
20
+ // bash/webfetch): the model reads or greps that file instead of re-running the
21
+ // search narrower. Scanning already visits every candidate when matches are
22
+ // scarce, so continuing past the cap costs no more than a no-match search.
23
+ // The spill is written through the same IO as the scan, so on a sandbox it
24
+ // lands where the model's follow-up `read` can reach it.
25
+
26
+ /** Hard bound on a spilled scan, so `.` over a huge corpus still terminates early. */
27
+ export const GREP_SPILL_MAX_MATCHES = 5000;
28
+
29
+ /** Shown match text is clipped so one minified line can't flood the result. */
30
+ const MATCH_TEXT_MAX_CHARS = 300;
31
+
32
+ /** The tool's result shape, uniform across the complete/capped/spilled paths. */
33
+ interface GrepResult {
34
+ pattern: string;
35
+ count: number;
36
+ truncated: boolean;
37
+ matches: { file: string; line: number; text: string }[];
38
+ /** Absent when the backend can't count size-skipped files (remote search). */
39
+ skippedLargeFiles?: number;
40
+ totalMatches?: number;
41
+ note?: string;
42
+ }
43
+
44
+ export function createGrepTool(opts: {
45
+ workspace: Workspace;
46
+ noun: string;
47
+ /** Directory for overflow match lists; omit to keep the stop-at-cap behavior. */
48
+ spillDir?: string;
49
+ /** Per-call I/O backend (../workspace-io.ts). Defaults to local node:fs. */
50
+ io?: WorkspaceIoProvider;
51
+ }) {
52
+ const { workspace, noun, spillDir } = opts;
53
+ const io = opts.io ?? localIoProvider(workspace.root);
54
+ return defineTool({
55
+ description: `Search ${noun} file contents by regular expression, returning matching lines with their file and line number. Scope with \`path\` (a file or directory) and/or a \`glob\` on the filename. Gitignored files, build/VCS dirs, binaries, and files over ~1.5 MB are skipped.${spillDir === undefined ? "" : " When more lines match than max_results, the collected matches are saved to a file named in the result (the note says whether that list is complete) — read or grep that file instead of re-searching."}`,
56
+ inputSchema: z.object({
57
+ pattern: z.string().min(1).describe("JavaScript regular expression to search for."),
58
+ path: z
59
+ .string()
60
+ .optional()
61
+ .describe(`A file or directory (relative to the ${noun} root) to limit the search to.`),
62
+ glob: z
63
+ .string()
64
+ .optional()
65
+ .describe("Only search files whose path matches this glob, e.g. `**/*.ts`."),
66
+ ignore_case: z.boolean().optional().describe("Case-insensitive match."),
67
+ max_results: z
68
+ .number()
69
+ .int()
70
+ .positive()
71
+ .optional()
72
+ .describe("Max matching lines (default 200)."),
73
+ }),
74
+ async execute({ pattern, path, glob, ignore_case, max_results }, ctx): Promise<GrepResult> {
75
+ // Validate as a JS regex up front so a malformed pattern fails the same
76
+ // way on every backend (a remote rg/grep would report its own error).
77
+ try {
78
+ new RegExp(pattern, ignore_case ? "i" : "");
79
+ } catch (err) {
80
+ const reason = err instanceof Error ? err.message : String(err);
81
+ throw new Error(`Invalid regular expression: ${reason}`);
82
+ }
83
+ const max = max_results ?? 200;
84
+ const fio = io(ctx);
85
+
86
+ let scope: string | undefined;
87
+ if (path) {
88
+ const abs = workspace.resolve(path);
89
+ const stat = await fio.stat(abs);
90
+ if (stat === null) {
91
+ throw new Error(`${workspace.relativize(abs)} does not exist.`);
92
+ }
93
+ scope = abs;
94
+ }
95
+
96
+ // Without a spill destination the scan stops as soon as it has proven
97
+ // there are more than `max` matching lines; with one it keeps going so
98
+ // the complete list can be written out. The hard bound applies either
99
+ // way, so `.` over a huge corpus still terminates early.
100
+ const cap =
101
+ spillDir === undefined
102
+ ? Math.min(max + 1, GREP_SPILL_MAX_MATCHES)
103
+ : GREP_SPILL_MAX_MATCHES;
104
+ const searched = await fio.search({
105
+ pattern,
106
+ ignoreCase: ignore_case ?? false,
107
+ scope,
108
+ glob,
109
+ maxMatches: cap,
110
+ });
111
+ const clip = (m: IoSearchMatch) => ({
112
+ file: m.file,
113
+ line: m.line,
114
+ text: m.text.slice(0, MATCH_TEXT_MAX_CHARS),
115
+ });
116
+ const matches = searched.matches.slice(0, max).map(clip);
117
+ // Omit the count entirely when the backend can't know it (remote
118
+ // searchers enforce the size cap without reporting skips) — a hard 0
119
+ // would misinform the model.
120
+ const skipped =
121
+ searched.skippedLargeFiles === null
122
+ ? {}
123
+ : { skippedLargeFiles: searched.skippedLargeFiles };
124
+ const hitHardBound =
125
+ searched.stopped === "max-matches" && cap === GREP_SPILL_MAX_MATCHES;
126
+ // A remote backend's byte cap cut the stream mid-scan: fewer than the
127
+ // match cap were parsed and the spill can't claim completeness.
128
+ const floodCut = searched.stopped === "output-cap";
129
+
130
+ // A stopped scan is never complete, even when everything found so far
131
+ // fits under max (a max_results at or above the hard bound).
132
+ if (searched.stopped === false && searched.matches.length <= max) {
133
+ return { pattern, count: matches.length, truncated: false, ...skipped, matches };
134
+ }
135
+ if (spillDir === undefined && !hitHardBound && !floodCut) {
136
+ // "may exist": the backend stopped scanning at the cap, so we can't
137
+ // know how many more there are.
138
+ return {
139
+ pattern,
140
+ count: matches.length,
141
+ truncated: true,
142
+ ...skipped,
143
+ matches,
144
+ note: `Stopped at ${max} matching lines — more matches may exist. Narrow with path/glob or a more specific pattern, or raise max_results.`,
145
+ };
146
+ }
147
+
148
+ // Every match, in `file:line: text` lines, for the spill file.
149
+ const allLines = searched.matches.map(
150
+ (m) => `${m.file}:${m.line}: ${m.text.slice(0, MATCH_TEXT_MAX_CHARS)}`,
151
+ );
152
+ let label: string | null = null;
153
+ if (spillDir !== undefined) {
154
+ const spillPath = join(
155
+ spillDir,
156
+ `grep-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.txt`,
157
+ );
158
+ try {
159
+ await fio.writeFile(spillPath, allLines.join("\n") + "\n");
160
+ label = workspace.relativize(spillPath);
161
+ } catch {
162
+ // A failed spill degrades to the capped result, not an error.
163
+ }
164
+ }
165
+ const found = floodCut
166
+ ? `Search output hit the transfer cap after ${allLines.length} matching lines — more matches may exist`
167
+ : hitHardBound
168
+ ? `Stopped scanning at ${GREP_SPILL_MAX_MATCHES} matching lines`
169
+ : `Found ${allLines.length} matching lines`;
170
+ // "Complete" only when the scan genuinely covered everything — a
171
+ // hard-bound or byte-capped scan spills what it collected, no more.
172
+ const spillIsComplete = !floodCut && !hitHardBound;
173
+ const where =
174
+ label === null
175
+ ? "Narrow with path/glob or a more specific pattern, or raise max_results."
176
+ : `The ${spillIsComplete ? "complete list is" : "matches collected so far are"} at ${label} — read or grep that file, or narrow with path/glob.`;
177
+ return {
178
+ pattern,
179
+ count: matches.length,
180
+ totalMatches: allLines.length,
181
+ truncated: true,
182
+ ...skipped,
183
+ matches,
184
+ note: `${found} — showing the first ${matches.length} here. ${where}`,
185
+ };
186
+ },
187
+ });
188
+ }
@@ -0,0 +1,319 @@
1
+ import { defineTool } from "eve/tools";
2
+ import { z } from "zod";
3
+ import { basename } from "node:path";
4
+ import {
5
+ CHAT_ATTACHMENT_FIELD,
6
+ DEFAULT_MAX_INLINE_MEDIA_BYTES,
7
+ type ChatAttachment,
8
+ } from "../attachments";
9
+ import type { DirConventionsRider, DirConventionsTracker } from "../dir-conventions";
10
+ import { audioMediaType, imageMediaType, videoMediaType } from "../file-kind";
11
+ import { buildFileView, READ_FILE_MAX_BYTES } from "../file-view";
12
+ import { loadFileContent } from "../read-file-content";
13
+ import type { Workspace } from "../workspace";
14
+ import { localIoProvider, type WorkspaceIoProvider } from "../workspace-io";
15
+
16
+ /** The description phrase covering media reads/fetches, from the enabled
17
+ * attach kinds. Shared with `createWebFetchTool`; static per factory build. */
18
+ export function buildMediaHint(
19
+ attach: { image: boolean; video: boolean; audio: boolean },
20
+ verb: "reading" | "fetching",
21
+ ): string {
22
+ const kinds = ["image", "video", "audio"] as const;
23
+ const on = kinds.filter((kind) => attach[kind]);
24
+ const off = kinds.filter((kind) => !attach[kind]);
25
+ const list = (items: readonly string[]) => items.join(" or ");
26
+ if (on.length === 0) {
27
+ return `${verb} media (images, video, audio) returns metadata only`;
28
+ }
29
+ const queued = `${verb} ${list(on)} files returns metadata and queues the file to appear as a viewable attachment on your next message`;
30
+ return off.length === 0
31
+ ? queued
32
+ : `${queued} (${list(off)} ${verb === "reading" ? "reads" : "fetches"} return metadata only)`;
33
+ }
34
+
35
+ // Replaces eve's sandbox `read_file` (vacate the framework name with a disable
36
+ // shim), reading from the real workspace under the Claude Code / opencode name
37
+ // `read`. Content routes by sniffed kind (see ../read-file-content.ts): text is
38
+ // windowed directly; PDF/DOCX/spreadsheets are converted to text first.
39
+ //
40
+ // eve tool results are text/json — pixels can't ride one. So for media under
41
+ // the inline cap, we embed the bytes as a data URL on the raw result under
42
+ // CHAT_ATTACHMENT_FIELD (see ../attachments.ts) and strip that field in
43
+ // `toModelOutput`: the model sees metadata + a note, while a connected client
44
+ // reads the bytes off the tool-result event and re-injects the media as a real
45
+ // user message part on the next turn. Over the cap (or when disabled), we fall
46
+ // back to a metadata-only note. Images attach by default; video/audio are
47
+ // opt-in because model support is provider-gated (Gemini takes them, Claude
48
+ // does not) and eve's attachment staging today hydrates only images/PDFs back
49
+ // into the model call (see the README's eve-maintainer notes).
50
+ export function createReadTool(opts: {
51
+ workspace: Workspace;
52
+ noun: string;
53
+ /**
54
+ * The I/O backend resolved per call (see ../workspace-io.ts). Defaults to
55
+ * the local node:fs backend; hosted agents pass the sandbox provider
56
+ * (../sandbox-io.ts) so reads hit the session's workspace.
57
+ */
58
+ io?: WorkspaceIoProvider;
59
+ attachImagesToChat: boolean;
60
+ maxInlineImageBytes: number;
61
+ /**
62
+ * Attach video files (mp4/mov/webm/mkv/avi) the way images attach. Default
63
+ * false — enable only when the agent's model accepts video input AND the
64
+ * runtime delivers video file parts (see the module comment).
65
+ */
66
+ attachVideoToChat?: boolean;
67
+ /** Attach audio files (mp3/wav/ogg/flac/m4a). Same gating as video. */
68
+ attachAudioToChat?: boolean;
69
+ /**
70
+ * Max video/audio size (bytes) to inline on the tool result. Defaults to
71
+ * 10 MB — the read stat guard rejects bigger files before this bites.
72
+ */
73
+ maxInlineMediaBytes?: number;
74
+ /**
75
+ * When set, the first read under a directory carrying its own conventions
76
+ * file attaches that file to the result under `directory_conventions` —
77
+ * once per directory per session (see ../dir-conventions.ts).
78
+ */
79
+ dirConventions?: { tracker: DirConventionsTracker; fileName: string } | undefined;
80
+ /**
81
+ * The "what to do instead" sentence in the file-too-large error. Defaults
82
+ * to the bash suggestion; agents without bash point it at the tools they
83
+ * do have.
84
+ */
85
+ oversizeHint?: string;
86
+ /**
87
+ * The "what to do instead" sentence in the image result note when the
88
+ * pixels can't be delivered (attach disabled or over the size cap).
89
+ * Defaults to asking the user to attach the image; agents without HITL
90
+ * substitute advice that's actually actionable.
91
+ */
92
+ imageUnavailableHint?: string;
93
+ /**
94
+ * The "what to do instead" sentence in the video/audio result note when the
95
+ * bytes can't be delivered (attach disabled — the default — or over the
96
+ * cap). Defaults to steering toward bash extraction (ffmpeg frames read as
97
+ * images); agents without bash substitute their own.
98
+ */
99
+ mediaUnavailableHint?: string;
100
+ /**
101
+ * Include the read-before-edit guidance in the description. Default true;
102
+ * read-only consumers turn it off — there is no edit.
103
+ */
104
+ includeEditGuidance?: boolean;
105
+ }) {
106
+ const { workspace, noun, attachImagesToChat, maxInlineImageBytes, dirConventions } = opts;
107
+ const io = opts.io ?? localIoProvider(workspace.root);
108
+ const attachVideoToChat = opts.attachVideoToChat ?? false;
109
+ const attachAudioToChat = opts.attachAudioToChat ?? false;
110
+ const maxInlineMediaBytes = opts.maxInlineMediaBytes ?? DEFAULT_MAX_INLINE_MEDIA_BYTES;
111
+ const oversizeHint =
112
+ opts.oversizeHint ?? "Use bash (head, sed -n, rg) to extract the part you need.";
113
+ const imageUnavailableHint =
114
+ opts.imageUnavailableHint ??
115
+ "If you need to see this image, ask the user to attach it to the chat.";
116
+ const mediaUnavailableHint =
117
+ opts.mediaUnavailableHint ??
118
+ "If you need its contents, extract what you can with bash (e.g. ffmpeg frames from a video, read as images) or ask the user about it.";
119
+ // Static per-build (option-dependent, never per-turn): prompt-cache safe.
120
+ const conventionsHint = dirConventions
121
+ ? ` When a read first enters a directory with its own ${dirConventions.fileName} conventions file, the result includes it under directory_conventions (once per directory per session) — honor those conventions for work in that directory.`
122
+ : "";
123
+ // Only promise the attachment path for kinds a client actually delivers
124
+ // (attach option + the park-delivery hook); otherwise be honest that the
125
+ // read is metadata-only.
126
+ const mediaHint = buildMediaHint(
127
+ { image: attachImagesToChat, video: attachVideoToChat, audio: attachAudioToChat },
128
+ "reading",
129
+ );
130
+ const editHint =
131
+ (opts.includeEditGuidance ?? true)
132
+ ? " Read a file before editing it so your edits target the current text."
133
+ : "";
134
+ return defineTool({
135
+ description:
136
+ `Read a file from the ${noun}, returning line-numbered text. PDF, DOCX, and spreadsheet files (.xlsx, .xlsm, .xls, .ods) are converted to plain text (PDFs get per-page markers, spreadsheets render as TSV per sheet); ${mediaHint}.${editHint} Returns up to 2000 lines per call by default; page bigger files with offset/limit.` +
137
+ conventionsHint,
138
+ inputSchema: z.object({
139
+ path: z.string().min(1).describe(`File path, relative to the ${noun} root.`),
140
+ offset: z
141
+ .number()
142
+ .int()
143
+ .positive()
144
+ .optional()
145
+ .describe("1-based line to start reading from."),
146
+ limit: z.number().int().positive().optional().describe("Max number of lines to return."),
147
+ }),
148
+ async execute({ path, offset, limit }, ctx) {
149
+ const abs = workspace.resolve(path);
150
+ const rel = workspace.relativize(abs);
151
+ const fio = io(ctx);
152
+ const stat = await fio.stat(abs);
153
+ if (stat === null) throw new Error(`${rel} does not exist.`);
154
+ if (!stat.isFile) {
155
+ throw new Error(`${rel} is not a regular file. Use glob to list a directory.`);
156
+ }
157
+ if (stat.size > READ_FILE_MAX_BYTES) {
158
+ throw new Error(
159
+ `${rel} is ${stat.size} bytes — too large to read (max ${READ_FILE_MAX_BYTES}). ` +
160
+ oversizeHint,
161
+ );
162
+ }
163
+ const buffer = await fio.readFile(abs);
164
+ if (buffer === null) throw new Error(`${rel} does not exist.`);
165
+ const content = await loadFileContent(buffer, rel, {
166
+ mtimeMs: stat.mtimeMs,
167
+ size: stat.size,
168
+ });
169
+ // Conventions riders ride the RESULT (transcript-append, prompt-cache
170
+ // safe). Collected only after the content load succeeded — a throwing
171
+ // read must not consume the directory's once-per-session delivery slot.
172
+ // A caller without an eve session (direct factory use) gets none.
173
+ // Riders load through the same per-call IO as the read, so a
174
+ // sandbox-backed read delivers sandbox conventions files. Riders are
175
+ // best-effort garnish: a failing collection (a transient sandbox error
176
+ // on the rider hop) must never fail the read the model actually asked
177
+ // for — the tracker leaves failed dirs unconsumed, so delivery retries
178
+ // on a later read.
179
+ const riders: DirConventionsRider[] = await (async () => {
180
+ try {
181
+ return (
182
+ (await dirConventions?.tracker.collect(
183
+ ctx?.session?.id,
184
+ rel,
185
+ async (absPath) => {
186
+ const bytes = await fio.readFile(absPath);
187
+ return bytes === null ? null : bytes.toString("utf8");
188
+ },
189
+ )) ?? []
190
+ );
191
+ } catch {
192
+ return [];
193
+ }
194
+ })();
195
+ const conventions =
196
+ riders.length > 0 ? { directory_conventions: riders } : {};
197
+ switch (content.kind) {
198
+ case "text":
199
+ return {
200
+ path: rel,
201
+ ...buildFileView(content.text, { offset, limit }),
202
+ ...conventions,
203
+ };
204
+ case "pdf":
205
+ return {
206
+ path: rel,
207
+ source: "pdf" as const,
208
+ pages: content.pages,
209
+ ...buildFileView(content.text, { offset, limit }),
210
+ ...conventions,
211
+ };
212
+ case "docx":
213
+ return {
214
+ path: rel,
215
+ source: "docx" as const,
216
+ ...buildFileView(content.text, { offset, limit }),
217
+ ...conventions,
218
+ };
219
+ case "sheet":
220
+ return {
221
+ path: rel,
222
+ source: "sheet" as const,
223
+ format: content.format,
224
+ sheets: content.sheets,
225
+ ...buildFileView(content.text, { offset, limit }),
226
+ ...conventions,
227
+ };
228
+ case "image": {
229
+ const meta = {
230
+ path: rel,
231
+ source: "image" as const,
232
+ format: content.format,
233
+ width: content.width,
234
+ height: content.height,
235
+ bytes: stat.size,
236
+ };
237
+ if (!attachImagesToChat || stat.size > maxInlineImageBytes) {
238
+ const why =
239
+ attachImagesToChat && stat.size > maxInlineImageBytes
240
+ ? `too large to attach automatically (${stat.size} bytes, max ${maxInlineImageBytes})`
241
+ : "cannot be returned as a tool result (text/json only), and image attachments are not enabled for this agent";
242
+ return {
243
+ ...meta,
244
+ note: `Image content ${why}. ${imageUnavailableHint}`,
245
+ ...conventions,
246
+ };
247
+ }
248
+ const attachment: ChatAttachment = {
249
+ kind: "image",
250
+ dataUrl: `data:${imageMediaType(content.format)};base64,${buffer.toString("base64")}`,
251
+ mediaType: imageMediaType(content.format),
252
+ filename: basename(rel),
253
+ width: content.width,
254
+ height: content.height,
255
+ };
256
+ return {
257
+ ...meta,
258
+ 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.",
259
+ [CHAT_ATTACHMENT_FIELD]: attachment,
260
+ ...conventions,
261
+ };
262
+ }
263
+ case "video":
264
+ case "audio": {
265
+ const kind = content.kind;
266
+ const mediaType =
267
+ kind === "video"
268
+ ? videoMediaType(content.format)
269
+ : audioMediaType(content.format);
270
+ const meta = {
271
+ path: rel,
272
+ source: kind,
273
+ format: content.format,
274
+ mediaType,
275
+ bytes: stat.size,
276
+ };
277
+ const label = kind === "video" ? "Video" : "Audio";
278
+ const enabled = kind === "video" ? attachVideoToChat : attachAudioToChat;
279
+ if (!enabled || stat.size > maxInlineMediaBytes) {
280
+ const why =
281
+ enabled && stat.size > maxInlineMediaBytes
282
+ ? `too large to attach automatically (${stat.size} bytes, max ${maxInlineMediaBytes})`
283
+ : `cannot be returned as a tool result (text/json only), and ${kind} attachments are not enabled for this agent`;
284
+ return {
285
+ ...meta,
286
+ note: `${label} content ${why}. ${mediaUnavailableHint}`,
287
+ ...conventions,
288
+ };
289
+ }
290
+ const attachment: ChatAttachment = {
291
+ kind,
292
+ dataUrl: `data:${mediaType};base64,${buffer.toString("base64")}`,
293
+ mediaType,
294
+ filename: basename(rel),
295
+ };
296
+ return {
297
+ ...meta,
298
+ note: `This ${kind} file is queued and will be attached to your next message — no need to ask the user to attach it.`,
299
+ [CHAT_ATTACHMENT_FIELD]: attachment,
300
+ ...conventions,
301
+ };
302
+ }
303
+ }
304
+ },
305
+ // Keep the embedded image bytes out of the model's context: the client reads
306
+ // them off the raw tool-result event, the model only needs the note + meta.
307
+ toModelOutput(output) {
308
+ if (
309
+ typeof output === "object" &&
310
+ output !== null &&
311
+ CHAT_ATTACHMENT_FIELD in output
312
+ ) {
313
+ const { [CHAT_ATTACHMENT_FIELD]: _omitted, ...rest } = output;
314
+ return { type: "json", value: rest };
315
+ }
316
+ return { type: "json", value: output };
317
+ },
318
+ });
319
+ }