@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.
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- 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
|
+
}
|