@robzilla1738/agentswarm 0.5.0 → 0.7.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/README.md +29 -12
- package/dist/agent.js +6 -15
- package/dist/cli.js +31 -4
- package/dist/config.js +44 -1
- package/dist/crawltools.js +3 -22
- package/dist/executor.js +276 -60
- package/dist/hub.js +67 -3
- package/dist/journal.js +39 -5
- package/dist/memory.js +17 -11
- package/dist/pdftext.js +211 -0
- package/dist/prompts.js +23 -15
- package/dist/report.js +39 -1
- package/dist/run.js +8 -0
- package/dist/sandbox.js +11 -0
- package/dist/searchcore.js +55 -2
- package/dist/state.js +67 -17
- package/dist/tools.js +208 -19
- package/dist/util.js +117 -3
- package/dist/webtools.js +185 -32
- package/package.json +1 -1
- package/ui/out/404/index.html +1 -1
- package/ui/out/404.html +1 -1
- package/ui/out/_next/static/chunks/677-a62d486d6734bcf3.js +1 -0
- package/ui/out/_next/static/chunks/app/run/page-c29f95c51af08c60.js +1 -0
- package/ui/out/_next/static/chunks/app/settings/page-41a5d8ba43ecfd4a.js +1 -0
- package/ui/out/_next/static/css/{9f7bd82b8e4c762c.css → d95c2ba395730031.css} +1 -1
- package/ui/out/index.html +1 -1
- package/ui/out/index.txt +3 -3
- package/ui/out/run/index.html +1 -1
- package/ui/out/run/index.txt +3 -3
- package/ui/out/settings/index.html +1 -1
- package/ui/out/settings/index.txt +3 -3
- package/ui/out/_next/static/chunks/677-859e8d42add1806b.js +0 -1
- package/ui/out/_next/static/chunks/app/run/page-2420c9e4c963d9b3.js +0 -1
- package/ui/out/_next/static/chunks/app/settings/page-092a6bf42dfde57d.js +0 -1
- /package/ui/out/_next/static/{errjtBR_bKoee8ogLp8xk → JFkx5KtNi0DYyqm_THzbY}/_buildManifest.js +0 -0
- /package/ui/out/_next/static/{errjtBR_bKoee8ogLp8xk → JFkx5KtNi0DYyqm_THzbY}/_ssgManifest.js +0 -0
package/dist/sandbox.js
CHANGED
|
@@ -302,7 +302,17 @@ class RemoteRuntime {
|
|
|
302
302
|
throw new Error(`${what} failed (exit ${r.code}): ${r.out.slice(0, 300)}`);
|
|
303
303
|
return r.out;
|
|
304
304
|
}
|
|
305
|
+
/** base64-over-shell transfers buffer the whole file — refuse the huge ones. */
|
|
306
|
+
async checkSize(abs, capBytes, what) {
|
|
307
|
+
const out = await this.execOk(`wc -c < ${shq(abs)}`, `stat ${abs}`);
|
|
308
|
+
const size = Number(out.trim());
|
|
309
|
+
if (Number.isFinite(size) && size > capBytes) {
|
|
310
|
+
throw new Error(`${what}: file is ${Math.round(size / 1e6)}MB (cap ${Math.round(capBytes / 1e6)}MB) — ` +
|
|
311
|
+
`compress it or extract the relevant part in the sandbox first`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
305
314
|
async readFile(abs) {
|
|
315
|
+
await this.checkSize(abs, 4_000_000, `read ${abs}`);
|
|
306
316
|
const out = await this.execOk(`base64 < ${shq(abs)}`, `read ${abs}`);
|
|
307
317
|
return Buffer.from(out.replace(/\s+/g, ""), "base64").toString("utf8");
|
|
308
318
|
}
|
|
@@ -319,6 +329,7 @@ class RemoteRuntime {
|
|
|
319
329
|
}
|
|
320
330
|
}
|
|
321
331
|
async pull(remoteAbs, localAbs) {
|
|
332
|
+
await this.checkSize(remoteAbs, 32_000_000, `pull ${remoteAbs}`);
|
|
322
333
|
const out = await this.execOk(`base64 < ${shq(remoteAbs)}`, `pull ${remoteAbs}`);
|
|
323
334
|
(0, util_1.ensureDir)(path.dirname(localAbs));
|
|
324
335
|
fs.writeFileSync(localAbs, Buffer.from(out.replace(/\s+/g, ""), "base64"));
|
package/dist/searchcore.js
CHANGED
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.queryTerms = queryTerms;
|
|
11
11
|
exports.expandQueries = expandQueries;
|
|
12
|
+
exports.reformulate = reformulate;
|
|
13
|
+
exports.looksAcademic = looksAcademic;
|
|
12
14
|
exports.canonicalizeUrl = canonicalizeUrl;
|
|
13
15
|
exports.classifySource = classifySource;
|
|
16
|
+
exports.freshnessBoost = freshnessBoost;
|
|
14
17
|
exports.detectDate = detectDate;
|
|
15
18
|
exports.selectPassages = selectPassages;
|
|
16
19
|
exports.scorePage = scorePage;
|
|
@@ -42,6 +45,22 @@ function expandQueries(query, max = 3) {
|
|
|
42
45
|
const seen = new Set();
|
|
43
46
|
return out.map((q) => q.trim()).filter((q) => q && !seen.has(q.toLowerCase()) && seen.add(q.toLowerCase())).slice(0, max);
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Fallback phrasing when a query returns nothing: strip quotes and search
|
|
50
|
+
* operators down to the top keyword terms. Returns "" when no useful
|
|
51
|
+
* simplification exists.
|
|
52
|
+
*/
|
|
53
|
+
function reformulate(query) {
|
|
54
|
+
const cleaned = query
|
|
55
|
+
.replace(/["'""'']/g, " ")
|
|
56
|
+
.replace(/\b(site|intitle|inurl|filetype):\S+/gi, " ");
|
|
57
|
+
const alt = queryTerms(cleaned).slice(0, 6).join(" ");
|
|
58
|
+
return alt && alt !== query.toLowerCase().trim() ? alt : "";
|
|
59
|
+
}
|
|
60
|
+
/** Queries that smell academic trigger the scholarly engines in deep mode. */
|
|
61
|
+
function looksAcademic(query) {
|
|
62
|
+
return /\b(paper|papers|study|studies|research|arxiv|doi|journal|peer.?review(ed)?|preprint|dataset|benchmark|survey|meta.?analysis|citations?|et al)\b/i.test(query);
|
|
63
|
+
}
|
|
45
64
|
const TRACKING_KEYS = new Set(["fbclid", "gclid", "mc_cid", "mc_eid"]);
|
|
46
65
|
/** Stable canonical form for dedup: strip tracking params, www, trailing slash; sort the query. */
|
|
47
66
|
function canonicalizeUrl(url) {
|
|
@@ -61,18 +80,50 @@ function canonicalizeUrl(url) {
|
|
|
61
80
|
path = path.replace(/\/+$/, "");
|
|
62
81
|
return `${u.protocol.toLowerCase()}//${host}${path}${query}`;
|
|
63
82
|
}
|
|
83
|
+
const ACADEMIC_HOSTS = [
|
|
84
|
+
"arxiv.org",
|
|
85
|
+
"doi.org",
|
|
86
|
+
"semanticscholar.org",
|
|
87
|
+
"ncbi.nlm.nih.gov",
|
|
88
|
+
"nature.com",
|
|
89
|
+
"sciencedirect.com",
|
|
90
|
+
"springer.com",
|
|
91
|
+
"link.springer.com",
|
|
92
|
+
"scholar.google.com",
|
|
93
|
+
"acm.org",
|
|
94
|
+
"ieee.org",
|
|
95
|
+
];
|
|
64
96
|
function classifySource(domain) {
|
|
65
97
|
const d = domain.toLowerCase();
|
|
66
98
|
if (d.endsWith(".gov") || d.endsWith(".mil"))
|
|
67
99
|
return "government";
|
|
68
100
|
if (d.endsWith(".edu"))
|
|
69
101
|
return "academic";
|
|
102
|
+
if (ACADEMIC_HOSTS.some((h) => d === h || d.endsWith("." + h)))
|
|
103
|
+
return "academic";
|
|
70
104
|
if (["twitter.com", "x.com", "reddit.com", "facebook.com"].some((s) => d.includes(s)))
|
|
71
105
|
return "social";
|
|
72
106
|
if (d.includes("news") || d.includes("reuters.com") || d.includes("apnews.com") || d.includes("bbc."))
|
|
73
107
|
return "news";
|
|
74
108
|
return "secondary";
|
|
75
109
|
}
|
|
110
|
+
/** Recency boost from an ISO date or bare year: +3 <1y, +2 <2y, +1 <5y, 0 older/undated. */
|
|
111
|
+
function freshnessBoost(date, now = Date.now()) {
|
|
112
|
+
if (!date)
|
|
113
|
+
return 0;
|
|
114
|
+
const m = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?/.exec(date.trim());
|
|
115
|
+
if (!m)
|
|
116
|
+
return 0;
|
|
117
|
+
const t = Date.UTC(Number(m[1]), m[2] ? Number(m[2]) - 1 : 6, m[3] ? Number(m[3]) : 15);
|
|
118
|
+
const years = (now - t) / 31_557_600_000;
|
|
119
|
+
if (years < 1)
|
|
120
|
+
return 3;
|
|
121
|
+
if (years < 2)
|
|
122
|
+
return 2;
|
|
123
|
+
if (years < 5)
|
|
124
|
+
return 1;
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
76
127
|
/** ISO date if present, else a bare year. */
|
|
77
128
|
function detectDate(text) {
|
|
78
129
|
const iso = /\b(20\d{2}-\d{2}-\d{2})\b/.exec(text);
|
|
@@ -136,8 +187,7 @@ function scorePage(page, terms) {
|
|
|
136
187
|
score += 4;
|
|
137
188
|
if (["pypi.org", "npmjs.com", "rubygems.org"].includes(domain))
|
|
138
189
|
score -= 2;
|
|
139
|
-
|
|
140
|
-
score += 1;
|
|
190
|
+
score += freshnessBoost(page.date);
|
|
141
191
|
const lowered = page.text.toLowerCase();
|
|
142
192
|
for (const t of terms)
|
|
143
193
|
if (lowered.includes(t))
|
|
@@ -158,6 +208,9 @@ function resultQualityScore(c) {
|
|
|
158
208
|
score += 4;
|
|
159
209
|
if (url.includes("github.com") || url.includes("gitlab.com"))
|
|
160
210
|
score += 3;
|
|
211
|
+
if (c.engine === "arxiv" || c.engine === "crossref")
|
|
212
|
+
score += 3;
|
|
213
|
+
score += Math.min(2, freshnessBoost(c.date));
|
|
161
214
|
if (LOW_VALUE_SNIPPET.some((t) => snippet.includes(t)))
|
|
162
215
|
score -= 10;
|
|
163
216
|
return score;
|
package/dist/state.js
CHANGED
|
@@ -21,6 +21,8 @@ class RunState {
|
|
|
21
21
|
usageByModel = new Map();
|
|
22
22
|
totalUsage = { ...types_1.ZERO_USAGE };
|
|
23
23
|
cost = 0;
|
|
24
|
+
/** Sampled cumulative token spend over time (budget sparkline). */
|
|
25
|
+
budgetSeries = [];
|
|
24
26
|
finalSummary;
|
|
25
27
|
finalReportPath;
|
|
26
28
|
lastSeq = 0;
|
|
@@ -55,6 +57,13 @@ class RunState {
|
|
|
55
57
|
this.usageByModel.set(model, (0, types_1.addUsage)(this.usageByModel.get(model) ?? { ...types_1.ZERO_USAGE }, u));
|
|
56
58
|
this.totalUsage = (0, types_1.addUsage)(this.totalUsage, u);
|
|
57
59
|
this.cost += (0, types_1.usageCost)(u, this.pricing[model]);
|
|
60
|
+
this.pushBudgetPoint(ev.t);
|
|
61
|
+
}
|
|
62
|
+
else if (ev.type === "note.added") {
|
|
63
|
+
// The blackboard is shared swarm-wide at runtime, so team notes are
|
|
64
|
+
// root facts too — without this, a resume would forget every note a
|
|
65
|
+
// team agent posted (decisions included).
|
|
66
|
+
this.pushNote(ev, teamId);
|
|
58
67
|
}
|
|
59
68
|
return;
|
|
60
69
|
}
|
|
@@ -127,6 +136,8 @@ class RunState {
|
|
|
127
136
|
t.openQuestions = ev.openQuestions;
|
|
128
137
|
if (Array.isArray(ev.filesTouched))
|
|
129
138
|
t.filesTouched = ev.filesTouched;
|
|
139
|
+
if (Array.isArray(ev.sources))
|
|
140
|
+
t.sources = ev.sources;
|
|
130
141
|
}
|
|
131
142
|
break;
|
|
132
143
|
}
|
|
@@ -195,23 +206,7 @@ class RunState {
|
|
|
195
206
|
});
|
|
196
207
|
break;
|
|
197
208
|
case "note.added":
|
|
198
|
-
this.
|
|
199
|
-
t: ev.t,
|
|
200
|
-
taskId: ev.taskId,
|
|
201
|
-
agentId: ev.agentId,
|
|
202
|
-
key: ev.key,
|
|
203
|
-
kind: ev.kind,
|
|
204
|
-
text: ev.text,
|
|
205
|
-
});
|
|
206
|
-
// Reduced state is held live by the hub and the resume seed — keep
|
|
207
|
-
// only the tail that digests/views actually use. Decisions are never
|
|
208
|
-
// dropped: they anchor the conductor's long-horizon coherence.
|
|
209
|
-
if (this.notes.length > 1000) {
|
|
210
|
-
const decisions = this.notes.filter((n) => n.kind === "decision");
|
|
211
|
-
const rest = this.notes.filter((n) => n.kind !== "decision");
|
|
212
|
-
rest.splice(0, rest.length - Math.max(0, 1000 - decisions.length));
|
|
213
|
-
this.notes = [...decisions, ...rest].sort((a, b) => a.t - b.t);
|
|
214
|
-
}
|
|
209
|
+
this.pushNote(ev);
|
|
215
210
|
break;
|
|
216
211
|
case "conductor.say":
|
|
217
212
|
this.conductorLog.push({ t: ev.t, text: ev.text });
|
|
@@ -233,6 +228,7 @@ class RunState {
|
|
|
233
228
|
this.usageByModel.set(model, (0, types_1.addUsage)(this.usageByModel.get(model) ?? { ...types_1.ZERO_USAGE }, u));
|
|
234
229
|
this.totalUsage = (0, types_1.addUsage)(this.totalUsage, u);
|
|
235
230
|
this.cost += (0, types_1.usageCost)(u, this.pricing[model]);
|
|
231
|
+
this.pushBudgetPoint(ev.t);
|
|
236
232
|
break;
|
|
237
233
|
}
|
|
238
234
|
case "run.final":
|
|
@@ -241,6 +237,60 @@ class RunState {
|
|
|
241
237
|
break;
|
|
242
238
|
}
|
|
243
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Sample the cumulative spend: a point per meaningful jump (≥0.5% of the
|
|
242
|
+
* budget cap, or 2k tokens unbounded), halving resolution past 600 points.
|
|
243
|
+
*/
|
|
244
|
+
pushBudgetPoint(t) {
|
|
245
|
+
const tokens = this.totalUsage.promptTokens + this.totalUsage.completionTokens;
|
|
246
|
+
const cap = this.meta?.options?.maxTokens ?? 0;
|
|
247
|
+
const minStep = cap > 0 ? Math.max(2000, cap * 0.005) : 2000;
|
|
248
|
+
const last = this.budgetSeries[this.budgetSeries.length - 1];
|
|
249
|
+
if (last && tokens - last.tokens < minStep) {
|
|
250
|
+
last.t = t;
|
|
251
|
+
last.tokens = tokens;
|
|
252
|
+
last.cost = this.cost;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.budgetSeries.push({ t, tokens, cost: this.cost });
|
|
256
|
+
if (this.budgetSeries.length > 600) {
|
|
257
|
+
this.budgetSeries = this.budgetSeries.filter((_, i) => i % 2 === 0 || i === this.budgetSeries.length - 1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
pushNote(ev, teamId) {
|
|
261
|
+
this.notes.push({
|
|
262
|
+
t: ev.t,
|
|
263
|
+
taskId: ev.taskId,
|
|
264
|
+
teamId,
|
|
265
|
+
agentId: ev.agentId,
|
|
266
|
+
key: ev.key,
|
|
267
|
+
kind: ev.kind,
|
|
268
|
+
text: ev.text,
|
|
269
|
+
url: typeof ev.url === "string" ? ev.url : undefined,
|
|
270
|
+
});
|
|
271
|
+
// Reduced state is held live by the hub and the resume seed — keep only
|
|
272
|
+
// the tail that digests/views actually use. Decisions and conflicts are
|
|
273
|
+
// never dropped: they anchor long-horizon coherence. Forward-pass splice
|
|
274
|
+
// (mirroring the executor's addNote): the array is permanently at the cap
|
|
275
|
+
// once a long run passes it, so this runs on every note event — no
|
|
276
|
+
// filter/sort allocations on the reducer hot path.
|
|
277
|
+
if (this.notes.length > 1000) {
|
|
278
|
+
const keep = (n) => n.kind === "decision" || n.kind === "conflict";
|
|
279
|
+
let pinnedCount = 0;
|
|
280
|
+
for (const n of this.notes)
|
|
281
|
+
if (keep(n))
|
|
282
|
+
pinnedCount++;
|
|
283
|
+
let toDrop = this.notes.length - Math.max(pinnedCount, 1000);
|
|
284
|
+
for (let i = 0; i < this.notes.length && toDrop > 0;) {
|
|
285
|
+
if (!keep(this.notes[i])) {
|
|
286
|
+
this.notes.splice(i, 1);
|
|
287
|
+
toDrop--;
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
i++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
244
294
|
taskList() {
|
|
245
295
|
return this.taskOrder.map((id) => this.tasks.get(id)).filter(Boolean);
|
|
246
296
|
}
|
package/dist/tools.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.synthToolset = synthToolset;
|
|
|
40
40
|
const fs = __importStar(require("fs"));
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const crawltools_1 = require("./crawltools");
|
|
43
|
+
const searchcore_1 = require("./searchcore");
|
|
43
44
|
const util_1 = require("./util");
|
|
44
45
|
const webtools_1 = require("./webtools");
|
|
45
46
|
// ---------- safety ----------
|
|
@@ -62,9 +63,48 @@ function checkCommand(cmd, cfg) {
|
|
|
62
63
|
function resolveRead(p, ctx) {
|
|
63
64
|
return path.resolve(ctx.workdir, p);
|
|
64
65
|
}
|
|
66
|
+
/** Single-quote a string for sh. */
|
|
67
|
+
function shq(s) {
|
|
68
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Where a write actually lands: realpath of the deepest existing ancestor plus
|
|
72
|
+
* the not-yet-created remainder. Confinement checks must use this, or a
|
|
73
|
+
* symlink inside the workdir smuggles writes anywhere on the host.
|
|
74
|
+
*/
|
|
75
|
+
function realDestination(abs) {
|
|
76
|
+
let dir = abs;
|
|
77
|
+
const tail = [];
|
|
78
|
+
while (!fs.existsSync(dir)) {
|
|
79
|
+
tail.unshift(path.basename(dir));
|
|
80
|
+
const parent = path.dirname(dir);
|
|
81
|
+
if (parent === dir)
|
|
82
|
+
break;
|
|
83
|
+
dir = parent;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
dir = fs.realpathSync(dir);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* races/permissions: keep the lexical path */
|
|
90
|
+
}
|
|
91
|
+
return path.join(dir, ...tail);
|
|
92
|
+
}
|
|
93
|
+
function realBase(base) {
|
|
94
|
+
try {
|
|
95
|
+
return fs.realpathSync(base);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return base;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
65
101
|
function resolveWrite(p, ctx) {
|
|
66
102
|
const abs = path.resolve(ctx.workdir, p);
|
|
67
|
-
|
|
103
|
+
// Remote sandboxes own their filesystem — host-side realpath is meaningless there.
|
|
104
|
+
const real = ctx.sandbox.localFs ? realDestination(abs) : abs;
|
|
105
|
+
const ok = (0, util_1.pathInside)(realBase(ctx.workdir), real) ||
|
|
106
|
+
(0, util_1.pathInside)(realBase(ctx.runDirPath), real) ||
|
|
107
|
+
!ctx.cfg.safeMode;
|
|
68
108
|
if (!ok) {
|
|
69
109
|
throw new Error(`safeMode: writes are restricted to the working directory (${ctx.workdir}). ` +
|
|
70
110
|
`Use a relative path, or save deliverables with save_artifact.`);
|
|
@@ -171,7 +211,7 @@ function workerToolset(cfg) {
|
|
|
171
211
|
tools.replace_in_file = {
|
|
172
212
|
schema: {
|
|
173
213
|
name: "replace_in_file",
|
|
174
|
-
description: "Exact string replacement in a file. `find` must match exactly (including whitespace). Fails if not found, or if ambiguous when all=false.",
|
|
214
|
+
description: "Exact string replacement in a file. `find` must match exactly (including whitespace). Fails if not found, or if ambiguous when all=false. For several edits to the same file, pass `edits` — they apply in order, all-or-nothing, in one call.",
|
|
175
215
|
parameters: {
|
|
176
216
|
type: "object",
|
|
177
217
|
properties: {
|
|
@@ -179,25 +219,108 @@ function workerToolset(cfg) {
|
|
|
179
219
|
find: { type: "string" },
|
|
180
220
|
replace: { type: "string" },
|
|
181
221
|
all: { type: "boolean", description: "Replace every occurrence (default false)" },
|
|
222
|
+
edits: {
|
|
223
|
+
type: "array",
|
|
224
|
+
description: "Batch mode: multiple find/replace pairs applied in order, atomically (replaces top-level find/replace)",
|
|
225
|
+
items: {
|
|
226
|
+
type: "object",
|
|
227
|
+
properties: {
|
|
228
|
+
find: { type: "string" },
|
|
229
|
+
replace: { type: "string" },
|
|
230
|
+
all: { type: "boolean" },
|
|
231
|
+
},
|
|
232
|
+
required: ["find", "replace"],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
182
235
|
},
|
|
183
|
-
required: ["path"
|
|
236
|
+
required: ["path"],
|
|
184
237
|
},
|
|
185
238
|
},
|
|
186
239
|
run: async (args, ctx) => {
|
|
187
240
|
const abs = resolveWrite(String(args.path), ctx);
|
|
188
241
|
const raw = await readFileVia(ctx, abs);
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
242
|
+
const edits = Array.isArray(args.edits) && args.edits.length
|
|
243
|
+
? args.edits.map((e) => ({
|
|
244
|
+
find: String(e.find ?? ""),
|
|
245
|
+
replace: String(e.replace ?? ""),
|
|
246
|
+
all: Boolean(e.all),
|
|
247
|
+
}))
|
|
248
|
+
: args.find !== undefined && args.replace !== undefined
|
|
249
|
+
? [{ find: String(args.find), replace: String(args.replace), all: Boolean(args.all) }]
|
|
250
|
+
: null;
|
|
251
|
+
if (!edits)
|
|
252
|
+
throw new Error("provide find+replace, or an edits array");
|
|
253
|
+
// Validate-then-apply against the progressively edited content:
|
|
254
|
+
// any failing edit aborts the whole batch with nothing written.
|
|
255
|
+
let next = raw;
|
|
256
|
+
let total = 0;
|
|
257
|
+
const at = (i) => (edits.length > 1 ? `edit ${i + 1}: ` : "");
|
|
258
|
+
for (let i = 0; i < edits.length; i++) {
|
|
259
|
+
const { find, replace, all } = edits[i];
|
|
260
|
+
if (!find)
|
|
261
|
+
throw new Error(`${at(i)}find must not be empty`);
|
|
262
|
+
const count = next.split(find).length - 1;
|
|
263
|
+
if (count === 0) {
|
|
264
|
+
throw new Error(`${at(i)}find string not found in file${edits.length > 1 ? " — no edits were applied" : ""}`);
|
|
265
|
+
}
|
|
266
|
+
if (count > 1 && !all) {
|
|
267
|
+
throw new Error(`${at(i)}find string matches ${count} times; provide more context or set all=true${edits.length > 1 ? " — no edits were applied" : ""}`);
|
|
268
|
+
}
|
|
269
|
+
next = all ? next.split(find).join(replace) : next.replace(find, replace);
|
|
270
|
+
total += all ? count : 1;
|
|
196
271
|
}
|
|
197
|
-
const next = args.all ? raw.split(find).join(replace) : raw.replace(find, replace);
|
|
198
272
|
await writeFileVia(ctx, abs, next);
|
|
199
273
|
const warn = ctx.checkClaim?.(String(args.path));
|
|
200
|
-
return `replaced ${
|
|
274
|
+
return `replaced ${total} occurrence(s) via ${edits.length} edit(s) in ${abs}${warn ? `\n${warn}` : ""}`;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
tools.grep_files = {
|
|
278
|
+
schema: {
|
|
279
|
+
name: "grep_files",
|
|
280
|
+
description: "Search file contents with a regex (grep -E syntax). Returns matching lines as path:line:text. Use this to locate code or text instead of shell grep pipelines — one round-trip, works identically in remote sandboxes, skips node_modules/.git/build output.",
|
|
281
|
+
parameters: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
pattern: { type: "string", description: "Extended regex (grep -E)" },
|
|
285
|
+
path: { type: "string", description: "Directory or file to search (default: working directory)" },
|
|
286
|
+
glob: { type: "string", description: "Filename filter, e.g. *.ts" },
|
|
287
|
+
ignore_case: { type: "boolean" },
|
|
288
|
+
max_results: { type: "number", description: "Default 50, max 200" },
|
|
289
|
+
},
|
|
290
|
+
required: ["pattern"],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
run: async (args, ctx) => {
|
|
294
|
+
const pattern = String(args.pattern ?? "");
|
|
295
|
+
if (!pattern.trim())
|
|
296
|
+
throw new Error("pattern is required");
|
|
297
|
+
const root = args.path ? resolveRead(String(args.path), ctx) : ctx.workdir;
|
|
298
|
+
const max = Math.min(Math.max(Number(args.max_results) || 50, 1), 200);
|
|
299
|
+
const flags = `-rnE${args.ignore_case ? "i" : ""}`;
|
|
300
|
+
const include = args.glob ? ` --include=${shq(String(args.glob))}` : "";
|
|
301
|
+
const excludes = ["node_modules", ".git", "dist", ".next", "out", "build", "target", "__pycache__", ".venv"]
|
|
302
|
+
.map((d) => ` --exclude-dir=${d}`)
|
|
303
|
+
.join("");
|
|
304
|
+
// No `| head`: a pipe would mask grep's exit code, and an invalid regex
|
|
305
|
+
// or unreadable path must fail loudly, not read as "no matches".
|
|
306
|
+
// (Output volume is already bounded by the sandbox's collect cap.)
|
|
307
|
+
const cmd = `grep ${flags}${include}${excludes} -e ${shq(pattern)} ${shq(root)}`;
|
|
308
|
+
const r = await ctx.sandbox.exec(cmd, { cwd: ctx.workdir, timeoutSec: 60, signal: ctx.signal });
|
|
309
|
+
// Sandbox exec merges stderr into out — separate grep's diagnostics.
|
|
310
|
+
const all = r.out.split("\n").filter(Boolean);
|
|
311
|
+
const diags = all.filter((l) => l.startsWith("grep:"));
|
|
312
|
+
const lines = all.filter((l) => !l.startsWith("grep:"));
|
|
313
|
+
// Exit 1 = clean no-match. Anything past 1 with zero matches is a real
|
|
314
|
+
// failure (bad pattern, missing path); with matches it's partial
|
|
315
|
+
// (some files unreadable) and the matches still count.
|
|
316
|
+
if (r.code !== 0 && r.code !== 1 && !lines.length) {
|
|
317
|
+
throw new Error(`grep failed (exit ${r.code}): ${diags.join("; ").slice(0, 300) || "no error detail"}`);
|
|
318
|
+
}
|
|
319
|
+
if (!lines.length)
|
|
320
|
+
return "no matches";
|
|
321
|
+
const shown = lines.slice(0, max);
|
|
322
|
+
const more = lines.length > max ? `\n…more matches truncated (raise max_results or narrow the pattern)` : "";
|
|
323
|
+
return shown.join("\n") + more;
|
|
201
324
|
},
|
|
202
325
|
};
|
|
203
326
|
tools.list_dir = {
|
|
@@ -295,6 +418,39 @@ function workerToolset(cfg) {
|
|
|
295
418
|
.join("\n");
|
|
296
419
|
},
|
|
297
420
|
};
|
|
421
|
+
tools.academic_search = {
|
|
422
|
+
schema: {
|
|
423
|
+
name: "academic_search",
|
|
424
|
+
description: "Search scholarly sources: arXiv preprints and Crossref journal/conference metadata (keyless APIs). Returns papers with title, link (arXiv/DOI), abstract snippet, and date. Use for scientific or technical questions where peer-reviewed and preprint sources beat the open web.",
|
|
425
|
+
parameters: {
|
|
426
|
+
type: "object",
|
|
427
|
+
properties: {
|
|
428
|
+
query: { type: "string" },
|
|
429
|
+
count: { type: "number", description: "Max results, default 8, max 20" },
|
|
430
|
+
},
|
|
431
|
+
required: ["query"],
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
run: async (args, ctx) => {
|
|
435
|
+
const count = Math.min(Math.max(Number(args.count) || 8, 1), 20);
|
|
436
|
+
const q = String(args.query);
|
|
437
|
+
const settled = await Promise.allSettled([
|
|
438
|
+
(0, webtools_1.arxivSearch)(q, count, ctx.signal),
|
|
439
|
+
(0, webtools_1.crossrefSearch)(q, count, ctx.signal),
|
|
440
|
+
]);
|
|
441
|
+
const candidates = settled.flatMap((s) => (s.status === "fulfilled" ? s.value : []));
|
|
442
|
+
if (!candidates.length) {
|
|
443
|
+
const err = settled.find((s) => s.status === "rejected");
|
|
444
|
+
if (err)
|
|
445
|
+
throw err.reason;
|
|
446
|
+
return "no results";
|
|
447
|
+
}
|
|
448
|
+
const merged = (0, searchcore_1.mergeCandidates)(candidates, count);
|
|
449
|
+
return merged
|
|
450
|
+
.map((h, i) => `${i + 1}. ${h.title}${h.date ? ` (${h.date})` : ""} [${h.engine}]\n ${h.url}\n ${h.snippet}`)
|
|
451
|
+
.join("\n");
|
|
452
|
+
},
|
|
453
|
+
};
|
|
298
454
|
tools.fetch_url = {
|
|
299
455
|
schema: {
|
|
300
456
|
name: "fetch_url",
|
|
@@ -318,7 +474,7 @@ function workerToolset(cfg) {
|
|
|
318
474
|
tools.note = {
|
|
319
475
|
schema: {
|
|
320
476
|
name: "note",
|
|
321
|
-
description: "Post a durable fact/discovery to the swarm's shared blackboard so the conductor and other agents can see it. Use sparingly — facts other tasks need, not progress chatter. Mark kind='decision' for choices the rest of the mission must respect (
|
|
477
|
+
description: "Post a durable fact/discovery to the swarm's shared blackboard so the conductor and other agents can see it. Use sparingly — facts other tasks need, not progress chatter. Mark kind='decision' for choices the rest of the mission must respect, and kind='conflict' when independent sources disagree on a material fact (both are never trimmed from digests).",
|
|
322
478
|
parameters: {
|
|
323
479
|
type: "object",
|
|
324
480
|
properties: {
|
|
@@ -326,18 +482,20 @@ function workerToolset(cfg) {
|
|
|
326
482
|
key: { type: "string", description: "Optional short label" },
|
|
327
483
|
kind: {
|
|
328
484
|
type: "string",
|
|
329
|
-
enum: ["finding", "decision", "open-question", "handoff", "claim"],
|
|
330
|
-
description: "Category (default finding). kind='claim' with key=<file path> advertises you are editing that file",
|
|
485
|
+
enum: ["finding", "decision", "conflict", "open-question", "handoff", "claim"],
|
|
486
|
+
description: "Category (default finding). kind='conflict' flags sources that disagree — name both. kind='claim' with key=<file path> advertises you are editing that file",
|
|
331
487
|
},
|
|
488
|
+
url: { type: "string", description: "Source URL backing this note, when it came from the web" },
|
|
332
489
|
},
|
|
333
490
|
required: ["text"],
|
|
334
491
|
},
|
|
335
492
|
},
|
|
336
493
|
run: async (args, ctx) => {
|
|
337
|
-
const kind = ["finding", "decision", "open-question", "handoff", "claim"].includes(String(args.kind))
|
|
494
|
+
const kind = ["finding", "decision", "conflict", "open-question", "handoff", "claim"].includes(String(args.kind))
|
|
338
495
|
? String(args.kind)
|
|
339
496
|
: undefined;
|
|
340
|
-
|
|
497
|
+
const url = /^https?:\/\//.test(String(args.url ?? "")) ? String(args.url) : undefined;
|
|
498
|
+
ctx.addNote(String(args.text), args.key ? String(args.key) : undefined, kind, url);
|
|
341
499
|
return "noted on the blackboard";
|
|
342
500
|
},
|
|
343
501
|
};
|
|
@@ -416,8 +574,12 @@ function workerToolset(cfg) {
|
|
|
416
574
|
},
|
|
417
575
|
run: async (args, ctx) => {
|
|
418
576
|
const name = String(args.name).replace(/^\/+/, "");
|
|
419
|
-
const
|
|
420
|
-
|
|
577
|
+
const artifactsRoot = path.join(ctx.runDirPath, "artifacts");
|
|
578
|
+
(0, util_1.ensureDir)(artifactsRoot);
|
|
579
|
+
const dest = path.join(artifactsRoot, name);
|
|
580
|
+
// Realpath-based: neither ../ traversal nor a planted symlink may move
|
|
581
|
+
// the artifact outside the run's artifacts folder.
|
|
582
|
+
if (!(0, util_1.pathInside)(realBase(artifactsRoot), realDestination(dest))) {
|
|
421
583
|
throw new Error("artifact name must stay inside the artifacts folder");
|
|
422
584
|
}
|
|
423
585
|
(0, util_1.ensureDir)(path.dirname(dest));
|
|
@@ -546,6 +708,20 @@ exports.REPORT_TOOL = {
|
|
|
546
708
|
items: { type: "string" },
|
|
547
709
|
description: "Every file you created or modified (exact paths)",
|
|
548
710
|
},
|
|
711
|
+
sources: {
|
|
712
|
+
type: "array",
|
|
713
|
+
description: "Web sources your findings rely on — REQUIRED whenever your work drew on the web. They flow into the final report's bibliography; a web-sourced claim without an entry here cannot be cited.",
|
|
714
|
+
items: {
|
|
715
|
+
type: "object",
|
|
716
|
+
properties: {
|
|
717
|
+
url: { type: "string" },
|
|
718
|
+
title: { type: "string" },
|
|
719
|
+
date: { type: "string", description: "Publication date if known (ISO or year)" },
|
|
720
|
+
note: { type: "string", description: "What this source supports" },
|
|
721
|
+
},
|
|
722
|
+
required: ["url"],
|
|
723
|
+
},
|
|
724
|
+
},
|
|
549
725
|
},
|
|
550
726
|
required: ["status", "report"],
|
|
551
727
|
},
|
|
@@ -561,6 +737,19 @@ exports.VERDICT_TOOL = {
|
|
|
561
737
|
type: "string",
|
|
562
738
|
description: "If fail: exactly what is wrong and where. If pass: one-line confirmation of the evidence.",
|
|
563
739
|
},
|
|
740
|
+
issues: {
|
|
741
|
+
type: "array",
|
|
742
|
+
description: "On fail: one entry per concrete problem. The worker's retry sees these verbatim — make each actionable.",
|
|
743
|
+
items: {
|
|
744
|
+
type: "object",
|
|
745
|
+
properties: {
|
|
746
|
+
problem: { type: "string", description: "What is wrong" },
|
|
747
|
+
evidence: { type: "string", description: "What you observed that proves it (command output, file content, URL)" },
|
|
748
|
+
fix: { type: "string", description: "The exact change that would resolve it" },
|
|
749
|
+
},
|
|
750
|
+
required: ["problem"],
|
|
751
|
+
},
|
|
752
|
+
},
|
|
564
753
|
},
|
|
565
754
|
required: ["pass", "feedback"],
|
|
566
755
|
},
|