@openape/apes 1.25.1 → 1.26.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/dist/chunk-VHZEVW2N.js +998 -0
- package/dist/chunk-VHZEVW2N.js.map +1 -0
- package/dist/cli.js +7 -7
- package/dist/index.js +1 -1
- package/dist/{server-LT5BFMTP.js → server-64H5O3CG.js} +2 -2
- package/package.json +2 -2
- package/dist/chunk-L2V3CW5B.js +0 -605
- package/dist/chunk-L2V3CW5B.js.map +0 -1
- /package/dist/{server-LT5BFMTP.js.map → server-64H5O3CG.js.map} +0 -0
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var CliError = class extends Error {
|
|
5
|
+
constructor(message, exitCode = 1) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
this.name = "CliError";
|
|
9
|
+
}
|
|
10
|
+
exitCode;
|
|
11
|
+
};
|
|
12
|
+
var CliExit = class extends Error {
|
|
13
|
+
constructor(exitCode = 0) {
|
|
14
|
+
super("");
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
this.name = "CliExit";
|
|
17
|
+
}
|
|
18
|
+
exitCode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/duration.ts
|
|
22
|
+
function parseDuration(value) {
|
|
23
|
+
const match = value.match(/^(\d+)\s*([smhd])$/);
|
|
24
|
+
if (!match) {
|
|
25
|
+
throw new Error(`Invalid duration format: "${value}". Use e.g. 30m, 1h, 7d`);
|
|
26
|
+
}
|
|
27
|
+
const amount = Number.parseInt(match[1], 10);
|
|
28
|
+
switch (match[2]) {
|
|
29
|
+
case "s":
|
|
30
|
+
return amount;
|
|
31
|
+
case "m":
|
|
32
|
+
return amount * 60;
|
|
33
|
+
case "h":
|
|
34
|
+
return amount * 3600;
|
|
35
|
+
case "d":
|
|
36
|
+
return amount * 86400;
|
|
37
|
+
default:
|
|
38
|
+
throw new Error(`Unknown duration unit: ${match[2]}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/lib/agent-tools/ape-shell-exec.ts
|
|
43
|
+
import { spawn } from "child_process";
|
|
44
|
+
var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
45
|
+
var MAX_STDIO_BYTES = 64 * 1024;
|
|
46
|
+
var BIN = "ape-shell";
|
|
47
|
+
function capStdio(s) {
|
|
48
|
+
const buf = Buffer.from(s, "utf8");
|
|
49
|
+
if (buf.byteLength <= MAX_STDIO_BYTES) return s;
|
|
50
|
+
return `${buf.subarray(0, MAX_STDIO_BYTES).toString("utf8")}
|
|
51
|
+
[truncated to ${MAX_STDIO_BYTES} bytes]`;
|
|
52
|
+
}
|
|
53
|
+
function runApeShell(cmd, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
54
|
+
return new Promise((resolveResult) => {
|
|
55
|
+
const child = spawn(BIN, ["-c", cmd], {
|
|
56
|
+
env: { ...process.env, APE_WAIT: "1" },
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
58
|
+
});
|
|
59
|
+
let stdout = "";
|
|
60
|
+
let stderr = "";
|
|
61
|
+
let timedOut = false;
|
|
62
|
+
let spawnError = null;
|
|
63
|
+
child.stdout.on("data", (chunk) => {
|
|
64
|
+
stdout += chunk.toString("utf8");
|
|
65
|
+
});
|
|
66
|
+
child.stderr.on("data", (chunk) => {
|
|
67
|
+
stderr += chunk.toString("utf8");
|
|
68
|
+
});
|
|
69
|
+
child.on("error", (err) => {
|
|
70
|
+
spawnError = err;
|
|
71
|
+
});
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
child.kill("SIGTERM");
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
try {
|
|
77
|
+
child.kill("SIGKILL");
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}, 5e3);
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
child.on("close", (code) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
if (spawnError) {
|
|
85
|
+
resolveResult({
|
|
86
|
+
stdout: "",
|
|
87
|
+
stderr: "",
|
|
88
|
+
exit_code: -1,
|
|
89
|
+
error: spawnError.message,
|
|
90
|
+
hint: `Could not exec '${BIN}'. The agent host needs @openape/apes installed globally so ape-shell is on PATH.`
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolveResult({
|
|
95
|
+
stdout: capStdio(stdout),
|
|
96
|
+
stderr: capStdio(stderr),
|
|
97
|
+
exit_code: code ?? -1,
|
|
98
|
+
...timedOut ? { timed_out: true } : {}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
var DEFAULTS = { DEFAULT_TIMEOUT_MS };
|
|
104
|
+
|
|
105
|
+
// src/lib/agent-tools/bash.ts
|
|
106
|
+
var bashTools = [
|
|
107
|
+
{
|
|
108
|
+
name: "bash",
|
|
109
|
+
description: "Run a shell command on the agent host. Every invocation goes through the OpenApe DDISA grant cycle \u2014 auto-approved if the owner has a matching YOLO scope, otherwise the owner gets a push notification to approve. Runs as the agent's macOS user, so file/network access is limited to what that user can see. Returns stdout, stderr, and exit code. For repeated command patterns ask the owner to set up a YOLO scope so approvals don't pile up.",
|
|
110
|
+
parameters: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
cmd: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "Shell command to run, e.g. `ls -la ~/Documents`, `git status`, `curl -fsSL https://example.com`. The whole string is passed to `bash -c`; quote internally as needed."
|
|
116
|
+
},
|
|
117
|
+
timeout_ms: {
|
|
118
|
+
type: "number",
|
|
119
|
+
description: "Wall-clock cap for the whole approval-and-run cycle in milliseconds. Default 300000 (5 min). Approval waits count against this budget."
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
required: ["cmd"]
|
|
123
|
+
},
|
|
124
|
+
execute: async (args) => {
|
|
125
|
+
const a = args;
|
|
126
|
+
if (typeof a.cmd !== "string" || a.cmd.trim() === "") {
|
|
127
|
+
throw new Error("cmd must be a non-empty string");
|
|
128
|
+
}
|
|
129
|
+
const timeout = typeof a.timeout_ms === "number" && a.timeout_ms > 0 ? a.timeout_ms : DEFAULTS.DEFAULT_TIMEOUT_MS;
|
|
130
|
+
return await runApeShell(a.cmd, timeout);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// src/lib/agent-tools/file.ts
|
|
136
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
137
|
+
import { homedir } from "os";
|
|
138
|
+
import { dirname, normalize, resolve } from "path";
|
|
139
|
+
var MAX_BYTES = 1024 * 1024;
|
|
140
|
+
function jailPath(input) {
|
|
141
|
+
if (typeof input !== "string" || input === "") {
|
|
142
|
+
throw new Error("path must be a non-empty string");
|
|
143
|
+
}
|
|
144
|
+
const home = homedir();
|
|
145
|
+
const candidate = input.startsWith("~/") ? resolve(home, input.slice(2)) : input.startsWith("/") ? normalize(input) : resolve(home, input);
|
|
146
|
+
if (candidate !== home && !candidate.startsWith(`${home}/`)) {
|
|
147
|
+
throw new Error(`path "${input}" resolves outside the agent's home`);
|
|
148
|
+
}
|
|
149
|
+
return candidate;
|
|
150
|
+
}
|
|
151
|
+
var fileTools = [
|
|
152
|
+
{
|
|
153
|
+
name: "file.read",
|
|
154
|
+
description: "Read a UTF-8 file from the agent's home directory ($HOME). Capped at 1MB. Path traversal blocked.",
|
|
155
|
+
parameters: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: {
|
|
158
|
+
path: { type: "string", description: "Path relative to $HOME (or absolute under $HOME). `..` segments are rejected." }
|
|
159
|
+
},
|
|
160
|
+
required: ["path"]
|
|
161
|
+
},
|
|
162
|
+
execute: async (args) => {
|
|
163
|
+
const a = args;
|
|
164
|
+
const p = jailPath(a.path);
|
|
165
|
+
const content = readFileSync(p, "utf8");
|
|
166
|
+
if (Buffer.byteLength(content, "utf8") > MAX_BYTES) {
|
|
167
|
+
return { path: p, truncated: true, content: content.slice(0, MAX_BYTES) };
|
|
168
|
+
}
|
|
169
|
+
return { path: p, truncated: false, content };
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "file.write",
|
|
174
|
+
description: "Write a UTF-8 file under the agent's home directory. Creates parent dirs as needed. 1MB max.",
|
|
175
|
+
parameters: {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
path: { type: "string", description: "Path relative to $HOME (or absolute under $HOME)." },
|
|
179
|
+
content: { type: "string", description: "File body. Existing files are overwritten." }
|
|
180
|
+
},
|
|
181
|
+
required: ["path", "content"]
|
|
182
|
+
},
|
|
183
|
+
execute: async (args) => {
|
|
184
|
+
const a = args;
|
|
185
|
+
if (typeof a.content !== "string") throw new Error("content must be a string");
|
|
186
|
+
if (Buffer.byteLength(a.content, "utf8") > MAX_BYTES) {
|
|
187
|
+
throw new Error(`content exceeds ${MAX_BYTES} byte cap`);
|
|
188
|
+
}
|
|
189
|
+
const p = jailPath(a.path);
|
|
190
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
191
|
+
writeFileSync(p, a.content, { encoding: "utf8" });
|
|
192
|
+
return { path: p, bytes: Buffer.byteLength(a.content, "utf8") };
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "file.edit",
|
|
197
|
+
description: "Replace an exact substring in a file under the agent's home directory. Prefer this over file.write for edits \u2014 it touches only the changed region instead of rewriting the whole file. `old_string` must appear exactly once unless `replace_all` is true. Path traversal blocked, 1MB max.",
|
|
198
|
+
parameters: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {
|
|
201
|
+
path: { type: "string", description: "Path relative to $HOME (or absolute under $HOME)." },
|
|
202
|
+
old_string: { type: "string", description: "Exact text to replace. Include enough surrounding context to be unique unless replace_all is set." },
|
|
203
|
+
new_string: { type: "string", description: "Replacement text. Must differ from old_string." },
|
|
204
|
+
replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring a unique match. Default false." }
|
|
205
|
+
},
|
|
206
|
+
required: ["path", "old_string", "new_string"]
|
|
207
|
+
},
|
|
208
|
+
execute: async (args) => {
|
|
209
|
+
const a = args;
|
|
210
|
+
if (typeof a.old_string !== "string" || a.old_string === "") {
|
|
211
|
+
throw new Error("old_string must be a non-empty string");
|
|
212
|
+
}
|
|
213
|
+
if (typeof a.new_string !== "string") {
|
|
214
|
+
throw new TypeError("new_string must be a string");
|
|
215
|
+
}
|
|
216
|
+
if (a.old_string === a.new_string) {
|
|
217
|
+
throw new Error("old_string and new_string are identical \u2014 nothing to change");
|
|
218
|
+
}
|
|
219
|
+
const replaceAll = a.replace_all === true;
|
|
220
|
+
const p = jailPath(a.path);
|
|
221
|
+
const before = readFileSync(p, "utf8");
|
|
222
|
+
const occurrences = before.split(a.old_string).length - 1;
|
|
223
|
+
if (occurrences === 0) {
|
|
224
|
+
throw new Error("old_string not found in file");
|
|
225
|
+
}
|
|
226
|
+
if (occurrences > 1 && !replaceAll) {
|
|
227
|
+
throw new Error(`old_string occurs ${occurrences} times \u2014 pass replace_all:true or add surrounding context to make it unique`);
|
|
228
|
+
}
|
|
229
|
+
const after = replaceAll ? before.split(a.old_string).join(a.new_string) : before.replace(a.old_string, a.new_string);
|
|
230
|
+
if (Buffer.byteLength(after, "utf8") > MAX_BYTES) {
|
|
231
|
+
throw new Error(`result exceeds ${MAX_BYTES} byte cap`);
|
|
232
|
+
}
|
|
233
|
+
writeFileSync(p, after, { encoding: "utf8" });
|
|
234
|
+
return { path: p, replacements: replaceAll ? occurrences : 1 };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
// src/lib/coding/forge.ts
|
|
240
|
+
var BRANCH_RE = /^[\w./-]{1,200}$/;
|
|
241
|
+
var ID_RE = /^\d{1,12}$/;
|
|
242
|
+
function shq(s) {
|
|
243
|
+
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
244
|
+
}
|
|
245
|
+
function assertBranch(v) {
|
|
246
|
+
if (typeof v !== "string" || !BRANCH_RE.test(v)) {
|
|
247
|
+
throw new Error("branch must match ^[A-Za-z0-9._/-]{1,200}$");
|
|
248
|
+
}
|
|
249
|
+
return v;
|
|
250
|
+
}
|
|
251
|
+
function assertId(v) {
|
|
252
|
+
if (typeof v !== "string" && typeof v !== "number") throw new Error("id required");
|
|
253
|
+
const s = String(v);
|
|
254
|
+
if (!ID_RE.test(s)) throw new Error("id must be a number");
|
|
255
|
+
return s;
|
|
256
|
+
}
|
|
257
|
+
var githubAdapter = {
|
|
258
|
+
id: "github",
|
|
259
|
+
matchesRemote: (url) => /github\.com/i.test(url),
|
|
260
|
+
prCreate: (i) => {
|
|
261
|
+
const head = assertBranch(i.head);
|
|
262
|
+
const parts = ["gh", "pr", "create", "--title", shq(i.title), "--body", shq(i.body), "--head", shq(head)];
|
|
263
|
+
if (i.base !== void 0) parts.push("--base", shq(assertBranch(i.base)));
|
|
264
|
+
return parts.join(" ");
|
|
265
|
+
},
|
|
266
|
+
prMerge: (i) => {
|
|
267
|
+
const ref = String(i.ref);
|
|
268
|
+
const refTok = ID_RE.test(ref) ? ref : assertBranch(ref);
|
|
269
|
+
const parts = ["gh", "pr", "merge", shq(refTok)];
|
|
270
|
+
if (i.squash === true) parts.push("--squash");
|
|
271
|
+
if (i.auto) parts.push("--auto");
|
|
272
|
+
if (i.deleteBranch) parts.push("--delete-branch");
|
|
273
|
+
return parts.join(" ");
|
|
274
|
+
},
|
|
275
|
+
prStatus: (ref) => {
|
|
276
|
+
const r = String(ref);
|
|
277
|
+
const refTok = ID_RE.test(r) ? r : assertBranch(r);
|
|
278
|
+
return `gh pr view ${shq(refTok)} --json state,mergeStateStatus,statusCheckRollup,reviewDecision`;
|
|
279
|
+
},
|
|
280
|
+
issueGet: (ref) => `gh issue view ${assertId(ref)} --json number,title,body,labels`
|
|
281
|
+
};
|
|
282
|
+
var azureAdapter = {
|
|
283
|
+
id: "azure",
|
|
284
|
+
matchesRemote: (url) => /dev\.azure\.com|visualstudio\.com/i.test(url),
|
|
285
|
+
prCreate: (i) => {
|
|
286
|
+
const head = assertBranch(i.head);
|
|
287
|
+
const parts = ["az", "repos", "pr", "create", "--title", shq(i.title), "--description", shq(i.body), "--source-branch", shq(head)];
|
|
288
|
+
if (i.base !== void 0) parts.push("--target-branch", shq(assertBranch(i.base)));
|
|
289
|
+
return parts.join(" ");
|
|
290
|
+
},
|
|
291
|
+
prMerge: (i) => {
|
|
292
|
+
const id = assertId(i.ref);
|
|
293
|
+
const parts = ["az", "repos", "pr", "update", "--id", id];
|
|
294
|
+
if (i.auto) parts.push("--auto-complete", "true");
|
|
295
|
+
else parts.push("--status", "completed");
|
|
296
|
+
if (i.squash === true) parts.push("--merge-commit-message-style", "squash");
|
|
297
|
+
if (i.deleteBranch) parts.push("--delete-source-branch", "true");
|
|
298
|
+
return parts.join(" ");
|
|
299
|
+
},
|
|
300
|
+
prStatus: (ref) => `az repos pr show --id ${assertId(ref)}`,
|
|
301
|
+
issueGet: (ref) => `az boards work-item show --id ${assertId(ref)}`
|
|
302
|
+
};
|
|
303
|
+
var registry = /* @__PURE__ */ new Map([
|
|
304
|
+
[githubAdapter.id, githubAdapter],
|
|
305
|
+
[azureAdapter.id, azureAdapter]
|
|
306
|
+
]);
|
|
307
|
+
function listForges() {
|
|
308
|
+
return [...registry.keys()];
|
|
309
|
+
}
|
|
310
|
+
function getForge(id) {
|
|
311
|
+
const a = registry.get(id);
|
|
312
|
+
if (!a) {
|
|
313
|
+
throw new Error(`unknown forge '${id}'. Registered: ${listForges().join(", ")}. Add one with registerForge().`);
|
|
314
|
+
}
|
|
315
|
+
return a;
|
|
316
|
+
}
|
|
317
|
+
function detectForge(remoteUrl) {
|
|
318
|
+
if (typeof remoteUrl !== "string" || remoteUrl === "") {
|
|
319
|
+
throw new Error("remote URL required to detect forge");
|
|
320
|
+
}
|
|
321
|
+
for (const a of registry.values()) {
|
|
322
|
+
if (a.matchesRemote(remoteUrl)) return a.id;
|
|
323
|
+
}
|
|
324
|
+
throw new Error(`no forge adapter matches remote: ${remoteUrl}. Registered: ${listForges().join(", ")}. Register one with registerForge() (e.g. GitLab/Bitbucket/Gitea).`);
|
|
325
|
+
}
|
|
326
|
+
function buildPrCreate(input) {
|
|
327
|
+
return getForge(input.forge).prCreate(input);
|
|
328
|
+
}
|
|
329
|
+
function buildPrMerge(input) {
|
|
330
|
+
return getForge(input.forge).prMerge(input);
|
|
331
|
+
}
|
|
332
|
+
function buildPrStatus(forge, ref) {
|
|
333
|
+
return getForge(forge).prStatus(ref);
|
|
334
|
+
}
|
|
335
|
+
function buildIssueGet(forge, ref) {
|
|
336
|
+
return getForge(forge).issueGet(ref);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/lib/agent-tools/forge.ts
|
|
340
|
+
function resolveForge(a) {
|
|
341
|
+
if (typeof a.forge === "string" && a.forge !== "") return a.forge;
|
|
342
|
+
if (typeof a.remote === "string") return detectForge(a.remote);
|
|
343
|
+
throw new Error("provide a forge id (e.g. github, azure, or a registered adapter) or a remote URL to detect it");
|
|
344
|
+
}
|
|
345
|
+
var forgeParam = { type: "string", description: "Target forge id (github, azure, or a registered adapter). Omit to auto-detect from `remote`." };
|
|
346
|
+
var remoteParam = { type: "string", description: "git remote URL \u2014 used to auto-detect the forge when `forge` is omitted." };
|
|
347
|
+
var forgeTools = [
|
|
348
|
+
{
|
|
349
|
+
name: "forge.pr.create",
|
|
350
|
+
description: "Open a pull request on GitHub (gh) or Azure DevOps (az). Gated via the DDISA grant cycle. Provider chosen by `forge` or auto-detected from `remote`.",
|
|
351
|
+
parameters: {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {
|
|
354
|
+
forge: forgeParam,
|
|
355
|
+
remote: remoteParam,
|
|
356
|
+
title: { type: "string", description: "PR title." },
|
|
357
|
+
body: { type: "string", description: "PR description / body." },
|
|
358
|
+
head: { type: "string", description: "Source branch." },
|
|
359
|
+
base: { type: "string", description: "Target branch. Omit for the repo default." }
|
|
360
|
+
},
|
|
361
|
+
required: ["title", "body", "head"]
|
|
362
|
+
},
|
|
363
|
+
execute: async (args) => {
|
|
364
|
+
const a = args;
|
|
365
|
+
const cmd = buildPrCreate({ forge: resolveForge(a), title: a.title, body: a.body, head: a.head, base: a.base });
|
|
366
|
+
return await runApeShell(cmd);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "forge.pr.merge",
|
|
371
|
+
description: 'Merge a PR \u2014 or with auto=true, arm "merge when checks pass" (gh --auto / az auto-complete) so the platform merges only on green CI. Gated. Never bypasses required checks (branch protection is the server-side gate).',
|
|
372
|
+
parameters: {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: {
|
|
375
|
+
forge: forgeParam,
|
|
376
|
+
remote: remoteParam,
|
|
377
|
+
ref: { type: "string", description: "GitHub: PR number or branch. Azure: PR id." },
|
|
378
|
+
auto: { type: "boolean", description: "Arm merge-when-green instead of immediate merge. Recommended." },
|
|
379
|
+
squash: { type: "boolean", description: "Squash-merge. Default true." },
|
|
380
|
+
delete_branch: { type: "boolean", description: "Delete the source branch after merge." }
|
|
381
|
+
},
|
|
382
|
+
required: ["ref"]
|
|
383
|
+
},
|
|
384
|
+
execute: async (args) => {
|
|
385
|
+
const a = args;
|
|
386
|
+
const cmd = buildPrMerge({ forge: resolveForge(a), ref: a.ref, auto: a.auto, squash: a.squash, deleteBranch: a.delete_branch });
|
|
387
|
+
return await runApeShell(cmd);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: "forge.pr.status",
|
|
392
|
+
description: "Fetch a PR's state + checks + review decision. Gated (read).",
|
|
393
|
+
parameters: {
|
|
394
|
+
type: "object",
|
|
395
|
+
properties: { forge: forgeParam, remote: remoteParam, ref: { type: "string", description: "PR number/branch (GitHub) or id (Azure)." } },
|
|
396
|
+
required: ["ref"]
|
|
397
|
+
},
|
|
398
|
+
execute: async (args) => {
|
|
399
|
+
const a = args;
|
|
400
|
+
return await runApeShell(buildPrStatus(resolveForge(a), a.ref));
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "forge.issue.get",
|
|
405
|
+
description: "Fetch an issue (GitHub) or work-item (Azure) \u2014 title, body, labels. Gated (read). Use to turn an assigned task into a coding run.",
|
|
406
|
+
parameters: {
|
|
407
|
+
type: "object",
|
|
408
|
+
properties: { forge: forgeParam, remote: remoteParam, ref: { type: "string", description: "Issue number (GitHub) or work-item id (Azure)." } },
|
|
409
|
+
required: ["ref"]
|
|
410
|
+
},
|
|
411
|
+
execute: async (args) => {
|
|
412
|
+
const a = args;
|
|
413
|
+
return await runApeShell(buildIssueGet(resolveForge(a), a.ref));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
// src/lib/agent-tools/git-worktree.ts
|
|
419
|
+
import { homedir as homedir2 } from "os";
|
|
420
|
+
import { resolve as resolve2 } from "path";
|
|
421
|
+
import process2 from "process";
|
|
422
|
+
function jailedRoot(envVar, fallbackName) {
|
|
423
|
+
const home = homedir2();
|
|
424
|
+
const raw = process2.env[envVar];
|
|
425
|
+
const dir = raw ? resolve2(raw) : resolve2(home, fallbackName);
|
|
426
|
+
if (dir !== home && !dir.startsWith(`${home}/`)) {
|
|
427
|
+
throw new Error(`${envVar} (${dir}) must resolve inside the agent's home`);
|
|
428
|
+
}
|
|
429
|
+
return dir;
|
|
430
|
+
}
|
|
431
|
+
function workRoot() {
|
|
432
|
+
return jailedRoot("OPENAPE_CODING_WORK_DIR", "work");
|
|
433
|
+
}
|
|
434
|
+
function reposRoot() {
|
|
435
|
+
return jailedRoot("OPENAPE_CODING_REPOS_DIR", "repos");
|
|
436
|
+
}
|
|
437
|
+
var TASK_ID_RE = /^[\w.-]{1,64}$/;
|
|
438
|
+
var BRANCH_RE2 = /^[\w./-]{1,128}$/;
|
|
439
|
+
var URL_RE = /^(?:https:\/\/|git@)[\w@:/.-]{3,256}$/;
|
|
440
|
+
function assertTaskId(v) {
|
|
441
|
+
if (typeof v !== "string" || !TASK_ID_RE.test(v)) {
|
|
442
|
+
throw new Error("task_id must match ^[a-zA-Z0-9._-]{1,64}$");
|
|
443
|
+
}
|
|
444
|
+
return v;
|
|
445
|
+
}
|
|
446
|
+
function assertBranch2(v) {
|
|
447
|
+
if (typeof v !== "string" || !BRANCH_RE2.test(v)) {
|
|
448
|
+
throw new Error("branch must match ^[A-Za-z0-9._/-]{1,128}$");
|
|
449
|
+
}
|
|
450
|
+
return v;
|
|
451
|
+
}
|
|
452
|
+
function resolveRepo(repo) {
|
|
453
|
+
if (typeof repo !== "string" || repo === "") {
|
|
454
|
+
throw new Error("repo must be a non-empty string (URL or path under $HOME)");
|
|
455
|
+
}
|
|
456
|
+
const home = homedir2();
|
|
457
|
+
if (URL_RE.test(repo)) {
|
|
458
|
+
const tail = repo.replace(/\.git$/, "").replace(/[/:]+$/, "");
|
|
459
|
+
const parts = tail.split(/[/:]/).filter(Boolean).slice(-2);
|
|
460
|
+
const base = parts.join("-").replace(/[^\w.-]/g, "");
|
|
461
|
+
if (!base) throw new Error("could not derive a clone name from repo URL");
|
|
462
|
+
return { source: repo, baseDir: resolve2(reposRoot(), base), isUrl: true };
|
|
463
|
+
}
|
|
464
|
+
const candidate = repo.startsWith("~/") ? resolve2(home, repo.slice(2)) : resolve2(home, repo);
|
|
465
|
+
if (candidate !== home && !candidate.startsWith(`${home}/`)) {
|
|
466
|
+
throw new Error(`repo path "${repo}" resolves outside the agent's home`);
|
|
467
|
+
}
|
|
468
|
+
return { source: candidate, baseDir: candidate, isUrl: false };
|
|
469
|
+
}
|
|
470
|
+
function worktreePathFor(taskId) {
|
|
471
|
+
return resolve2(workRoot(), assertTaskId(taskId));
|
|
472
|
+
}
|
|
473
|
+
var q = (s) => `'${s}'`;
|
|
474
|
+
function buildCreateCommand(repo, taskId, branch) {
|
|
475
|
+
const id = assertTaskId(taskId);
|
|
476
|
+
const br = assertBranch2(branch);
|
|
477
|
+
const { source, baseDir, isUrl } = resolveRepo(repo);
|
|
478
|
+
const wt = worktreePathFor(id);
|
|
479
|
+
const clone = isUrl ? `if [ ! -d ${q(baseDir)}/.git ]; then git clone ${q(source)} ${q(baseDir)}; fi` : `test -d ${q(baseDir)}/.git`;
|
|
480
|
+
return [
|
|
481
|
+
`mkdir -p ${q(reposRoot())} ${q(workRoot())}`,
|
|
482
|
+
clone,
|
|
483
|
+
`git -C ${q(baseDir)} fetch --quiet || true`,
|
|
484
|
+
`git -C ${q(baseDir)} worktree add -b ${q(br)} ${q(wt)}`,
|
|
485
|
+
`echo ${q(wt)}`
|
|
486
|
+
].join(" && ");
|
|
487
|
+
}
|
|
488
|
+
function buildRemoveCommand(repo, taskId) {
|
|
489
|
+
const id = assertTaskId(taskId);
|
|
490
|
+
const { baseDir } = resolveRepo(repo);
|
|
491
|
+
const wt = worktreePathFor(id);
|
|
492
|
+
return `git -C ${q(baseDir)} worktree remove --force ${q(wt)} && git -C ${q(baseDir)} worktree prune`;
|
|
493
|
+
}
|
|
494
|
+
function buildListCommand() {
|
|
495
|
+
return `ls -1 ${q(workRoot())} 2>/dev/null || true`;
|
|
496
|
+
}
|
|
497
|
+
var gitWorktreeTools = [
|
|
498
|
+
{
|
|
499
|
+
name: "git.worktree",
|
|
500
|
+
description: "Manage isolated git worktrees for coding tasks. action=create clones the repo (cached under ~/repos) and adds a fresh worktree under ~/work/<task_id> on a new branch. action=remove tears it down. action=list shows current task worktrees. Git operations go through the DDISA grant cycle (git-shape).",
|
|
501
|
+
parameters: {
|
|
502
|
+
type: "object",
|
|
503
|
+
properties: {
|
|
504
|
+
action: { type: "string", enum: ["create", "remove", "list"], description: "create | remove | list" },
|
|
505
|
+
repo: { type: "string", description: "For create/remove: git remote URL (https/git@) or a path under $HOME to an existing clone." },
|
|
506
|
+
task_id: { type: "string", description: "For create/remove: identifier for the worktree, ^[a-zA-Z0-9._-]{1,64}$. The worktree lands at ~/work/<task_id>." },
|
|
507
|
+
branch: { type: "string", description: "For create: new branch name, ^[A-Za-z0-9._/-]{1,128}$." }
|
|
508
|
+
},
|
|
509
|
+
required: ["action"]
|
|
510
|
+
},
|
|
511
|
+
execute: async (args) => {
|
|
512
|
+
const a = args;
|
|
513
|
+
let cmd;
|
|
514
|
+
if (a.action === "create") {
|
|
515
|
+
if (typeof a.branch !== "string") throw new Error("branch is required for action=create");
|
|
516
|
+
cmd = buildCreateCommand(a.repo, assertTaskId(a.task_id), a.branch);
|
|
517
|
+
} else if (a.action === "remove") {
|
|
518
|
+
cmd = buildRemoveCommand(a.repo, assertTaskId(a.task_id));
|
|
519
|
+
} else if (a.action === "list") {
|
|
520
|
+
cmd = buildListCommand();
|
|
521
|
+
} else {
|
|
522
|
+
throw new Error("action must be one of: create, remove, list");
|
|
523
|
+
}
|
|
524
|
+
const res = await runApeShell(cmd);
|
|
525
|
+
return {
|
|
526
|
+
action: a.action,
|
|
527
|
+
...a.action !== "list" ? { worktree: worktreePathFor(assertTaskId(a.task_id)) } : {},
|
|
528
|
+
stdout: res.stdout,
|
|
529
|
+
stderr: res.stderr,
|
|
530
|
+
exit_code: res.exit_code,
|
|
531
|
+
...res.error ? { error: res.error, hint: res.hint } : {}
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
// src/lib/agent-tools/http.ts
|
|
538
|
+
var MAX_BYTES2 = 1024 * 1024;
|
|
539
|
+
var FORBIDDEN_HEADERS = /* @__PURE__ */ new Set([
|
|
540
|
+
"host",
|
|
541
|
+
"authorization",
|
|
542
|
+
"cookie",
|
|
543
|
+
"connection",
|
|
544
|
+
"transfer-encoding",
|
|
545
|
+
"upgrade",
|
|
546
|
+
"proxy-authorization"
|
|
547
|
+
]);
|
|
548
|
+
function sanitizeHeaders(input) {
|
|
549
|
+
if (!input || typeof input !== "object") return {};
|
|
550
|
+
const out = {};
|
|
551
|
+
for (const [k, v] of Object.entries(input)) {
|
|
552
|
+
if (typeof v !== "string") continue;
|
|
553
|
+
if (FORBIDDEN_HEADERS.has(k.toLowerCase())) continue;
|
|
554
|
+
out[k] = v;
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
async function readCappedBody(res) {
|
|
559
|
+
const buf = new Uint8Array(MAX_BYTES2 + 1);
|
|
560
|
+
let written = 0;
|
|
561
|
+
const reader = res.body?.getReader();
|
|
562
|
+
if (!reader) return await res.text();
|
|
563
|
+
while (true) {
|
|
564
|
+
const { value, done } = await reader.read();
|
|
565
|
+
if (done) break;
|
|
566
|
+
if (written + value.byteLength > MAX_BYTES2) {
|
|
567
|
+
buf.set(value.subarray(0, MAX_BYTES2 - written), written);
|
|
568
|
+
written = MAX_BYTES2;
|
|
569
|
+
try {
|
|
570
|
+
await reader.cancel();
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
buf.set(value, written);
|
|
576
|
+
written += value.byteLength;
|
|
577
|
+
}
|
|
578
|
+
return new TextDecoder().decode(buf.subarray(0, written));
|
|
579
|
+
}
|
|
580
|
+
var httpTools = [
|
|
581
|
+
{
|
|
582
|
+
name: "http.get",
|
|
583
|
+
description: "GET an HTTPS URL and return the response body (capped at 1MB). Useful for reading public APIs, RSS feeds, web pages.",
|
|
584
|
+
parameters: {
|
|
585
|
+
type: "object",
|
|
586
|
+
properties: {
|
|
587
|
+
url: { type: "string", description: "Absolute HTTPS URL." },
|
|
588
|
+
headers: { type: "object", description: "Optional headers (Host, Authorization, Cookie are stripped)." }
|
|
589
|
+
},
|
|
590
|
+
required: ["url"]
|
|
591
|
+
},
|
|
592
|
+
execute: async (args) => {
|
|
593
|
+
const a = args;
|
|
594
|
+
if (typeof a.url !== "string" || !a.url.startsWith("http")) {
|
|
595
|
+
throw new Error("url must be an http(s) URL");
|
|
596
|
+
}
|
|
597
|
+
const res = await fetch(a.url, { method: "GET", headers: sanitizeHeaders(a.headers) });
|
|
598
|
+
const body = await readCappedBody(res);
|
|
599
|
+
return { status: res.status, headers: Object.fromEntries(res.headers), body };
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: "http.post",
|
|
604
|
+
description: "POST JSON to an HTTPS URL and return the response body (capped at 1MB).",
|
|
605
|
+
parameters: {
|
|
606
|
+
type: "object",
|
|
607
|
+
properties: {
|
|
608
|
+
url: { type: "string", description: "Absolute HTTPS URL." },
|
|
609
|
+
body: { description: "JSON-serialisable payload." },
|
|
610
|
+
headers: { type: "object", description: "Optional headers (Host, Authorization, Cookie are stripped)." }
|
|
611
|
+
},
|
|
612
|
+
required: ["url", "body"]
|
|
613
|
+
},
|
|
614
|
+
execute: async (args) => {
|
|
615
|
+
const a = args;
|
|
616
|
+
if (typeof a.url !== "string" || !a.url.startsWith("http")) {
|
|
617
|
+
throw new Error("url must be an http(s) URL");
|
|
618
|
+
}
|
|
619
|
+
const res = await fetch(a.url, {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: { "content-type": "application/json", ...sanitizeHeaders(a.headers) },
|
|
622
|
+
body: JSON.stringify(a.body)
|
|
623
|
+
});
|
|
624
|
+
const body = await readCappedBody(res);
|
|
625
|
+
return { status: res.status, headers: Object.fromEntries(res.headers), body };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
// src/lib/agent-tools/mail.ts
|
|
631
|
+
import { execFileSync } from "child_process";
|
|
632
|
+
function o365(args) {
|
|
633
|
+
try {
|
|
634
|
+
return execFileSync("o365-cli", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
635
|
+
} catch (err) {
|
|
636
|
+
const e = err;
|
|
637
|
+
if (e.code === "ENOENT") {
|
|
638
|
+
throw new Error("o365-cli is not installed on this agent host");
|
|
639
|
+
}
|
|
640
|
+
const stderr = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8");
|
|
641
|
+
throw new Error(`o365-cli failed: ${stderr ?? e.message ?? err}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
var mailTools = [
|
|
645
|
+
{
|
|
646
|
+
name: "mail.list",
|
|
647
|
+
description: "List recent inbox messages via o365-cli. Optional `unread_only` and `limit`.",
|
|
648
|
+
parameters: {
|
|
649
|
+
type: "object",
|
|
650
|
+
properties: {
|
|
651
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
|
|
652
|
+
unread_only: { type: "boolean", default: false }
|
|
653
|
+
},
|
|
654
|
+
required: []
|
|
655
|
+
},
|
|
656
|
+
execute: async (args) => {
|
|
657
|
+
const a = args ?? {};
|
|
658
|
+
const argv = ["mail", "list", "--json", "--limit", String(a.limit ?? 20)];
|
|
659
|
+
if (a.unread_only) argv.push("--unread");
|
|
660
|
+
const out = o365(argv);
|
|
661
|
+
try {
|
|
662
|
+
return JSON.parse(out);
|
|
663
|
+
} catch {
|
|
664
|
+
return { raw: out };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "mail.search",
|
|
670
|
+
description: "Search the inbox via o365-cli using a free-form query string.",
|
|
671
|
+
parameters: {
|
|
672
|
+
type: "object",
|
|
673
|
+
properties: {
|
|
674
|
+
q: { type: "string" },
|
|
675
|
+
limit: { type: "integer", minimum: 1, maximum: 100, default: 20 }
|
|
676
|
+
},
|
|
677
|
+
required: ["q"]
|
|
678
|
+
},
|
|
679
|
+
execute: async (args) => {
|
|
680
|
+
const a = args;
|
|
681
|
+
if (typeof a.q !== "string" || a.q.length === 0) throw new Error("q is required");
|
|
682
|
+
const argv = ["mail", "search", a.q, "--json", "--limit", String(a.limit ?? 20)];
|
|
683
|
+
const out = o365(argv);
|
|
684
|
+
try {
|
|
685
|
+
return JSON.parse(out);
|
|
686
|
+
} catch {
|
|
687
|
+
return { raw: out };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
// src/lib/agent-tools/tasks.ts
|
|
694
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
695
|
+
function ape(args) {
|
|
696
|
+
try {
|
|
697
|
+
return execFileSync2("ape-tasks", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
698
|
+
} catch (err) {
|
|
699
|
+
const e = err;
|
|
700
|
+
const stderr = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8");
|
|
701
|
+
throw new Error(`ape-tasks failed: ${stderr ?? e.message ?? err}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
var tasksTools = [
|
|
705
|
+
{
|
|
706
|
+
name: "tasks.list",
|
|
707
|
+
description: "List the owner's open ape-tasks (the user's personal task list at tasks.openape.ai).",
|
|
708
|
+
parameters: {
|
|
709
|
+
type: "object",
|
|
710
|
+
properties: {
|
|
711
|
+
status: { type: "string", enum: ["open", "doing", "done", "archived"] },
|
|
712
|
+
team_id: { type: "string" }
|
|
713
|
+
},
|
|
714
|
+
required: []
|
|
715
|
+
},
|
|
716
|
+
execute: async (args) => {
|
|
717
|
+
const a = args ?? {};
|
|
718
|
+
const argv = ["list", "--json"];
|
|
719
|
+
if (a.status) argv.push("--status", a.status);
|
|
720
|
+
if (a.team_id) argv.push("--team", a.team_id);
|
|
721
|
+
const out = ape(argv);
|
|
722
|
+
try {
|
|
723
|
+
return JSON.parse(out);
|
|
724
|
+
} catch {
|
|
725
|
+
return { raw: out };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
name: "tasks.create",
|
|
731
|
+
description: "Create a new ape-task on the owner's task list at tasks.openape.ai.",
|
|
732
|
+
parameters: {
|
|
733
|
+
type: "object",
|
|
734
|
+
properties: {
|
|
735
|
+
title: { type: "string" },
|
|
736
|
+
notes: { type: "string" },
|
|
737
|
+
priority: { type: "string", enum: ["low", "med", "high"] },
|
|
738
|
+
due_at: { type: "string", description: "ISO date or +Nh/+Nd shorthand." }
|
|
739
|
+
},
|
|
740
|
+
required: ["title"]
|
|
741
|
+
},
|
|
742
|
+
execute: async (args) => {
|
|
743
|
+
const a = args;
|
|
744
|
+
const argv = ["new", "--title", a.title, "--json"];
|
|
745
|
+
if (a.notes) argv.push("--notes", a.notes);
|
|
746
|
+
if (a.priority) argv.push("--priority", a.priority);
|
|
747
|
+
if (a.due_at) argv.push("--due", a.due_at);
|
|
748
|
+
const out = ape(argv);
|
|
749
|
+
try {
|
|
750
|
+
return JSON.parse(out);
|
|
751
|
+
} catch {
|
|
752
|
+
return { raw: out };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
];
|
|
757
|
+
|
|
758
|
+
// src/lib/agent-tools/time.ts
|
|
759
|
+
var timeTools = [
|
|
760
|
+
{
|
|
761
|
+
name: "time.now",
|
|
762
|
+
description: "Returns the current UTC date and time as ISO 8601 plus epoch seconds. No inputs.",
|
|
763
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
764
|
+
execute: async () => {
|
|
765
|
+
const now = /* @__PURE__ */ new Date();
|
|
766
|
+
return {
|
|
767
|
+
iso: now.toISOString(),
|
|
768
|
+
epoch_seconds: Math.floor(now.getTime() / 1e3),
|
|
769
|
+
timezone_offset_minutes: -now.getTimezoneOffset()
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
];
|
|
774
|
+
|
|
775
|
+
// src/lib/coding/verify.ts
|
|
776
|
+
var CWD_RE = /^[\w./-]{1,256}$/;
|
|
777
|
+
async function runVerify(cwd, command, timeoutMs) {
|
|
778
|
+
if (typeof cwd !== "string" || !CWD_RE.test(cwd)) {
|
|
779
|
+
throw new Error("cwd must match ^[A-Za-z0-9._/-]{1,256}$");
|
|
780
|
+
}
|
|
781
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
782
|
+
throw new Error("verify command must be a non-empty string");
|
|
783
|
+
}
|
|
784
|
+
const res = await runApeShell(`cd '${cwd}' && ${command}`, timeoutMs);
|
|
785
|
+
return {
|
|
786
|
+
passed: res.exit_code === 0,
|
|
787
|
+
exit_code: res.exit_code,
|
|
788
|
+
stdout: res.stdout,
|
|
789
|
+
stderr: res.stderr,
|
|
790
|
+
...res.timed_out ? { timed_out: true } : {}
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/lib/agent-tools/verify.ts
|
|
795
|
+
var verifyTools = [
|
|
796
|
+
{
|
|
797
|
+
name: "verify",
|
|
798
|
+
description: "Run the verification command (tests/build/lint) in a worktree and report pass/fail. The coding loop must NOT open or merge a PR when this fails. Runs through the DDISA grant cycle (same as bash). Returns { passed, exit_code, stdout, stderr }.",
|
|
799
|
+
parameters: {
|
|
800
|
+
type: "object",
|
|
801
|
+
properties: {
|
|
802
|
+
cwd: { type: "string", description: "Worktree path to run in (e.g. ~/work/issue-42)." },
|
|
803
|
+
command: { type: "string", description: "Verification command, e.g. `pnpm test` or `npm run build && npm test`." },
|
|
804
|
+
timeout_ms: { type: "number", description: "Wall-clock cap incl. approval wait. Default 300000." }
|
|
805
|
+
},
|
|
806
|
+
required: ["cwd", "command"]
|
|
807
|
+
},
|
|
808
|
+
execute: async (args) => {
|
|
809
|
+
const a = args;
|
|
810
|
+
const timeout = typeof a.timeout_ms === "number" && a.timeout_ms > 0 ? a.timeout_ms : void 0;
|
|
811
|
+
return await runVerify(a.cwd, a.command, timeout);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
];
|
|
815
|
+
|
|
816
|
+
// src/lib/agent-tools/index.ts
|
|
817
|
+
var ALL_TOOLS = [
|
|
818
|
+
...timeTools,
|
|
819
|
+
...httpTools,
|
|
820
|
+
...fileTools,
|
|
821
|
+
...tasksTools,
|
|
822
|
+
...mailTools,
|
|
823
|
+
...bashTools,
|
|
824
|
+
...gitWorktreeTools,
|
|
825
|
+
...verifyTools,
|
|
826
|
+
...forgeTools
|
|
827
|
+
];
|
|
828
|
+
var TOOLS = Object.fromEntries(
|
|
829
|
+
ALL_TOOLS.map((t) => [t.name, t])
|
|
830
|
+
);
|
|
831
|
+
function taskTools(names) {
|
|
832
|
+
const out = [];
|
|
833
|
+
const missing = [];
|
|
834
|
+
for (const name of names) {
|
|
835
|
+
const tool = TOOLS[name];
|
|
836
|
+
if (!tool) missing.push(name);
|
|
837
|
+
else out.push(tool);
|
|
838
|
+
}
|
|
839
|
+
if (missing.length > 0) {
|
|
840
|
+
throw new Error(`unknown tool(s): ${missing.join(", ")}`);
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
function asOpenAiTools(tools) {
|
|
845
|
+
return tools.map((t) => ({
|
|
846
|
+
type: "function",
|
|
847
|
+
function: { name: wireToolName(t.name), description: t.description, parameters: t.parameters }
|
|
848
|
+
}));
|
|
849
|
+
}
|
|
850
|
+
function wireToolName(local) {
|
|
851
|
+
return local.replace(/\./g, "_");
|
|
852
|
+
}
|
|
853
|
+
function localToolName(wire) {
|
|
854
|
+
for (const t of Object.values(TOOLS)) {
|
|
855
|
+
if (wireToolName(t.name) === wire) return t.name;
|
|
856
|
+
}
|
|
857
|
+
return wire;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/lib/agent-runtime.ts
|
|
861
|
+
function previewJson(value, max = 500) {
|
|
862
|
+
let s;
|
|
863
|
+
try {
|
|
864
|
+
s = JSON.stringify(value);
|
|
865
|
+
} catch {
|
|
866
|
+
s = String(value);
|
|
867
|
+
}
|
|
868
|
+
return s.length > max ? `${s.slice(0, max)}\u2026` : s;
|
|
869
|
+
}
|
|
870
|
+
async function runLoop(opts) {
|
|
871
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
872
|
+
const trace = [];
|
|
873
|
+
const messages = [
|
|
874
|
+
{ role: "system", content: opts.systemPrompt },
|
|
875
|
+
...opts.history ?? [],
|
|
876
|
+
{ role: "user", content: opts.userMessage }
|
|
877
|
+
];
|
|
878
|
+
const tools = asOpenAiTools(opts.tools);
|
|
879
|
+
for (let step = 1; step <= opts.maxSteps; step++) {
|
|
880
|
+
const res = await fetchFn(`${opts.config.apiBase}/chat/completions`, {
|
|
881
|
+
method: "POST",
|
|
882
|
+
headers: {
|
|
883
|
+
"authorization": `Bearer ${opts.config.apiKey}`,
|
|
884
|
+
"content-type": "application/json"
|
|
885
|
+
},
|
|
886
|
+
body: JSON.stringify({
|
|
887
|
+
model: opts.config.model,
|
|
888
|
+
messages,
|
|
889
|
+
...tools.length > 0 ? { tools, tool_choice: "auto" } : {}
|
|
890
|
+
})
|
|
891
|
+
});
|
|
892
|
+
if (!res.ok) {
|
|
893
|
+
const text = await res.text().catch(() => "");
|
|
894
|
+
throw new Error(`LiteLLM ${res.status}: ${text.slice(0, 500)}`);
|
|
895
|
+
}
|
|
896
|
+
const data = await res.json();
|
|
897
|
+
const choice = data.choices?.[0];
|
|
898
|
+
if (!choice) throw new Error("LiteLLM response had no choices");
|
|
899
|
+
const assistant = choice.message;
|
|
900
|
+
messages.push(assistant);
|
|
901
|
+
if (assistant.content) opts.handlers?.onTextDelta?.(assistant.content);
|
|
902
|
+
trace.push({
|
|
903
|
+
step,
|
|
904
|
+
type: "assistant",
|
|
905
|
+
preview: previewJson({ content: assistant.content, tool_calls: assistant.tool_calls?.length ?? 0 })
|
|
906
|
+
});
|
|
907
|
+
if (!assistant.tool_calls || assistant.tool_calls.length === 0) {
|
|
908
|
+
const result2 = {
|
|
909
|
+
status: "ok",
|
|
910
|
+
finalMessage: assistant.content,
|
|
911
|
+
stepCount: step,
|
|
912
|
+
trace
|
|
913
|
+
};
|
|
914
|
+
opts.handlers?.onDone?.(result2);
|
|
915
|
+
return result2;
|
|
916
|
+
}
|
|
917
|
+
for (const call of assistant.tool_calls) {
|
|
918
|
+
const wireName = call.function.name;
|
|
919
|
+
const localName = localToolName(wireName);
|
|
920
|
+
const tool = opts.tools.find((t) => t.name === localName);
|
|
921
|
+
let parsedArgs;
|
|
922
|
+
try {
|
|
923
|
+
parsedArgs = JSON.parse(call.function.arguments);
|
|
924
|
+
} catch {
|
|
925
|
+
parsedArgs = {};
|
|
926
|
+
}
|
|
927
|
+
opts.handlers?.onToolCall?.({ name: localName, args: parsedArgs });
|
|
928
|
+
trace.push({ step, type: "tool_call", tool: localName, preview: previewJson(parsedArgs) });
|
|
929
|
+
let result2;
|
|
930
|
+
let isError = false;
|
|
931
|
+
if (!tool) {
|
|
932
|
+
result2 = `unknown tool: ${localName}`;
|
|
933
|
+
isError = true;
|
|
934
|
+
} else {
|
|
935
|
+
try {
|
|
936
|
+
result2 = await tool.execute(parsedArgs);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
result2 = err?.message ?? String(err);
|
|
939
|
+
isError = true;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (isError) {
|
|
943
|
+
opts.handlers?.onToolError?.({ name: localName, error: String(result2) });
|
|
944
|
+
trace.push({ step, type: "tool_error", tool: localName, preview: previewJson(result2) });
|
|
945
|
+
} else {
|
|
946
|
+
opts.handlers?.onToolResult?.({ name: localName, result: result2 });
|
|
947
|
+
trace.push({ step, type: "tool_result", tool: localName, preview: previewJson(result2) });
|
|
948
|
+
}
|
|
949
|
+
messages.push({
|
|
950
|
+
role: "tool",
|
|
951
|
+
tool_call_id: call.id,
|
|
952
|
+
name: wireToolName(localName),
|
|
953
|
+
content: typeof result2 === "string" ? result2 : JSON.stringify(result2)
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const result = {
|
|
958
|
+
status: "error",
|
|
959
|
+
finalMessage: `max_steps (${opts.maxSteps}) reached without completion`,
|
|
960
|
+
stepCount: opts.maxSteps,
|
|
961
|
+
trace
|
|
962
|
+
};
|
|
963
|
+
opts.handlers?.onDone?.(result);
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
966
|
+
var RPC_SESSION_TTL_MS = 60 * 60 * 1e3;
|
|
967
|
+
var RpcSessionMap = class {
|
|
968
|
+
sessions = /* @__PURE__ */ new Map();
|
|
969
|
+
get(id) {
|
|
970
|
+
const s = this.sessions.get(id);
|
|
971
|
+
if (s) s.lastTouched = Date.now();
|
|
972
|
+
return s;
|
|
973
|
+
}
|
|
974
|
+
put(id, s) {
|
|
975
|
+
s.lastTouched = Date.now();
|
|
976
|
+
this.sessions.set(id, s);
|
|
977
|
+
}
|
|
978
|
+
evictStale() {
|
|
979
|
+
const cutoff = Date.now() - RPC_SESSION_TTL_MS;
|
|
980
|
+
for (const [k, v] of this.sessions) {
|
|
981
|
+
if (v.lastTouched < cutoff) this.sessions.delete(k);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
size() {
|
|
985
|
+
return this.sessions.size;
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
export {
|
|
990
|
+
CliError,
|
|
991
|
+
CliExit,
|
|
992
|
+
parseDuration,
|
|
993
|
+
TOOLS,
|
|
994
|
+
taskTools,
|
|
995
|
+
runLoop,
|
|
996
|
+
RpcSessionMap
|
|
997
|
+
};
|
|
998
|
+
//# sourceMappingURL=chunk-VHZEVW2N.js.map
|