@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.
Files changed (37) hide show
  1. package/README.md +29 -12
  2. package/dist/agent.js +6 -15
  3. package/dist/cli.js +31 -4
  4. package/dist/config.js +44 -1
  5. package/dist/crawltools.js +3 -22
  6. package/dist/executor.js +276 -60
  7. package/dist/hub.js +67 -3
  8. package/dist/journal.js +39 -5
  9. package/dist/memory.js +17 -11
  10. package/dist/pdftext.js +211 -0
  11. package/dist/prompts.js +23 -15
  12. package/dist/report.js +39 -1
  13. package/dist/run.js +8 -0
  14. package/dist/sandbox.js +11 -0
  15. package/dist/searchcore.js +55 -2
  16. package/dist/state.js +67 -17
  17. package/dist/tools.js +208 -19
  18. package/dist/util.js +117 -3
  19. package/dist/webtools.js +185 -32
  20. package/package.json +1 -1
  21. package/ui/out/404/index.html +1 -1
  22. package/ui/out/404.html +1 -1
  23. package/ui/out/_next/static/chunks/677-a62d486d6734bcf3.js +1 -0
  24. package/ui/out/_next/static/chunks/app/run/page-c29f95c51af08c60.js +1 -0
  25. package/ui/out/_next/static/chunks/app/settings/page-41a5d8ba43ecfd4a.js +1 -0
  26. package/ui/out/_next/static/css/{9f7bd82b8e4c762c.css → d95c2ba395730031.css} +1 -1
  27. package/ui/out/index.html +1 -1
  28. package/ui/out/index.txt +3 -3
  29. package/ui/out/run/index.html +1 -1
  30. package/ui/out/run/index.txt +3 -3
  31. package/ui/out/settings/index.html +1 -1
  32. package/ui/out/settings/index.txt +3 -3
  33. package/ui/out/_next/static/chunks/677-859e8d42add1806b.js +0 -1
  34. package/ui/out/_next/static/chunks/app/run/page-2420c9e4c963d9b3.js +0 -1
  35. package/ui/out/_next/static/chunks/app/settings/page-092a6bf42dfde57d.js +0 -1
  36. /package/ui/out/_next/static/{errjtBR_bKoee8ogLp8xk → JFkx5KtNi0DYyqm_THzbY}/_buildManifest.js +0 -0
  37. /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"));
@@ -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
- if (page.date)
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.notes.push({
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
- const ok = (0, util_1.pathInside)(ctx.workdir, abs) || (0, util_1.pathInside)(ctx.runDirPath, abs) || !ctx.cfg.safeMode;
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", "find", "replace"],
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 find = String(args.find);
190
- const replace = String(args.replace);
191
- const count = raw.split(find).length - 1;
192
- if (count === 0)
193
- throw new Error("find string not found in file");
194
- if (count > 1 && !args.all) {
195
- throw new Error(`find string matches ${count} times; provide more context or set all=true`);
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 ${args.all ? count : 1} occurrence(s) in ${abs}${warn ? `\n${warn}` : ""}`;
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 (these are never trimmed from digests).",
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
- ctx.addNote(String(args.text), args.key ? String(args.key) : undefined, kind);
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 dest = path.join(ctx.runDirPath, "artifacts", name);
420
- if (!(0, util_1.pathInside)(path.join(ctx.runDirPath, "artifacts"), dest)) {
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
  },