@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
package/src/task.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { defineAgent, type AgentDefinition } from "eve";
|
|
2
|
+
import { defineDynamic, defineInstructions } from "eve/instructions";
|
|
3
|
+
import { createDirConventionsTracker } from "./dir-conventions";
|
|
4
|
+
import { createReadTool } from "./tools/read";
|
|
5
|
+
import { createWebFetchTool } from "./tools/webfetch";
|
|
6
|
+
import { createWorkspace } from "./workspace";
|
|
7
|
+
|
|
8
|
+
// The task subagent kit: a generic, full-capability child preset for eve's
|
|
9
|
+
// declared subagents (`agent/subagents/task_fast/`, `task_deep/`, …). Eve has
|
|
10
|
+
// no per-call model parameter — a subagent tool's input is fixed at
|
|
11
|
+
// `{ message, outputSchema? }` and its model is compiled from its `agent.ts` —
|
|
12
|
+
// so the model knob is encoded as ONE DECLARED SUBAGENT PER TIER: the caller
|
|
13
|
+
// picks a model by picking a tool, and each tool's description carries that
|
|
14
|
+
// model's identity and routing guidance.
|
|
15
|
+
//
|
|
16
|
+
// Unlike the retired read-only explore preset, a task child is a clone-alike:
|
|
17
|
+
// it re-exports the PARENT's tool surface (one-line re-exports — eve's
|
|
18
|
+
// isolation boundary means a declared subagent inherits nothing), minus an
|
|
19
|
+
// explicit exclusion list for parent-session-coupled tools, plus a
|
|
20
|
+
// `disableTool()` shim for `ask_question` (children are autonomous: they
|
|
21
|
+
// decide and report instead of parking the parent's turn on the user), with
|
|
22
|
+
// `read`/`webfetch` swapped for attach-disabled instances
|
|
23
|
+
// ({@link createTaskChildTools}) since no park-delivery hook runs in a child.
|
|
24
|
+
// `expectedTaskToolNames` is the manifest a consumer's test diffs its
|
|
25
|
+
// subagent `tools/` dir against, so a parent tool added without a re-export
|
|
26
|
+
// (or a forgotten shim) fails CI instead of silently shipping a child with a
|
|
27
|
+
// different tool surface than advertised.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Framework built-ins a task child vacates with a `disableTool()` shim — one
|
|
31
|
+
* `tools/<name>.ts` per entry. Only `ask_question`: a parked child parks the
|
|
32
|
+
* PARENT's turn, so a task worker that hits ambiguity makes the reasonable
|
|
33
|
+
* call itself (or reports the blocker as its result) instead of asking the
|
|
34
|
+
* user. Everything else follows the parent: parent-authored tools re-export;
|
|
35
|
+
* builtins the parent didn't touch stay at their framework defaults in the
|
|
36
|
+
* child too.
|
|
37
|
+
*
|
|
38
|
+
* The `agent` clone tool is deliberately NOT here: eve injects it at the
|
|
39
|
+
* harness layer, not as a framework tool, so a `disableTool()` shim for it
|
|
40
|
+
* fails runtime agent-graph resolution — every session create 500s. The task
|
|
41
|
+
* instruction bounds onward delegation instead.
|
|
42
|
+
*/
|
|
43
|
+
export const TASK_DISABLED_BUILTINS = ["ask_question"] as const;
|
|
44
|
+
|
|
45
|
+
export interface TaskToolManifestOptions {
|
|
46
|
+
/**
|
|
47
|
+
* The parent's authored tool names — file names (without `.ts`) under the
|
|
48
|
+
* parent's `agent/tools/`, disable shims included (re-exporting a parent
|
|
49
|
+
* shim keeps the vacated name vacated in the child too).
|
|
50
|
+
*/
|
|
51
|
+
parentToolNames: readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Parent tools deliberately not re-exported into the child — the
|
|
54
|
+
* parent-session/cockpit-coupled ones (e.g. a tool that queues messages
|
|
55
|
+
* into the parent's own chat). Every entry must name a real parent tool;
|
|
56
|
+
* a typo here would silently weaken the manifest guard, so it throws.
|
|
57
|
+
*/
|
|
58
|
+
excludedParentTools?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The exact file set (sorted, without `.ts`) a task subagent's `tools/` dir
|
|
63
|
+
* must contain: every parent tool minus the exclusions, plus one disable shim
|
|
64
|
+
* per {@link TASK_DISABLED_BUILTINS} entry. A consumer's manifest test diffs
|
|
65
|
+
* its directory against this so tool-surface drift fails CI.
|
|
66
|
+
*/
|
|
67
|
+
export function expectedTaskToolNames(options: TaskToolManifestOptions): string[] {
|
|
68
|
+
const parent = new Set(options.parentToolNames);
|
|
69
|
+
const excluded = options.excludedParentTools ?? [];
|
|
70
|
+
for (const name of excluded) {
|
|
71
|
+
if (!parent.has(name)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`excludedParentTools names "${name}", which is not a parent tool — a stale exclusion would silently weaken the manifest guard`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const names = new Set(options.parentToolNames.filter((name) => !excluded.includes(name)));
|
|
78
|
+
for (const name of TASK_DISABLED_BUILTINS) {
|
|
79
|
+
if (names.has(name)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`"${name}" is both a parent tool and a task-disabled builtin — re-export or shim, not both`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
names.add(name);
|
|
85
|
+
}
|
|
86
|
+
return [...names].sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface TaskChildToolsOptions {
|
|
90
|
+
/** Directory the child works in; tools refuse paths that escape it. */
|
|
91
|
+
workspaceRoot: string;
|
|
92
|
+
/** What tool descriptions call the workspace. Defaults to "workspace". */
|
|
93
|
+
workspaceNoun?: string;
|
|
94
|
+
/** Where oversized grep/webfetch output spills (the parent's spill dir). */
|
|
95
|
+
spillDir: string;
|
|
96
|
+
/**
|
|
97
|
+
* Attach a directory's conventions file to the first `read` under it, once
|
|
98
|
+
* per directory per session (see ./dir-conventions.ts). Defaults to `true`.
|
|
99
|
+
*/
|
|
100
|
+
injectDirConventions?: boolean;
|
|
101
|
+
/** Conventions filename the read riders look for. Defaults to "AGENTS.md". */
|
|
102
|
+
conventionsFileName?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Child-safe `read` and `webfetch` overrides for a task subagent's `tools/`
|
|
107
|
+
* dir. A declared child runs WITHOUT the park-delivery hook (it never parks
|
|
108
|
+
* awaiting input — `ask_question` is shimmed off — so queued deliveries would
|
|
109
|
+
* never send), which means the parent's attachment-enabled `read`/`webfetch`
|
|
110
|
+
* would promise media "attached to your next message" that never arrives.
|
|
111
|
+
* These instances disable attachments and rewrite the image hint to the
|
|
112
|
+
* child's honest move: report the path so the caller can view it. Everything
|
|
113
|
+
* else in the child's `tools/` stays a one-line re-export of the parent's
|
|
114
|
+
* instance.
|
|
115
|
+
*/
|
|
116
|
+
export function createTaskChildTools(options: TaskChildToolsOptions) {
|
|
117
|
+
const noun = options.workspaceNoun ?? "workspace";
|
|
118
|
+
const workspace = createWorkspace(options.workspaceRoot);
|
|
119
|
+
const conventionsFileName = options.conventionsFileName ?? "AGENTS.md";
|
|
120
|
+
const dirConventions =
|
|
121
|
+
(options.injectDirConventions ?? true)
|
|
122
|
+
? {
|
|
123
|
+
tracker: createDirConventionsTracker({
|
|
124
|
+
workspaceRoot: workspace.root,
|
|
125
|
+
fileName: conventionsFileName,
|
|
126
|
+
}),
|
|
127
|
+
fileName: conventionsFileName,
|
|
128
|
+
}
|
|
129
|
+
: undefined;
|
|
130
|
+
return {
|
|
131
|
+
read: createReadTool({
|
|
132
|
+
workspace,
|
|
133
|
+
noun,
|
|
134
|
+
attachImagesToChat: false,
|
|
135
|
+
maxInlineImageBytes: 0,
|
|
136
|
+
dirConventions,
|
|
137
|
+
// No client re-injects bytes in a child session; asking the user is
|
|
138
|
+
// also off the table (ask_question is shimmed). Reporting is the
|
|
139
|
+
// honest move. Bash-based hints stay default — the child has bash.
|
|
140
|
+
imageUnavailableHint:
|
|
141
|
+
"Its pixels are not available in a delegated child session — report the image's path and metadata in your final message so the caller can view it.",
|
|
142
|
+
mediaUnavailableHint:
|
|
143
|
+
"Its bytes are not available in a delegated child session — use bash extraction if text will do, or report the file's path and metadata so the caller can handle it.",
|
|
144
|
+
}),
|
|
145
|
+
webfetch: createWebFetchTool({
|
|
146
|
+
workspace,
|
|
147
|
+
spillDir: options.spillDir,
|
|
148
|
+
attachImagesToChat: false,
|
|
149
|
+
maxInlineImageBytes: 0,
|
|
150
|
+
// Same honest move as read: children can't ask the user or deliver
|
|
151
|
+
// attachments, so the honest guidance is to report the URL.
|
|
152
|
+
imageUnavailableHint:
|
|
153
|
+
"Its pixels are not available in a delegated child session — report the image's URL in your final message so the caller can fetch it.",
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tool names {@link createTaskChildTools} overrides (not parent re-exports). */
|
|
159
|
+
export const TASK_CHILD_TOOL_OVERRIDES = ["read", "webfetch"] as const;
|
|
160
|
+
|
|
161
|
+
/** Pure markdown for the task child's operating contract. */
|
|
162
|
+
export function buildTaskMarkdown(opts?: { workspaceNoun?: string }): string {
|
|
163
|
+
const noun = opts?.workspaceNoun ?? "workspace";
|
|
164
|
+
return `## Working as a delegated task
|
|
165
|
+
|
|
166
|
+
You are a delegated worker: a copy of the parent agent, handed one self-contained task in this ${noun}. Your **final message is your entire deliverable** — the caller sees nothing else you did, so make it complete and self-contained.
|
|
167
|
+
|
|
168
|
+
- **Do the task asked, completely.** Cite concrete paths and line references (\`src/parser.ts:42\`) for every claim about code, so the caller can jump straight to it.
|
|
169
|
+
- **Decide, don't ask.** You cannot ask the user anything: make the reasonable call yourself and note it in your report. If you're genuinely blocked, report the blocker as your result — never guess silently.
|
|
170
|
+
- **Stay in your write scope.** Touch only the files your task calls for; the caller may be running sibling workers in parallel with their own scopes, and overlapping edits clobber.
|
|
171
|
+
- **Honor the requested thoroughness.** "quick" means the first solid result and stop; "very thorough" means check every plausible angle before concluding; "medium" sits between. Unspecified means medium.
|
|
172
|
+
- **Delegate onward sparingly.** You have your own \`agent\` clone for genuinely independent subtasks, but never chain delegations more than one level deeper.
|
|
173
|
+
- **Background tasks work, but \`notify\` doesn't.** You can \`run_async\` and \`await_task\`, but \`notify\` watchers queue matches that never deliver — you don't idle waiting for user input, so use \`await_task\` or \`check_tasks\` to poll instead.
|
|
174
|
+
- **Report outcomes, not process.** Skip the narration of your work; include what changed, what you verified, and only what changes what the caller does next.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The task child's operating contract, wired as the subagent's
|
|
179
|
+
* `instructions/task.ts` re-export. Static and session-stable
|
|
180
|
+
* (prompt-cache safe).
|
|
181
|
+
*/
|
|
182
|
+
export function createTaskInstruction(opts?: { workspaceNoun?: string }) {
|
|
183
|
+
const instruction = defineInstructions({ markdown: buildTaskMarkdown(opts) });
|
|
184
|
+
return defineDynamic({
|
|
185
|
+
events: {
|
|
186
|
+
"session.started": () => instruction,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface TaskDescriptionOptions {
|
|
192
|
+
/** Display name of the pinned model (e.g. "Claude Sonnet 5"). */
|
|
193
|
+
modelName: string;
|
|
194
|
+
/**
|
|
195
|
+
* When the parent should pick this tier over its siblings, as one complete
|
|
196
|
+
* sentence (e.g. "Prefer it for quick, well-scoped subtasks — exploration,
|
|
197
|
+
* focused questions, mechanical edits — where a fast, cheap model is
|
|
198
|
+
* enough.").
|
|
199
|
+
*/
|
|
200
|
+
use: string;
|
|
201
|
+
/**
|
|
202
|
+
* The model's own catalog description (from the AI Gateway model catalog —
|
|
203
|
+
* see {@link fetchGatewayModelCatalog}). Checked in by the consumer, never
|
|
204
|
+
* fetched at agent build time: tool descriptions are part of the cached
|
|
205
|
+
* prompt prefix and must be static and offline-safe.
|
|
206
|
+
*
|
|
207
|
+
* Explicitly `| undefined` (the exactOptionalPropertyTypes idiom): consumers
|
|
208
|
+
* feed a `Record<string, string>` catalog lookup, which may miss — an
|
|
209
|
+
* explicit `undefined` reads the same as omitting the blurb.
|
|
210
|
+
*/
|
|
211
|
+
modelBlurb?: string | undefined;
|
|
212
|
+
/**
|
|
213
|
+
* What the child CANNOT do relative to the parent, as one complete
|
|
214
|
+
* sentence — name the excluded tools when the consumer excludes any
|
|
215
|
+
* (`excludedParentTools`), so the parent never delegates work the child
|
|
216
|
+
* can't perform. Omit only when the child truly mirrors the parent's
|
|
217
|
+
* authored toolset.
|
|
218
|
+
*/
|
|
219
|
+
capabilityNote?: string;
|
|
220
|
+
/** What the description calls the workspace. Defaults to "workspace". */
|
|
221
|
+
workspaceNoun?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Pure default for a task subagent's parent-facing tool description. */
|
|
225
|
+
export function buildTaskDescription(options: TaskDescriptionOptions): string {
|
|
226
|
+
const noun = options.workspaceNoun ?? "workspace";
|
|
227
|
+
const capability = options.capabilityNote ? ` ${options.capabilityNote}` : "";
|
|
228
|
+
const blurb = options.modelBlurb
|
|
229
|
+
? ` About ${options.modelName}: ${options.modelBlurb}`
|
|
230
|
+
: "";
|
|
231
|
+
return `Delegate one self-contained subtask to a copy of this agent pinned to ${options.modelName} — same ${noun}, fresh conversation. It cannot ask the user anything: it decides and reports.${capability} ${options.use} Pack the message with everything the child needs (it sees none of your history), name the exact deliverable and the thoroughness you want ("quick", "medium", or "very thorough"), and give parallel children non-overlapping write scopes.${blurb}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface TaskAgentOptions extends TaskDescriptionOptions {
|
|
235
|
+
/** The child's pinned model — the tier this subagent encodes. */
|
|
236
|
+
model: AgentDefinition["model"];
|
|
237
|
+
/**
|
|
238
|
+
* Parent-facing tool description override (eve requires one on a declared
|
|
239
|
+
* subagent). Defaults to {@link buildTaskDescription} over the other
|
|
240
|
+
* options.
|
|
241
|
+
*/
|
|
242
|
+
description?: string;
|
|
243
|
+
/** Reasoning effort forwarded to the child's model calls. */
|
|
244
|
+
reasoning?: AgentDefinition["reasoning"];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The `defineAgent` config for a consumer's
|
|
249
|
+
* `agent/subagents/task_<tier>/agent.ts`. The description is what the parent
|
|
250
|
+
* model reads to pick a tier — it carries the model's identity, the
|
|
251
|
+
* when-to-pick-it guidance, and the delegation contract, so the default is
|
|
252
|
+
* written for the parent, not the child.
|
|
253
|
+
*/
|
|
254
|
+
export function createTaskAgent(options: TaskAgentOptions) {
|
|
255
|
+
return defineAgent({
|
|
256
|
+
description: options.description ?? buildTaskDescription(options),
|
|
257
|
+
model: options.model,
|
|
258
|
+
...(options.reasoning !== undefined ? { reasoning: options.reasoning } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Gateway model catalog (for one-shot blurb-refresh scripts) --------------
|
|
263
|
+
|
|
264
|
+
/** One model entry from the AI Gateway's public model catalog. */
|
|
265
|
+
export interface GatewayModelInfo {
|
|
266
|
+
id: string;
|
|
267
|
+
name: string | undefined;
|
|
268
|
+
description: string | undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The AI Gateway's public model-catalog endpoint (no API key required). */
|
|
272
|
+
export const GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models";
|
|
273
|
+
|
|
274
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Parse the gateway catalog response body (`{ data: [{ id, name?,
|
|
280
|
+
* description? }, …] }`) into typed entries; `null` on any malformed shape.
|
|
281
|
+
* Same catalog the AI SDK's `gateway.getAvailableModels()` reads.
|
|
282
|
+
*/
|
|
283
|
+
export function parseGatewayModelCatalog(value: unknown): GatewayModelInfo[] | null {
|
|
284
|
+
if (!isRecord(value) || !Array.isArray(value.data)) return null;
|
|
285
|
+
const models: GatewayModelInfo[] = [];
|
|
286
|
+
for (const entry of value.data) {
|
|
287
|
+
if (!isRecord(entry) || typeof entry.id !== "string") return null;
|
|
288
|
+
models.push({
|
|
289
|
+
id: entry.id,
|
|
290
|
+
name: typeof entry.name === "string" ? entry.name : undefined,
|
|
291
|
+
description: typeof entry.description === "string" ? entry.description : undefined,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return models;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Fetch the AI Gateway model catalog — names + descriptions for every model
|
|
299
|
+
* the gateway serves. For ONE-SHOT refresh scripts that regenerate a
|
|
300
|
+
* consumer's checked-in model blurbs; never call it at agent build time (tool
|
|
301
|
+
* descriptions are part of the cached prompt prefix, so they must be static
|
|
302
|
+
* and offline-safe).
|
|
303
|
+
*/
|
|
304
|
+
export async function fetchGatewayModelCatalog(options?: {
|
|
305
|
+
url?: string;
|
|
306
|
+
/** Injectable fetch seam (the one call this makes); defaults to global fetch. */
|
|
307
|
+
fetchImpl?: (url: string) => Promise<Response>;
|
|
308
|
+
}): Promise<GatewayModelInfo[]> {
|
|
309
|
+
const url = options?.url ?? GATEWAY_MODELS_URL;
|
|
310
|
+
const fetchImpl = options?.fetchImpl ?? fetch;
|
|
311
|
+
const response = await fetchImpl(url);
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
throw new Error(`gateway model catalog fetch failed: ${response.status} ${url}`);
|
|
314
|
+
}
|
|
315
|
+
const parsed = parseGatewayModelCatalog(await response.json());
|
|
316
|
+
if (parsed === null) {
|
|
317
|
+
throw new Error(`gateway model catalog response has an unexpected shape: ${url}`);
|
|
318
|
+
}
|
|
319
|
+
return parsed;
|
|
320
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { TaskRegistry } from "../async-tasks";
|
|
4
|
+
import { postParkNotification } from "../park-delivery";
|
|
5
|
+
import type { CommandRunner } from "../run";
|
|
6
|
+
import { createOutputWatcher, formatWatchNotification } from "../watch-output";
|
|
7
|
+
import type { Workspace } from "../workspace";
|
|
8
|
+
|
|
9
|
+
// Overrides eve's built-in `bash`, which targets the isolated sandbox. This
|
|
10
|
+
// one runs commands on the real host, rooted at the workspace, so `git`,
|
|
11
|
+
// package managers, tests, and ripgrep all act on the actual checkout.
|
|
12
|
+
|
|
13
|
+
// Appended to the description so the model doesn't launch full-screen CLIs in
|
|
14
|
+
// a piped shell. Agents with a real-terminal tool override it to point there
|
|
15
|
+
// (see createStdlib's `bashInteractiveHint`).
|
|
16
|
+
const DEFAULT_INTERACTIVE_HINT =
|
|
17
|
+
"This is a piped shell with NO tty: avoid interactive or full-screen CLIs (a REPL, vim, an interactive installer/prompt) — those programs hang or degrade without a real terminal.";
|
|
18
|
+
|
|
19
|
+
export function createBashTool(opts: {
|
|
20
|
+
workspace: Workspace;
|
|
21
|
+
runner: CommandRunner;
|
|
22
|
+
registry: TaskRegistry;
|
|
23
|
+
noun: string;
|
|
24
|
+
interactiveHint?: string | undefined;
|
|
25
|
+
}) {
|
|
26
|
+
const { workspace, runner, registry, noun } = opts;
|
|
27
|
+
const interactiveHint = opts.interactiveHint ?? DEFAULT_INTERACTIVE_HINT;
|
|
28
|
+
return defineTool({
|
|
29
|
+
description:
|
|
30
|
+
`Run a shell command on the host, from the ${noun} root by default. Quick commands return normally. If the command is still running after foreground_ms, it keeps running in the background and returns a task_id plus current stdout/stderr; use check_tasks and await_task to monitor or collect the result. For a long-running command where you only care about a specific output signal (a failure line, a "listening on" banner), pass notify — if the command backgrounds, matching output is delivered to you as a message while you're idle, so you can keep working instead of polling. Use it for git, tests/builds/type-checks, ripgrep, dev servers, and anything the file tools don't cover. Very long output is truncated to its head and tail; the complete output is saved to a file named in the result — grep or read that file instead of re-running the command. This is a real shell on the user's machine with no sandbox and no undo — be careful with destructive commands. ` +
|
|
31
|
+
interactiveHint,
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
command: z.string().min(1).describe("The shell command to run."),
|
|
34
|
+
cwd: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe(`Working directory, relative to the ${noun} root. Defaults to the ${noun} root.`),
|
|
38
|
+
timeout_ms: z
|
|
39
|
+
.number()
|
|
40
|
+
.int()
|
|
41
|
+
.positive()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Kill the command after this many milliseconds (default 600000)."),
|
|
44
|
+
foreground_ms: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.positive()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("How long to wait before returning a background task handle (default 2000)."),
|
|
50
|
+
notify: z
|
|
51
|
+
.object({
|
|
52
|
+
pattern: z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.describe("Regex matched against complete output lines (stdout and stderr)."),
|
|
56
|
+
reason: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.describe("Short phrase naming what you're watching for, e.g. 'test failures'."),
|
|
60
|
+
debounce_ms: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.positive()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Minimum ms between match notifications (default 5000)."),
|
|
66
|
+
})
|
|
67
|
+
.optional()
|
|
68
|
+
.describe(
|
|
69
|
+
"Watch the command's output if it backgrounds: matching lines are delivered to you as a message while you're idle. No effect on a command that completes in the foreground.",
|
|
70
|
+
),
|
|
71
|
+
}),
|
|
72
|
+
async execute({ command, cwd, timeout_ms, foreground_ms, notify }, ctx) {
|
|
73
|
+
// Built before spawn so an invalid regex fails as a normal tool error;
|
|
74
|
+
// chunks buffer until we know whether the command backgrounds (a
|
|
75
|
+
// foreground completion needs no notification — its result returns).
|
|
76
|
+
const watcher = notify
|
|
77
|
+
? createOutputWatcher({ pattern: notify.pattern, debounceMs: notify.debounce_ms })
|
|
78
|
+
: null;
|
|
79
|
+
let feedLive: ((chunk: string) => void) | null = null;
|
|
80
|
+
const buffered: string[] = [];
|
|
81
|
+
const running = runner.startCommand(command, {
|
|
82
|
+
cwd,
|
|
83
|
+
timeoutMs: timeout_ms ?? 600_000,
|
|
84
|
+
onOutput: watcher
|
|
85
|
+
? (chunk) => {
|
|
86
|
+
if (feedLive) feedLive(chunk);
|
|
87
|
+
else buffered.push(chunk);
|
|
88
|
+
}
|
|
89
|
+
: undefined,
|
|
90
|
+
});
|
|
91
|
+
const result = await Promise.race([
|
|
92
|
+
running.result,
|
|
93
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), foreground_ms ?? 2_000)),
|
|
94
|
+
]);
|
|
95
|
+
if (result !== null) {
|
|
96
|
+
return {
|
|
97
|
+
workdir: workspace.root,
|
|
98
|
+
mode: "completed" as const,
|
|
99
|
+
exitCode: result.exitCode,
|
|
100
|
+
timedOut: result.timedOut,
|
|
101
|
+
stdout: result.stdout,
|
|
102
|
+
stderr: result.stderr,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const taskId = registry.spawnTask("bash", command, running.result);
|
|
106
|
+
registry.updateTaskProgress(taskId, running.progress());
|
|
107
|
+
const interval = setInterval(() => registry.updateTaskProgress(taskId, running.progress()), 500);
|
|
108
|
+
void running.result.finally(() => clearInterval(interval)).catch(() => undefined);
|
|
109
|
+
if (watcher && notify) {
|
|
110
|
+
const sessionId = ctx?.session?.id;
|
|
111
|
+
let matchCount = 0;
|
|
112
|
+
const post = (lines: readonly string[] | null) => {
|
|
113
|
+
if (!lines || !sessionId) return;
|
|
114
|
+
matchCount += 1;
|
|
115
|
+
postParkNotification(sessionId, {
|
|
116
|
+
key: `${taskId}#watch${matchCount}`,
|
|
117
|
+
text: formatWatchNotification({ taskId, label: command, reason: notify.reason, lines }),
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
feedLive = (chunk) => post(watcher.feed(chunk));
|
|
121
|
+
for (const chunk of buffered.splice(0)) feedLive(chunk);
|
|
122
|
+
void running.result
|
|
123
|
+
.finally(() => post(watcher.flush()))
|
|
124
|
+
.catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
workdir: workspace.root,
|
|
128
|
+
mode: "backgrounded" as const,
|
|
129
|
+
task_id: taskId,
|
|
130
|
+
status: "running" as const,
|
|
131
|
+
progress: running.progress(),
|
|
132
|
+
...(watcher
|
|
133
|
+
? {
|
|
134
|
+
watching: notify?.pattern,
|
|
135
|
+
note: "Command is still running in the background; matching output will be delivered to you as a message while you're idle. Continue independent work, or call await_task if your next step needs the result.",
|
|
136
|
+
}
|
|
137
|
+
: {
|
|
138
|
+
note: "Command is still running in the background. Continue independent work, then call check_tasks for live output or await_task when you need the final result.",
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
// A new tool (no eve default, so no disable shim needed): exact-string
|
|
8
|
+
// replacement in an existing file, the workhorse for focused edits. old_string
|
|
9
|
+
// must match once unless replace_all. Named `edit` to match Claude Code / opencode.
|
|
10
|
+
export function createEditTool(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:
|
|
20
|
+
"Replace an exact string in an existing file. By default old_string must occur exactly once — include enough surrounding context to make it unique. Set replace_all to replace every occurrence (e.g. renaming a symbol).",
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
path: z.string().min(1).describe(`File path, relative to the ${noun} root.`),
|
|
23
|
+
old_string: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe("Exact text to replace; must currently exist in the file."),
|
|
27
|
+
new_string: z.string().describe("Text to replace it with."),
|
|
28
|
+
replace_all: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Replace every occurrence instead of requiring a single match."),
|
|
32
|
+
}),
|
|
33
|
+
async execute({ path, old_string, new_string, replace_all }, ctx) {
|
|
34
|
+
const abs = workspace.resolve(path);
|
|
35
|
+
const rel = workspace.relativize(abs);
|
|
36
|
+
const fio = io(ctx);
|
|
37
|
+
// Serialized per path: eve runs a step's tool calls concurrently, and
|
|
38
|
+
// two unserialized edits to one file both read the same original text —
|
|
39
|
+
// the second write silently drops the first edit (see ../path-locks.ts).
|
|
40
|
+
return withPathLock(abs, async () => {
|
|
41
|
+
const bytes = await fio.readFile(abs);
|
|
42
|
+
if (bytes === null) throw new Error(`${rel} does not exist.`);
|
|
43
|
+
const before = bytes.toString("utf8");
|
|
44
|
+
const count = before.split(old_string).length - 1;
|
|
45
|
+
if (count === 0) throw new Error(`old_string not found in ${rel}.`);
|
|
46
|
+
if (count > 1 && !replace_all) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`old_string is not unique in ${rel} (${count} matches). Add surrounding context or set replace_all.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// split/join avoids String.replace's `$` substitution in new_string.
|
|
52
|
+
const after = before.split(old_string).join(new_string);
|
|
53
|
+
await fio.writeFile(abs, after);
|
|
54
|
+
return { ok: true, path: rel, replacements: count };
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { globToRegExp } from "../glob-match";
|
|
4
|
+
import type { Workspace } from "../workspace";
|
|
5
|
+
import { localIoProvider, type WorkspaceIoProvider } from "../workspace-io";
|
|
6
|
+
|
|
7
|
+
// Overrides eve's sandbox `glob`, searching the workspace through the I/O
|
|
8
|
+
// seam. Candidates come from the backend's file listing — locally `git
|
|
9
|
+
// ls-files` (exact .gitignore semantics — one fast spawn instead of walking
|
|
10
|
+
// build output) with the in-process walk as fallback; on a sandbox, the same
|
|
11
|
+
// git listing executed remotely.
|
|
12
|
+
export function createGlobTool(opts: {
|
|
13
|
+
workspace: Workspace;
|
|
14
|
+
noun: string;
|
|
15
|
+
/** Per-call I/O backend (../workspace-io.ts). Defaults to local node:fs. */
|
|
16
|
+
io?: WorkspaceIoProvider;
|
|
17
|
+
}) {
|
|
18
|
+
const { workspace, noun } = opts;
|
|
19
|
+
const io = opts.io ?? localIoProvider(workspace.root);
|
|
20
|
+
return defineTool({
|
|
21
|
+
description: `Find files in the ${noun} by glob pattern, returning ${noun}-relative paths. \`**\` spans directories, \`*\` matches within a path segment. A pattern without a leading \`**/\` is matched at any depth (so \`*.ts\` finds .ts files anywhere). Gitignored files and build/VCS dirs are skipped.`,
|
|
22
|
+
inputSchema: z.object({
|
|
23
|
+
pattern: z.string().min(1).describe("Glob pattern, e.g. `**/*.ts` or `src/tools/*.ts`."),
|
|
24
|
+
limit: z.number().int().positive().optional().describe("Max paths to return (default 500)."),
|
|
25
|
+
}),
|
|
26
|
+
async execute({ pattern, limit }, ctx) {
|
|
27
|
+
const normalized =
|
|
28
|
+
pattern.startsWith("**/") || pattern.startsWith("/")
|
|
29
|
+
? pattern.replace(/^\//, "")
|
|
30
|
+
: `**/${pattern}`;
|
|
31
|
+
const re = globToRegExp(normalized);
|
|
32
|
+
const max = limit ?? 500;
|
|
33
|
+
const candidates = await io(ctx).listFiles();
|
|
34
|
+
const files: string[] = [];
|
|
35
|
+
let truncated = false;
|
|
36
|
+
for (const file of candidates) {
|
|
37
|
+
if (re.test(file)) {
|
|
38
|
+
if (files.length >= max) {
|
|
39
|
+
truncated = true;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
files.push(file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
pattern,
|
|
47
|
+
count: files.length,
|
|
48
|
+
truncated,
|
|
49
|
+
files,
|
|
50
|
+
...(truncated
|
|
51
|
+
? { note: `More matches exist beyond the first ${max}. Use a more specific pattern, or raise limit.` }
|
|
52
|
+
: {}),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|