@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,80 @@
1
+ // Bounded windowing for `read`. Tool results enter the transcript
2
+ // permanently — an unbounded file read is the single biggest uncontrolled
3
+ // token source once the prompt prefix is stable — so every read gets a
4
+ // default line limit, a per-line cap, and a total character budget
5
+ // (opencode's 2000-line / 50 KB tool-output budget, adapted). See
6
+ // plans/ben/rib-speed-opencode-lessons.md.
7
+
8
+ // Default window when the caller passes no `limit`.
9
+ export const READ_FILE_DEFAULT_LINE_LIMIT = 2_000;
10
+ // A single line longer than this (minified bundles, data blobs) is clipped.
11
+ export const READ_FILE_MAX_LINE_CHARS = 2_000;
12
+ // Total budget for the numbered content of one call.
13
+ export const READ_FILE_MAX_CONTENT_CHARS = 50_000;
14
+ // `read` refuses files bigger than this outright (stat guard — the file
15
+ // is never read); the model is steered to `bash` for surgical extraction.
16
+ export const READ_FILE_MAX_BYTES = 10_000_000;
17
+
18
+ export interface FileView {
19
+ readonly totalLines: number;
20
+ /** 1-based first line of the window. */
21
+ readonly startLine: number;
22
+ /** 1-based last line included; `startLine - 1` when the window is empty. */
23
+ readonly endLine: number;
24
+ /** Line-numbered content, `NNNNNN|text` per line. */
25
+ readonly content: string;
26
+ /** True when the view was cut short of the requested window (budget) or a line was clipped. */
27
+ readonly truncated: boolean;
28
+ /** Continuation guidance when there is more file past `endLine`, else null. */
29
+ readonly note: string | null;
30
+ }
31
+
32
+ /** Build the bounded, line-numbered window of `text` for a read result. */
33
+ export function buildFileView(
34
+ text: string,
35
+ opts: { offset?: number | undefined; limit?: number | undefined } = {},
36
+ ): FileView {
37
+ const lines = text.split("\n");
38
+ const start = opts.offset ? opts.offset - 1 : 0;
39
+ const requestedEnd = Math.min(start + (opts.limit ?? READ_FILE_DEFAULT_LINE_LIMIT), lines.length);
40
+
41
+ const parts: string[] = [];
42
+ let chars = 0;
43
+ let included = 0;
44
+ let clippedLine = false;
45
+ let budgetStopped = false;
46
+ for (let i = start; i < requestedEnd; i++) {
47
+ const raw = lines[i];
48
+ if (raw === undefined) break;
49
+ const clipped =
50
+ raw.length > READ_FILE_MAX_LINE_CHARS
51
+ ? `${raw.slice(0, READ_FILE_MAX_LINE_CHARS)}… [line truncated]`
52
+ : raw;
53
+ if (clipped !== raw) clippedLine = true;
54
+ const numbered = `${String(i + 1).padStart(6)}|${clipped}`;
55
+ // Always include at least one line so a pathological first line still returns something.
56
+ if (included > 0 && chars + numbered.length + 1 > READ_FILE_MAX_CONTENT_CHARS) {
57
+ budgetStopped = true;
58
+ break;
59
+ }
60
+ parts.push(numbered);
61
+ chars += numbered.length + 1;
62
+ included += 1;
63
+ }
64
+
65
+ const endLine = start + included;
66
+ const note =
67
+ included === 0 && start >= lines.length
68
+ ? `Offset ${start + 1} is past the end of the file (${lines.length} lines).`
69
+ : endLine < lines.length
70
+ ? `Showing lines ${start + 1}–${endLine} of ${lines.length}${budgetStopped ? " (output budget reached)" : ""}. Continue with offset=${endLine + 1}, or use grep to locate what you need.`
71
+ : null;
72
+ return {
73
+ totalLines: lines.length,
74
+ startLine: start + 1,
75
+ endLine,
76
+ content: parts.join("\n"),
77
+ truncated: budgetStopped || clippedLine,
78
+ note,
79
+ };
80
+ }
@@ -0,0 +1,115 @@
1
+ // Stream guards for the gateway fetch. Neither eve's `defineAgent` nor the AI
2
+ // SDK's gateway provider exposes per-attempt timeouts, so a model call that
3
+ // hangs — headers never arrive, or the SSE body goes quiet mid-stream — hangs
4
+ // the turn forever (we measured a `step.started` that sat silent for good, and
5
+ // a retry loop that burned 33 minutes on an unresponsive connection). The one
6
+ // seam the provider does expose is `fetch`; this wrapper adds the two guards a
7
+ // streaming call needs:
8
+ //
9
+ // - **first byte**: abort when response headers don't arrive in time (the
10
+ // request-level hang);
11
+ // - **idle**: abort when the response body goes quiet between chunks (the
12
+ // mid-stream hang — a dropped connection the TCP stack never surfaces).
13
+ //
14
+ // A guard firing rejects/errors like any network failure, so the AI SDK's
15
+ // normal retry-with-backoff takes over instead of waiting on a dead socket.
16
+
17
+ type FetchLike = typeof globalThis.fetch;
18
+ // The call signature alone: Bun's `typeof fetch` also carries `preconnect`,
19
+ // which test doubles (and the wrapper itself) shouldn't have to fake.
20
+ type FetchCall = (
21
+ input: Parameters<FetchLike>[0],
22
+ init?: Parameters<FetchLike>[1],
23
+ ) => Promise<Response>;
24
+
25
+ export interface StreamGuardOptions {
26
+ /** Max wait for response headers, ms. */
27
+ readonly firstByteMs: number;
28
+ /** Max quiet gap between response-body chunks, ms. */
29
+ readonly idleMs: number;
30
+ }
31
+
32
+ // Generous on purpose: the point is to convert a *dead* connection into a
33
+ // retryable error, not to race a slow-but-alive model. Headers should arrive
34
+ // in seconds; reasoning models can legitimately pause between chunks, so the
35
+ // idle guard gets minutes.
36
+ export const DEFAULT_STREAM_GUARDS: StreamGuardOptions = {
37
+ firstByteMs: 60_000,
38
+ idleMs: 180_000,
39
+ };
40
+
41
+ /**
42
+ * Wrap a fetch with first-byte and idle timeouts. The returned fetch chains
43
+ * any caller-provided abort signal, preserves status/headers, and passes
44
+ * body-less responses through untouched.
45
+ */
46
+ export function withStreamGuards(
47
+ baseFetch: FetchCall,
48
+ options: StreamGuardOptions = DEFAULT_STREAM_GUARDS,
49
+ ): FetchLike {
50
+ const guarded: FetchCall = async (input, init) => {
51
+ const controller = new AbortController();
52
+ const outer = init?.signal;
53
+ if (outer != null) {
54
+ if (outer.aborted) controller.abort(outer.reason);
55
+ else outer.addEventListener("abort", () => controller.abort(outer.reason), { once: true });
56
+ }
57
+
58
+ const firstByteTimer = setTimeout(() => {
59
+ controller.abort(
60
+ new Error(`gateway response headers not received within ${options.firstByteMs}ms`),
61
+ );
62
+ }, options.firstByteMs);
63
+
64
+ let response: Response;
65
+ try {
66
+ response = await baseFetch(input, { ...init, signal: controller.signal });
67
+ } finally {
68
+ clearTimeout(firstByteTimer);
69
+ }
70
+
71
+ const body = response.body;
72
+ if (body === null) return response;
73
+
74
+ // Re-wrap the body in a pull-based stream that races every read against
75
+ // the idle timer. The timer is per-read, so a healthy stream never pays
76
+ // more than one pending timeout, and a stalled one errors (and aborts the
77
+ // underlying request) after exactly `idleMs` of silence.
78
+ const reader = body.getReader();
79
+ const guarded = new ReadableStream<Uint8Array>({
80
+ async pull(streamController) {
81
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
82
+ const idle = new Promise<never>((_, reject) => {
83
+ idleTimer = setTimeout(() => {
84
+ const reason = new Error(`gateway stream idle for ${options.idleMs}ms`);
85
+ controller.abort(reason);
86
+ reject(reason);
87
+ }, options.idleMs);
88
+ });
89
+ try {
90
+ const result = await Promise.race([reader.read(), idle]);
91
+ if (result.done) streamController.close();
92
+ else streamController.enqueue(result.value);
93
+ } catch (error) {
94
+ await reader.cancel(error).catch(() => {});
95
+ throw error;
96
+ } finally {
97
+ clearTimeout(idleTimer);
98
+ }
99
+ },
100
+ async cancel(reason) {
101
+ await reader.cancel(reason).catch(() => {});
102
+ },
103
+ });
104
+
105
+ return new Response(guarded, {
106
+ status: response.status,
107
+ statusText: response.statusText,
108
+ headers: response.headers,
109
+ });
110
+ };
111
+ // Bun's `typeof fetch` requires `preconnect` (a DNS/TLS warm-up hint);
112
+ // delegate to the global's so the wrapper satisfies the type under both
113
+ // runtimes. Nothing on the request path calls it.
114
+ return Object.assign(guarded, { preconnect: globalThis.fetch.preconnect });
115
+ }
@@ -0,0 +1,13 @@
1
+ // Convert a glob to an anchored RegExp over forward-slash paths. `**` (or `**/`)
2
+ // spans any number of directories; `*` matches within a single segment; `?` one
3
+ // non-separator char. Pure and dependency-free so `glob`, `grep`, and their
4
+ // tests all share one matcher.
5
+ export function globToRegExp(glob: string): RegExp {
6
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7
+ const body = escaped
8
+ .replace(/\*\*\/?/g, "\u0000") // placeholder for "any dirs"
9
+ .replace(/\*/g, "[^/]*")
10
+ .replace(/\?/g, "[^/]")
11
+ .replace(/\u0000/g, "(?:.*/)?");
12
+ return new RegExp(`^${body}$`);
13
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { Client } from "eve/client";
2
+ import { defineHook } from "eve/hooks";
3
+ import type { ChatAttachment } from "./attachments";
4
+ import {
5
+ createParkDeliveryState,
6
+ setParkNotificationHandler,
7
+ type ParkDeliveryRequest,
8
+ } from "./park-delivery";
9
+ import {
10
+ buildRedeliveryMessage,
11
+ redeliveryFromEvent,
12
+ type RedeliveryMessagePart,
13
+ } from "./redeliver";
14
+ import type { SteerMessage } from "./steer";
15
+ import { createSteerInbox } from "./steer-inbox";
16
+
17
+ // The effectful half of park delivery (pure core: ./park-delivery.ts). An
18
+ // agent wires it as one hook file:
19
+ //
20
+ // // agent/hooks/park-delivery.ts
21
+ // import { createParkDeliveryHook } from "@zocomputer/agent-sdk";
22
+ // export default createParkDeliveryHook();
23
+ //
24
+ // Three producers feed it:
25
+ // - **Read media** (./redeliver.ts): `action.result` events carry read's raw
26
+ // output (bytes included — toModelOutput only narrows what the model sees);
27
+ // on park the images/video/audio go back into the session as a real user
28
+ // turn.
29
+ // - **Background-task notifications** (./watch-output.ts): a bash/run_async
30
+ // watcher match or a completion notice posted through the notification
31
+ // bridge; delivered on park — or immediately, when the match lands while
32
+ // the session is already parked (a background task finishing mid-park is
33
+ // the whole point of the feature).
34
+ // - **Leftover steers** (./steer-inbox.ts, when `steer.dir` is set): a steered
35
+ // message normally rides the next completing tool result (./steer-tool.ts),
36
+ // but a turn that ends first strands the inbox — on park the hook drains it
37
+ // and sends the messages as the user turn they were meant to be.
38
+ //
39
+ // The send goes through eve's HTTP API on loopback — the hook context hands us
40
+ // the continuation token, and eve's localDev auth accepts loopback callers.
41
+ // Server-side by construction: no browser, no cockpit, no host app.
42
+ //
43
+ // Hooks are observe-only for *model context*, so this doesn't inject anything
44
+ // mid-turn — it starts the NEXT turn, exactly like a user hitting send. The
45
+ // send is fire-and-forget from the handler (awaiting it would stall eve's hook
46
+ // dispatch on a POST back into the same process) with a short retry ladder:
47
+ // the park→send window can race turn finalization or a real user message, and
48
+ // a send that still fails re-queues for the session's next park.
49
+
50
+ const RETRY_DELAYS_MS = [500, 2_000, 5_000];
51
+
52
+ /** What one park delivery carries: read media, a note, or a steered message. */
53
+ type DeliveryPayload =
54
+ | { readonly kind: "media"; readonly attachment: ChatAttachment }
55
+ | { readonly kind: "note"; readonly text: string }
56
+ | { readonly kind: "steer"; readonly message: SteerMessage };
57
+
58
+ function buildDeliveryMessage(
59
+ request: ParkDeliveryRequest<DeliveryPayload>,
60
+ ): RedeliveryMessagePart[] {
61
+ const media = request.items.flatMap((item) =>
62
+ item.payload.kind === "media"
63
+ ? [{ toolCallId: item.key, attachment: item.payload.attachment }]
64
+ : [],
65
+ );
66
+ const notes = request.items.flatMap((item) =>
67
+ item.payload.kind === "note" ? [item.payload.text] : [],
68
+ );
69
+ // Steers are the user's own words — deliver them verbatim, first.
70
+ const steers = request.items.flatMap((item) =>
71
+ item.payload.kind === "steer" ? [item.payload.message.text] : [],
72
+ );
73
+ return [
74
+ ...steers.map((text) => ({ type: "text" as const, text })),
75
+ ...(media.length > 0 ? buildRedeliveryMessage(media) : []),
76
+ ...notes.map((text) => ({ type: "text" as const, text })),
77
+ ];
78
+ }
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ function isSessionWaiting(event: unknown): boolean {
85
+ return isRecord(event) && event.type === "session.waiting";
86
+ }
87
+
88
+ export interface ParkDeliveryOptions {
89
+ /**
90
+ * Base URL of this agent's own eve server. Defaults to loopback on the
91
+ * server's port (`$PORT`, eve dev's default 2000 otherwise).
92
+ */
93
+ serverUrl?: string;
94
+ /** Log a line per delivery/failure (default true — it explains agent turns). */
95
+ log?: boolean;
96
+ /**
97
+ * The steer inbox dir (the same one passed to `createStdlib`). When set,
98
+ * steered messages a turn ends before delivering (no tool completed after
99
+ * they arrived) go out on park as the next user turn.
100
+ */
101
+ steer?: { dir: string };
102
+ }
103
+
104
+ export function createParkDeliveryHook(options: ParkDeliveryOptions = {}) {
105
+ const serverUrl =
106
+ options.serverUrl ?? `http://127.0.0.1:${process.env.PORT ?? "2000"}`;
107
+ const log = options.log ?? true;
108
+ const steerInbox = options.steer ? createSteerInbox({ dir: options.steer.dir }) : null;
109
+ const state = createParkDeliveryState<DeliveryPayload>();
110
+
111
+ async function deliver(request: ParkDeliveryRequest<DeliveryPayload>): Promise<void> {
112
+ const client = new Client({ host: serverUrl });
113
+ const message = buildDeliveryMessage(request);
114
+ for (let attempt = 0; ; attempt++) {
115
+ try {
116
+ const session = client.session({
117
+ sessionId: request.sessionId,
118
+ continuationToken: request.continuationToken,
119
+ streamIndex: 0,
120
+ });
121
+ const response = await session.send({ message });
122
+ // The continue route echoes the target session id; a mismatch means
123
+ // eve get-or-created a NEW session (a stale/mis-scoped token) — that's
124
+ // a failed delivery even though the POST succeeded.
125
+ if (response.sessionId !== request.sessionId) {
126
+ throw new Error(
127
+ `park delivery landed on ${response.sessionId} instead of ${request.sessionId} (continuation token mismatch)`,
128
+ );
129
+ }
130
+ // Drain the turn acknowledgement; the turn itself streams to whoever
131
+ // is watching the session. We only need the send to be accepted.
132
+ await response.result();
133
+ const next = state.settle(request, true);
134
+ if (log) {
135
+ const labels = request.items.map((item) =>
136
+ item.payload.kind === "media" ? item.payload.attachment.filename : item.key,
137
+ );
138
+ console.log(
139
+ `[agent-sdk] park delivery to ${request.sessionId}: ${labels.join(", ")}`,
140
+ );
141
+ }
142
+ // Items may have queued while we were delivering; dispatch them now.
143
+ if (next) void deliver(next);
144
+ return;
145
+ } catch (error) {
146
+ const delay = RETRY_DELAYS_MS[attempt];
147
+ if (delay === undefined) {
148
+ state.settle(request, false);
149
+ if (log) {
150
+ console.warn(
151
+ `[agent-sdk] park delivery to ${request.sessionId} failed; will retry on next park:`,
152
+ error,
153
+ );
154
+ }
155
+ return;
156
+ }
157
+ await new Promise((resolve) => setTimeout(resolve, delay));
158
+ }
159
+ }
160
+ }
161
+
162
+ // Tool code (bash watchers, run_async completion notices) posts through the
163
+ // process-global bridge; a notification arriving while the session is
164
+ // parked returns a request right here — no stream event will fire for it.
165
+ setParkNotificationHandler((sessionId, notification) => {
166
+ const request = state.enqueue(sessionId, {
167
+ key: notification.key,
168
+ payload: { kind: "note", text: notification.text },
169
+ });
170
+ if (request) void deliver(request);
171
+ });
172
+
173
+ return defineHook({
174
+ events: {
175
+ "*"(event, ctx) {
176
+ const meta = {
177
+ sessionId: ctx.session.id,
178
+ continuationToken: ctx.channel.continuationToken,
179
+ };
180
+ // Drain leftover steers BEFORE observe processes the park, so they
181
+ // ride the same delivery the waiting event triggers. The whole drain
182
+ // enqueues as one batch — item-by-item enqueue would let the first
183
+ // steer's immediate flush (session already parked) split the rest
184
+ // into a second turn. Failed sends re-queue in the delivery state
185
+ // (keyed by steer id), not the file.
186
+ if (steerInbox && isSessionWaiting(event)) {
187
+ const steers = steerInbox.drain(meta.sessionId);
188
+ if (steers.length > 0) {
189
+ const flush = state.enqueueAll(
190
+ meta.sessionId,
191
+ steers.map((message) => ({
192
+ key: `steer:${message.id}`,
193
+ payload: { kind: "steer" as const, message },
194
+ })),
195
+ );
196
+ if (flush) void deliver(flush);
197
+ }
198
+ }
199
+ const request = state.observe(event, meta);
200
+ if (request) void deliver(request);
201
+ const found = redeliveryFromEvent(event);
202
+ if (found) {
203
+ // Media arrive on action.result (a non-waiting event), so this
204
+ // enqueue never fires an immediate delivery — they ride the park.
205
+ state.enqueue(meta.sessionId, {
206
+ key: found.toolCallId,
207
+ payload: { kind: "media", attachment: found.attachment },
208
+ });
209
+ }
210
+ },
211
+ },
212
+ });
213
+ }