@odla-ai/harness 0.7.0 → 0.8.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.
@@ -4,10 +4,10 @@ import {
4
4
  stageWorkspace,
5
5
  stageWorkspacePair,
6
6
  verifyContainerEngineBoundary
7
- } from "./chunk-K76I2TCQ.js";
7
+ } from "./chunk-CR6RE3A2.js";
8
8
  import {
9
9
  HARNESS_PROTOCOL_VERSION
10
- } from "./chunk-LNQNFGQC.js";
10
+ } from "./chunk-RXNHCGWE.js";
11
11
 
12
12
  // src/workspace-digest.ts
13
13
  import { createHash } from "crypto";
@@ -351,7 +351,7 @@ function validateCodePatch(rawPatch, maxBytes) {
351
351
  if (FORBIDDEN.test(patch2) || /(?:old|new)(?: file)? mode 120000/.test(patch2)) {
352
352
  throw new TypeError("patch uses a forbidden binary, link, mode, rename, or copy operation");
353
353
  }
354
- const paths = [];
354
+ const paths2 = [];
355
355
  const lines = patch2.split("\n");
356
356
  for (let index = 0; index < lines.length; index += 1) {
357
357
  const line = lines[index];
@@ -367,10 +367,10 @@ function validateCodePatch(rawPatch, maxBytes) {
367
367
  if (!validHeaderPath(oldPath, path, "a") || !validHeaderPath(newPath, path, "b")) {
368
368
  throw new TypeError("patch file headers do not match the declared path");
369
369
  }
370
- paths.push(path);
370
+ paths2.push(path);
371
371
  }
372
- if (!paths.length || new Set(paths).size !== paths.length) throw new TypeError("patch has no diffs or repeats a path");
373
- return paths;
372
+ if (!paths2.length || new Set(paths2).size !== paths2.length) throw new TypeError("patch has no diffs or repeats a path");
373
+ return paths2;
374
374
  }
375
375
  function validHeaderPath(value, path, prefix) {
376
376
  return value === "/dev/null" || value === `${prefix}/${path}`;
@@ -395,11 +395,11 @@ function describePatchFailure(patch2, detail) {
395
395
  const hint = hunks.length > 0 && contextless ? " A hunk has no context lines; include at least one unchanged line above or below each change." : "";
396
396
  return `patch did not apply: ${detail}${hint}`;
397
397
  }
398
- async function applyCodePatch(workspaceDir, rawPatch, paths) {
398
+ async function applyCodePatch(workspaceDir, rawPatch, paths2) {
399
399
  const patch2 = stripPatchEnvelope(rawPatch);
400
400
  await gitApply(workspaceDir, patch2, true);
401
401
  await gitApply(workspaceDir, patch2, false);
402
- for (const path of paths) {
402
+ for (const path of paths2) {
403
403
  try {
404
404
  const info = await lstat(resolveCodePath(workspaceDir, path));
405
405
  if (info.isSymbolicLink() || !info.isFile() && !info.isDirectory()) {
@@ -421,8 +421,8 @@ function gitApply(cwd, patch2, check) {
421
421
  });
422
422
  let stderr = "";
423
423
  child.stderr.setEncoding("utf8");
424
- child.stderr.on("data", (text) => {
425
- if (stderr.length < 4e3) stderr += text.slice(0, 4e3);
424
+ child.stderr.on("data", (text2) => {
425
+ if (stderr.length < 4e3) stderr += text2.slice(0, 4e3);
426
426
  });
427
427
  child.once("error", reject);
428
428
  child.once("exit", (code) => code === 0 ? accept() : reject(new TypeError(describePatchFailure(patch2, stderr.trim().slice(0, 500)))));
@@ -452,8 +452,8 @@ async function restoreCodeWorkspaceCheckpoint(input) {
452
452
  const workspace = await stageWorkspace(input.trustedBaseDir, input.stage);
453
453
  try {
454
454
  if (checkpoint.patch) {
455
- const paths = validateCodePatch(checkpoint.patch, 256 * 1024);
456
- await applyCodePatch(workspace.workspaceDir, checkpoint.patch, paths);
455
+ const paths2 = validateCodePatch(checkpoint.patch, 256 * 1024);
456
+ await applyCodePatch(workspace.workspaceDir, checkpoint.patch, paths2);
457
457
  }
458
458
  return { workspace, checkpoint };
459
459
  } catch (error) {
@@ -622,8 +622,8 @@ async function verifyCodeCandidate(input) {
622
622
  try {
623
623
  const baseDigest = await digestStagedWorkspace(staged.workspaceDir, limits);
624
624
  if (baseDigest !== input.trustedBaseDigest) throw new TypeError("trusted base does not match its registered digest");
625
- const paths = validateCodePatch(input.candidatePatch, policy.maximumPatchBytes);
626
- await applyCodePatch(staged.workspaceDir, input.candidatePatch, paths);
625
+ const paths2 = validateCodePatch(input.candidatePatch, policy.maximumPatchBytes);
626
+ await applyCodePatch(staged.workspaceDir, input.candidatePatch, paths2);
627
627
  const sourceDigest = await digestStagedWorkspace(staged.workspaceDir, limits);
628
628
  const policyDigest = digestPolicy(policy);
629
629
  const patchDigest = digestBytes(input.candidatePatch);
@@ -632,7 +632,7 @@ async function verifyCodeCandidate(input) {
632
632
  trustedBaseDigest: input.trustedBaseDigest,
633
633
  patchDigest
634
634
  });
635
- const changedTests = changedTestPaths(paths, policy);
635
+ const changedTests = changedTestPaths(paths2, policy);
636
636
  if (changedTests.length > policy.maximumChangedTests) throw new TypeError("candidate changes too many test files");
637
637
  const recipes = [];
638
638
  const logs = [];
@@ -699,8 +699,8 @@ function validate(input) {
699
699
  }
700
700
  return result;
701
701
  }
702
- function changedTestPaths(paths, policy) {
703
- return paths.filter((path) => policy.testPathSuffixes.some((suffix) => path.endsWith(suffix)) || policy.testPathPrefixes.some((prefix) => path.startsWith(prefix) || path.includes(`/${prefix}`))).sort();
702
+ function changedTestPaths(paths2, policy) {
703
+ return paths2.filter((path) => policy.testPathSuffixes.some((suffix) => path.endsWith(suffix)) || policy.testPathPrefixes.some((prefix) => path.startsWith(prefix) || path.includes(`/${prefix}`))).sort();
704
704
  }
705
705
  function recipeReceipt(recipe2, result, artifacts) {
706
706
  const status = result.timedOut ? "timed_out" : result.outputLimitExceeded ? "output_limited" : result.exitCode === 0 && artifacts.every((item) => item.status === "verified") ? "passed" : "failed";
@@ -1128,7 +1128,10 @@ Never claim a build or test passed unless odla_run_recipe returned that result.`
1128
1128
  var V2_SYSTEM_PROMPT = `You are the coding agent inside an odla Code harness.
1129
1129
  Start by orienting: odla_list shows the files in the workspace and odla_search
1130
1130
  finds a literal string across them. Prefer those over guessing a path.
1131
- Then odla_read a bounded range, and odla_apply_git_diff to mutate.
1131
+ Then odla_read a bounded range, and odla_apply_git_diff to mutate. When you
1132
+ need several independent searches or file ranges, issue those read-only calls
1133
+ together in one turn; their results stay ordered and the harness overlaps them.
1134
+ Never issue odla_apply_git_diff or odla_run_recipe alongside another tool call.
1132
1135
  For mutations, call odla_apply_git_diff with raw git diff text. It must start
1133
1136
  with "diff --git a/<path> b/<path>", include matching "---" and "+++" file
1134
1137
  headers and numbered "@@" hunks, and never use "*** Begin Patch" wrappers.
@@ -1159,18 +1162,26 @@ var SYSTEM_PROMPT_FOR = {
1159
1162
  };
1160
1163
  function codeSkill(opts) {
1161
1164
  let seq = 0;
1165
+ let nextCompletion = 1;
1166
+ const completed = /* @__PURE__ */ new Map();
1162
1167
  const call = async (tool, input, signal) => {
1168
+ const sequence = ++seq;
1163
1169
  const startedAt = Date.now();
1164
1170
  const response2 = await opts.broker.execute(
1165
1171
  { lease: opts.lease, workspaceDir: opts.workspaceDir, signal },
1166
- { requestId: `bench-${tool}-${++seq}`, tool, input }
1172
+ { requestId: `bench-${tool}-${sequence}`, tool, input }
1167
1173
  );
1168
- opts.onToolCall?.({
1174
+ completed.set(sequence, {
1169
1175
  tool,
1170
1176
  ok: response2.ok,
1171
1177
  durationMs: Date.now() - startedAt,
1172
1178
  ...response2.ok ? {} : { error: String(response2.content).slice(0, 300) }
1173
1179
  });
1180
+ while (completed.has(nextCompletion)) {
1181
+ const completion = completed.get(nextCompletion);
1182
+ completed.delete(nextCompletion++);
1183
+ opts.onToolCall?.(completion);
1184
+ }
1174
1185
  return { content: response2.content, isError: !response2.ok };
1175
1186
  };
1176
1187
  const read2 = {
@@ -1186,6 +1197,7 @@ function codeSkill(opts) {
1186
1197
  },
1187
1198
  additionalProperties: false
1188
1199
  },
1200
+ concurrency: "parallel",
1189
1201
  handler: (input, ctx) => call("sandbox.read", input, ctx.signal)
1190
1202
  };
1191
1203
  const applyPatch = {
@@ -1227,6 +1239,7 @@ function codeSkill(opts) {
1227
1239
  },
1228
1240
  additionalProperties: false
1229
1241
  },
1242
+ concurrency: "parallel",
1230
1243
  handler: (input, ctx) => call("sandbox.list", input, ctx.signal)
1231
1244
  };
1232
1245
  const searchFiles = {
@@ -1243,11 +1256,13 @@ function codeSkill(opts) {
1243
1256
  },
1244
1257
  additionalProperties: false
1245
1258
  },
1259
+ concurrency: "parallel",
1246
1260
  handler: (input, ctx) => call("sandbox.search", input, ctx.signal)
1247
1261
  };
1248
1262
  const graphTool = (name, tool, description, required) => ({
1249
1263
  name,
1250
1264
  description,
1265
+ concurrency: "parallel",
1251
1266
  inputSchema: {
1252
1267
  type: "object",
1253
1268
  ...required ? { required: ["query"] } : {},
@@ -1437,13 +1452,32 @@ function createCodeRuntimeInference(options) {
1437
1452
  }
1438
1453
 
1439
1454
  // src/code-tool-discovery.ts
1455
+ import { spawn as spawn3 } from "child_process";
1440
1456
  import { readFile as readFile2, readdir as readdir2 } from "fs/promises";
1441
1457
  import { relative as relative2, resolve as resolve4 } from "path";
1442
1458
  var DEFAULT_MAX_FILES = 2e4;
1443
1459
  var DEFAULT_MAX_RESULTS = 100;
1444
1460
  var DEFAULT_MAX_FILE_BYTES = 512 * 1024;
1461
+ function createWorkspaceFileRegistry(limit = DEFAULT_MAX_FILES, enumerate = registeredFiles) {
1462
+ const cache2 = /* @__PURE__ */ new Map();
1463
+ return {
1464
+ files(root) {
1465
+ const existing = cache2.get(root);
1466
+ if (existing) return existing;
1467
+ const pending = enumerate(root, limit).then((paths2) => Object.freeze(paths2));
1468
+ cache2.set(root, pending);
1469
+ void pending.catch(() => {
1470
+ if (cache2.get(root) === pending) cache2.delete(root);
1471
+ });
1472
+ return pending;
1473
+ },
1474
+ invalidate(root) {
1475
+ cache2.delete(root);
1476
+ }
1477
+ };
1478
+ }
1445
1479
  async function registeredFiles(root, limit = DEFAULT_MAX_FILES) {
1446
- const paths = [];
1480
+ const paths2 = [];
1447
1481
  const walk = async (directory) => {
1448
1482
  for (const entry of await readdir2(directory, { withFileTypes: true })) {
1449
1483
  if (SKIP_WORKSPACE_DIRS.has(entry.name)) continue;
@@ -1457,43 +1491,137 @@ async function registeredFiles(root, limit = DEFAULT_MAX_FILES) {
1457
1491
  } catch {
1458
1492
  continue;
1459
1493
  }
1460
- paths.push(path);
1461
- if (paths.length > limit) throw new TypeError("workspace file registry exceeds its bound");
1494
+ paths2.push(path);
1495
+ if (paths2.length > limit) throw new TypeError("workspace file registry exceeds its bound");
1462
1496
  }
1463
1497
  }
1464
1498
  };
1465
1499
  await walk(resolve4(root));
1466
- return paths.sort();
1500
+ return paths2.sort();
1467
1501
  }
1468
- function listWorkspace(paths, options = {}) {
1502
+ function listWorkspace(paths2, options = {}) {
1469
1503
  const max = options.maxEntries ?? 1e3;
1470
1504
  const prefix = options.prefix?.replace(/\/+$/, "");
1471
- const scoped = prefix ? paths.filter((path) => path === prefix || path.startsWith(`${prefix}/`)) : [...paths];
1505
+ const scoped = prefix ? paths2.filter((path) => path === prefix || path.startsWith(`${prefix}/`)) : [...paths2];
1472
1506
  return scoped.slice(0, max);
1473
1507
  }
1474
- async function searchWorkspace(root, paths, options) {
1475
- const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
1476
- if (!query) throw new TypeError("search query must be a non-empty string");
1508
+ async function searchWorkspace(root, paths2, options) {
1509
+ options.signal?.throwIfAborted();
1510
+ if (!options.query) throw new TypeError("search query must be a non-empty string");
1477
1511
  const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
1478
1512
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
1479
- const scoped = listWorkspace(paths, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths.length });
1513
+ const scoped = listWorkspace(paths2, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths2.length });
1514
+ if (scoped.length === 0) return [];
1515
+ try {
1516
+ return await nativeSearch(root, scoped, { ...options, maxResults, maxFileBytes });
1517
+ } catch (error) {
1518
+ options.signal?.throwIfAborted();
1519
+ return fallbackSearch(root, scoped, { ...options, maxResults, maxFileBytes });
1520
+ }
1521
+ }
1522
+ var MAX_NATIVE_ARG_BYTES = 96 * 1024;
1523
+ async function nativeSearch(root, paths2, options) {
1524
+ const batches = [];
1525
+ let batch = [];
1526
+ let bytes = 0;
1527
+ for (const path of paths2) {
1528
+ const size = Buffer.byteLength(path) + 1;
1529
+ if (batch.length > 0 && bytes + size > MAX_NATIVE_ARG_BYTES) {
1530
+ batches.push(batch);
1531
+ batch = [];
1532
+ bytes = 0;
1533
+ }
1534
+ batch.push(path);
1535
+ bytes += size;
1536
+ }
1537
+ if (batch.length > 0) batches.push(batch);
1538
+ const matches = [];
1539
+ for (const files of batches) {
1540
+ const remaining = options.maxResults - matches.length;
1541
+ if (remaining <= 0) break;
1542
+ matches.push(...await nativeSearchBatch(root, files, options, remaining));
1543
+ }
1544
+ return matches;
1545
+ }
1546
+ function nativeSearchBatch(root, paths2, options, remaining) {
1547
+ return new Promise((resolveMatches, reject) => {
1548
+ const args = [
1549
+ "--fixed-strings",
1550
+ "--json",
1551
+ "--no-messages",
1552
+ "--sort=path",
1553
+ `--max-filesize=${options.maxFileBytes}`,
1554
+ "--max-columns=4096",
1555
+ "--max-columns-preview",
1556
+ options.caseSensitive === false ? "--ignore-case" : "--case-sensitive",
1557
+ "--",
1558
+ options.query,
1559
+ ...paths2
1560
+ ];
1561
+ const child = spawn3("rg", args, {
1562
+ cwd: root,
1563
+ stdio: ["ignore", "pipe", "ignore"],
1564
+ ...options.signal ? { signal: options.signal } : {}
1565
+ });
1566
+ const matches = [];
1567
+ let carry = "";
1568
+ let stopped = false;
1569
+ const consume = (line) => {
1570
+ if (matches.length >= remaining) return;
1571
+ let event;
1572
+ try {
1573
+ event = JSON.parse(line);
1574
+ } catch {
1575
+ return;
1576
+ }
1577
+ const path = event.data?.path?.text;
1578
+ const lineNumber = event.data?.line_number;
1579
+ const source = event.data?.lines?.text;
1580
+ if (event.type !== "match" || path === void 0 || lineNumber === void 0 || source === void 0) return;
1581
+ matches.push({ path, line: lineNumber, text: source.trim().slice(0, 240) });
1582
+ if (matches.length >= remaining) {
1583
+ stopped = true;
1584
+ child.kill();
1585
+ }
1586
+ };
1587
+ child.stdout.setEncoding("utf8");
1588
+ child.stdout.on("data", (chunk) => {
1589
+ carry += chunk;
1590
+ let newline = carry.indexOf("\n");
1591
+ while (newline >= 0) {
1592
+ consume(carry.slice(0, newline));
1593
+ carry = carry.slice(newline + 1);
1594
+ newline = carry.indexOf("\n");
1595
+ }
1596
+ });
1597
+ child.once("error", reject);
1598
+ child.once("close", (code) => {
1599
+ if (carry) consume(carry);
1600
+ if (stopped || code === 0 || code === 1) resolveMatches(matches);
1601
+ else reject(new Error(`native search exited with status ${code ?? "unknown"}`));
1602
+ });
1603
+ });
1604
+ }
1605
+ async function fallbackSearch(root, scoped, options) {
1606
+ const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
1480
1607
  const matches = [];
1481
1608
  for (const path of scoped) {
1482
- if (matches.length >= maxResults) break;
1609
+ options.signal?.throwIfAborted();
1610
+ if (matches.length >= options.maxResults) break;
1483
1611
  let source;
1484
1612
  try {
1485
1613
  source = await readFile2(resolve4(root, path));
1486
1614
  } catch {
1487
1615
  continue;
1488
1616
  }
1489
- if (source.byteLength > maxFileBytes || source.includes(0)) continue;
1617
+ if (source.byteLength > options.maxFileBytes || source.includes(0)) continue;
1490
1618
  const lines = source.toString("utf8").split("\n");
1491
1619
  for (let index = 0; index < lines.length; index += 1) {
1492
1620
  const raw = lines[index];
1493
1621
  const haystack = options.caseSensitive === false ? raw.toLowerCase() : raw;
1494
1622
  if (!haystack.includes(query)) continue;
1495
1623
  matches.push({ path, line: index + 1, text: raw.trim().slice(0, 240) });
1496
- if (matches.length >= maxResults) break;
1624
+ if (matches.length >= options.maxResults) break;
1497
1625
  }
1498
1626
  }
1499
1627
  return matches;
@@ -1626,16 +1754,16 @@ function createCodePolicyGate(options) {
1626
1754
  }
1627
1755
  };
1628
1756
  }
1629
- function directoryPrefixes(paths) {
1757
+ function directoryPrefixes(paths2) {
1630
1758
  const prefixes = /* @__PURE__ */ new Set(["."]);
1631
- for (const path of paths) {
1759
+ for (const path of paths2) {
1632
1760
  const parts = path.split("/");
1633
1761
  for (let index = 1; index < parts.length; index += 1) prefixes.add(parts.slice(0, index).join("/"));
1634
1762
  }
1635
1763
  return [...prefixes].sort();
1636
1764
  }
1637
- async function safePrefix(base, paths, prefix) {
1638
- const prefixes = directoryPrefixes(paths);
1765
+ async function safePrefix(base, paths2, prefix) {
1766
+ const prefixes = directoryPrefixes(paths2);
1639
1767
  const conversions = await conversionRegistry(
1640
1768
  [await registeredPolicy("code.prefix.v1", "code.prefixes.v1", prefixes)],
1641
1769
  { "code.prefixes.v1": prefixes }
@@ -1765,7 +1893,7 @@ import {
1765
1893
  } from "@odla-ai/graph";
1766
1894
  import { buildCodeGraph, FILE, IMPORTS, PACKAGE, READS, SYMBOL, WRITES } from "@odla-ai/graph/code";
1767
1895
  var cache = /* @__PURE__ */ new Map();
1768
- function workspaceGraphs(workspaceDir, paths) {
1896
+ function workspaceGraphs(workspaceDir, paths2) {
1769
1897
  const existing = cache.get(workspaceDir);
1770
1898
  if (existing) return existing;
1771
1899
  const read2 = (path) => readFile3(join3(workspaceDir, path), "utf8");
@@ -1773,11 +1901,14 @@ function workspaceGraphs(workspaceDir, paths) {
1773
1901
  // No knownTables: a staged workspace may not carry migrations, and a filter
1774
1902
  // that silently drops every table is worse than an unfiltered one. Callers
1775
1903
  // with ground truth should build the graph themselves.
1776
- graph: await buildCodeGraph({ paths, read: read2, data: { ignore: (path) => path.includes(".generated.") } })
1904
+ graph: await buildCodeGraph({ paths: paths2, read: read2, data: { ignore: (path) => path.includes(".generated.") } })
1777
1905
  }))();
1778
1906
  cache.set(workspaceDir, built);
1779
1907
  return built;
1780
1908
  }
1909
+ function forgetWorkspaceGraphs(workspaceDir) {
1910
+ cache.delete(workspaceDir);
1911
+ }
1781
1912
  var shortId = (id) => id.slice(id.indexOf(":") + 1);
1782
1913
  function renderOverview(graphs, prefix) {
1783
1914
  const rows = rollup(graphs.graph, FILE, prefix === void 0 ? {} : { prefix });
@@ -1824,7 +1955,7 @@ var GRAPH_TOOLS = /* @__PURE__ */ new Set([
1824
1955
  "sandbox.who_imports",
1825
1956
  "sandbox.who_touches"
1826
1957
  ]);
1827
- async function read(context, request, options, policy) {
1958
+ async function read(context, request, options, policy, registry) {
1828
1959
  exactKeys(request.input, ["path", "startLine", "endLine"]);
1829
1960
  const path = stringField(request.input, "path");
1830
1961
  const startLine = optionalInteger(request.input.startLine) ?? 1;
@@ -1832,11 +1963,11 @@ async function read(context, request, options, policy) {
1832
1963
  if (endLine < startLine || endLine - startLine + 1 > (options.maxReadLines ?? 2e3)) {
1833
1964
  throw new TypeError("requested line range exceeds its bound");
1834
1965
  }
1835
- const paths = await registeredFiles(context.workspaceDir, 2e4);
1836
- if (!paths.includes(path)) {
1966
+ const paths2 = await registry.files(context.workspaceDir);
1967
+ if (!paths2.includes(path)) {
1837
1968
  throw new TypeError(`no such file in the staged workspace: "${path}". Use sandbox.overview, sandbox.where_is or sandbox.search to find the correct path.`);
1838
1969
  }
1839
- const allowed = await policy.read(policyContext(context, request, options, { paths, path, startLine, endLine }));
1970
+ const allowed = await policy.read(policyContext(context, request, options, { paths: paths2, path, startLine, endLine }));
1840
1971
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1841
1972
  const target = resolveCodePath(context.workspaceDir, path);
1842
1973
  const info = await stat(target);
@@ -1852,22 +1983,22 @@ async function read(context, request, options, policy) {
1852
1983
  }
1853
1984
  return response(request, true, content, { path, startLine, endLine: Math.min(endLine, lines.length) });
1854
1985
  }
1855
- async function list(context, request, options, policy) {
1986
+ async function list(context, request, options, policy, registry) {
1856
1987
  exactKeys(request.input, ["prefix", "maxEntries"]);
1857
1988
  const raw = request.input.prefix;
1858
1989
  const prefix = typeof raw === "string" && raw.length > 0 ? raw : void 0;
1859
1990
  const maxEntries = optionalInteger(request.input.maxEntries) ?? 1e3;
1860
1991
  if (maxEntries > 5e3) throw new TypeError("maxEntries exceeds its bound");
1861
- const paths = await registeredFiles(context.workspaceDir, 2e4);
1862
- const allowed = await policy.list(policyContext(context, request, options, { paths, ...prefix ? { prefix } : {} }));
1992
+ const paths2 = await registry.files(context.workspaceDir);
1993
+ const allowed = await policy.list(policyContext(context, request, options, { paths: paths2, ...prefix ? { prefix } : {} }));
1863
1994
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1864
- const entries = listWorkspace(paths, { ...prefix ? { prefix } : {}, maxEntries });
1995
+ const entries = listWorkspace(paths2, { ...prefix ? { prefix } : {}, maxEntries });
1865
1996
  if (!entries.length) {
1866
1997
  return response(request, true, prefix ? `No files under "${prefix}".` : "Workspace is empty.", { count: 0 });
1867
1998
  }
1868
- const truncated = entries.length < paths.length && entries.length === maxEntries;
1869
- const hint = !prefix && paths.length > 500 ? `
1870
- \u2026 ${paths.length} files total. sandbox.overview is far cheaper for orientation; use a prefix here once you know the area.` : "";
1999
+ const truncated = entries.length < paths2.length && entries.length === maxEntries;
2000
+ const hint = !prefix && paths2.length > 500 ? `
2001
+ \u2026 ${paths2.length} files total. sandbox.overview is far cheaper for orientation; use a prefix here once you know the area.` : "";
1871
2002
  return response(
1872
2003
  request,
1873
2004
  true,
@@ -1876,7 +2007,7 @@ async function list(context, request, options, policy) {
1876
2007
  { count: entries.length, truncated }
1877
2008
  );
1878
2009
  }
1879
- async function search(context, request, options, policy) {
2010
+ async function search(context, request, options, policy, registry) {
1880
2011
  exactKeys(request.input, ["query", "prefix", "maxResults", "caseSensitive"]);
1881
2012
  const query = stringField(request.input, "query");
1882
2013
  if (query.length > 512) throw new TypeError("search query exceeds its bound");
@@ -1885,21 +2016,22 @@ async function search(context, request, options, policy) {
1885
2016
  const maxResults = optionalInteger(request.input.maxResults) ?? 100;
1886
2017
  if (maxResults > 500) throw new TypeError("maxResults exceeds its bound");
1887
2018
  const caseSensitive = request.input.caseSensitive === void 0 ? true : request.input.caseSensitive === true;
1888
- const paths = await registeredFiles(context.workspaceDir, 2e4);
1889
- const allowed = await policy.search(policyContext(context, request, options, { paths, query, ...prefix ? { prefix } : {} }));
2019
+ const paths2 = await registry.files(context.workspaceDir);
2020
+ const allowed = await policy.search(policyContext(context, request, options, { paths: paths2, query, ...prefix ? { prefix } : {} }));
1890
2021
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1891
- const matches = await searchWorkspace(context.workspaceDir, paths, {
2022
+ const matches = await searchWorkspace(context.workspaceDir, paths2, {
1892
2023
  query,
1893
2024
  maxResults,
1894
2025
  caseSensitive,
1895
- ...prefix ? { prefix } : {}
2026
+ ...prefix ? { prefix } : {},
2027
+ ...context.signal ? { signal: context.signal } : {}
1896
2028
  });
1897
2029
  if (!matches.length) return response(request, true, `No match for "${query}".`, { count: 0 });
1898
2030
  return response(request, true, matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join("\n"), {
1899
2031
  count: matches.length
1900
2032
  });
1901
2033
  }
1902
- async function graphQuery(context, request, options, policy) {
2034
+ async function graphQuery(context, request, options, policy, registry) {
1903
2035
  exactKeys(request.input, ["query"]);
1904
2036
  const raw = request.input.query;
1905
2037
  const query = typeof raw === "string" ? raw : "";
@@ -1909,8 +2041,8 @@ async function graphQuery(context, request, options, policy) {
1909
2041
  selector: query
1910
2042
  }));
1911
2043
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1912
- const paths = await registeredFiles(context.workspaceDir, 2e4);
1913
- const graphs = await workspaceGraphs(context.workspaceDir, paths);
2044
+ const paths2 = await registry.files(context.workspaceDir);
2045
+ const graphs = await workspaceGraphs(context.workspaceDir, paths2);
1914
2046
  if (request.tool === "sandbox.overview") {
1915
2047
  return response(request, true, renderOverview(graphs, query || void 0));
1916
2048
  }
@@ -1925,23 +2057,38 @@ function createCodeToolBroker(options) {
1925
2057
  validateOptions(options);
1926
2058
  const recipes = new Map(options.recipes.map((recipe2) => [recipe2.id, recipe2]));
1927
2059
  const policy = createCodePolicyGate(options);
1928
- let tail = Promise.resolve();
2060
+ const registry = createWorkspaceFileRegistry();
2061
+ let barrier = Promise.resolve();
2062
+ const activeReads = /* @__PURE__ */ new Set();
1929
2063
  return {
1930
2064
  execute(context, request) {
1931
- const result = tail.then(() => route(context, request, options, recipes, policy));
1932
- tail = result.then(() => void 0, () => void 0);
2065
+ if (isReadTool(request.tool)) {
2066
+ const result2 = barrier.then(() => route(context, request, options, recipes, policy, registry));
2067
+ const settled = result2.then(() => void 0, () => void 0);
2068
+ activeReads.add(settled);
2069
+ void settled.then(() => {
2070
+ activeReads.delete(settled);
2071
+ });
2072
+ return result2;
2073
+ }
2074
+ const earlierReads = [...activeReads];
2075
+ const result = barrier.then(() => Promise.all(earlierReads)).then(() => route(context, request, options, recipes, policy, registry));
2076
+ barrier = result.then(() => void 0, () => void 0);
1933
2077
  return result;
1934
2078
  }
1935
2079
  };
1936
2080
  }
1937
- async function route(context, request, options, recipes, policy) {
2081
+ function isReadTool(tool) {
2082
+ return tool === "sandbox.read" || tool === "sandbox.list" || tool === "sandbox.search" || GRAPH_TOOLS.has(tool);
2083
+ }
2084
+ async function route(context, request, options, recipes, policy, registry) {
1938
2085
  try {
1939
2086
  if (context.signal?.aborted) throw new TypeError("tool request was cancelled");
1940
- if (request.tool === "sandbox.read") return await read(context, request, options, policy);
1941
- if (request.tool === "sandbox.list") return await list(context, request, options, policy);
1942
- if (request.tool === "sandbox.search") return await search(context, request, options, policy);
1943
- if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy);
1944
- if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy);
2087
+ if (request.tool === "sandbox.read") return await read(context, request, options, policy, registry);
2088
+ if (request.tool === "sandbox.list") return await list(context, request, options, policy, registry);
2089
+ if (request.tool === "sandbox.search") return await search(context, request, options, policy, registry);
2090
+ if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy, registry);
2091
+ if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy, registry);
1945
2092
  return await recipe(context, request, options, recipes, policy);
1946
2093
  } catch (reason) {
1947
2094
  return response(request, false, toolFailureMessage(reason));
@@ -1956,17 +2103,19 @@ function toolFailureMessage(reason) {
1956
2103
  if (code === "EACCES" || code === "EPERM") return "that path is not readable through this tool";
1957
2104
  return "tool failed closed";
1958
2105
  }
1959
- async function patch(context, request, options, policy) {
2106
+ async function patch(context, request, options, policy, registry) {
1960
2107
  exactKeys(request.input, ["patch"]);
1961
2108
  const value = stringField(request.input, "patch");
1962
- const paths = validateCodePatch(value, options.maxPatchBytes ?? 256 * 1024);
1963
- if (paths.some((path) => options.readOnlyPrefixes?.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))) {
2109
+ const paths2 = validateCodePatch(value, options.maxPatchBytes ?? 256 * 1024);
2110
+ if (paths2.some((path) => options.readOnlyPrefixes?.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))) {
1964
2111
  throw new TypeError("patch targets a read-only reference source");
1965
2112
  }
1966
2113
  const allowed = await policy.patch(policyContext(context, request, options, { patch: value }));
1967
2114
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1968
- await applyCodePatch(context.workspaceDir, value, paths);
1969
- return response(request, true, `Applied patch to ${paths.length} file(s).`, { paths });
2115
+ await applyCodePatch(context.workspaceDir, value, paths2);
2116
+ registry.invalidate(context.workspaceDir);
2117
+ forgetWorkspaceGraphs(context.workspaceDir);
2118
+ return response(request, true, `Applied patch to ${paths2.length} file(s).`, { paths: paths2 });
1970
2119
  }
1971
2120
  async function recipe(context, request, options, recipes, policy) {
1972
2121
  exactKeys(request.input, ["recipeId"]);
@@ -2350,12 +2499,129 @@ import { createHash as createHash3 } from "crypto";
2350
2499
  async function appendCodeRuntimeEvent(control, command, event, refs) {
2351
2500
  const eventId = `${command.commandId.slice(0, 45)}:${refs.length + 1}`;
2352
2501
  refs.push(eventId);
2353
- const bounded = event.type === "message" ? { ...event, body: event.body.trim().slice(0, 2e4) || `${event.actor} event` } : event;
2502
+ const attributed = { ...event, interactionId: command.commandId };
2503
+ const bounded = attributed.type === "message" ? { ...attributed, body: attributed.body.trim().slice(0, 2e4) || `${attributed.actor} event` } : attributed;
2354
2504
  await control.appendSessionEvent(command.sessionId, eventId, bounded);
2355
2505
  }
2356
2506
  var digestRuntimeValue = (value) => `sha256:${createHash3("sha256").update(value).digest("hex")}`;
2357
2507
  var runtimeErrorMessage = (value) => value instanceof Error ? value.message : String(value);
2358
2508
 
2509
+ // src/code-tool-presentation.ts
2510
+ var text = (value, maximum) => {
2511
+ if (typeof value !== "string") return void 0;
2512
+ const bounded = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
2513
+ return bounded ? bounded.slice(0, maximum) : void 0;
2514
+ };
2515
+ var integer2 = (value) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : void 0;
2516
+ var record3 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
2517
+ var excerpt = (value, tail = false) => {
2518
+ if (typeof value !== "string") return void 0;
2519
+ const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
2520
+ const source = (tail ? safe.slice(-1e4) : safe.slice(0, 1e4)).trim();
2521
+ if (!source) return void 0;
2522
+ const lines = source.split("\n").filter((line) => line.trim()).map((line) => line.slice(0, 240));
2523
+ const selected = tail ? lines.slice(-10) : lines.slice(0, 10);
2524
+ return text(selected.join("\n"), 2400);
2525
+ };
2526
+ var paths = (value) => {
2527
+ if (!Array.isArray(value)) return void 0;
2528
+ const items = value.flatMap((item) => {
2529
+ const path = text(item, 1024);
2530
+ return path ? [path] : [];
2531
+ }).slice(0, 12);
2532
+ return items.length ? items : void 0;
2533
+ };
2534
+ function patchStats(value) {
2535
+ if (typeof value !== "string") return {};
2536
+ let additions = 0;
2537
+ let deletions = 0;
2538
+ for (const line of value.slice(0, 262144).split("\n")) {
2539
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2540
+ if (line.startsWith("+")) additions += 1;
2541
+ else if (line.startsWith("-")) deletions += 1;
2542
+ }
2543
+ return { ...additions ? { additions } : {}, ...deletions ? { deletions } : {} };
2544
+ }
2545
+ function searchResults(value) {
2546
+ if (typeof value !== "string") return void 0;
2547
+ const results = value.split("\n").flatMap((line) => {
2548
+ const match = /^([^:\n]{1,1024}):(\d+):\s?(.*)$/.exec(line);
2549
+ if (!match) return [];
2550
+ const lineNumber = Number(match[2]);
2551
+ const itemText = text(match[3], 240);
2552
+ if (!Number.isSafeInteger(lineNumber) || lineNumber < 1) return [];
2553
+ return [{ path: match[1], line: lineNumber, ...itemText ? { text: itemText } : {} }];
2554
+ }).slice(0, 5);
2555
+ return results.length ? results : void 0;
2556
+ }
2557
+ function codeToolRequestPresentation(request) {
2558
+ const input = request.input;
2559
+ if (request.tool === "sandbox.read") {
2560
+ const path = text(input.path, 1024);
2561
+ if (!path) return void 0;
2562
+ const startLine = integer2(input.startLine);
2563
+ const endLine = integer2(input.endLine);
2564
+ return { kind: "read", path, ...startLine ? { startLine } : {}, ...endLine ? { endLine } : {} };
2565
+ }
2566
+ if (request.tool === "sandbox.list") {
2567
+ const scope = text(input.prefix, 1024);
2568
+ return { kind: "list", ...scope ? { scope } : {} };
2569
+ }
2570
+ if (request.tool === "sandbox.search" || request.tool === "sandbox.overview" || request.tool === "sandbox.where_is" || request.tool === "sandbox.who_imports" || request.tool === "sandbox.who_touches") {
2571
+ const query = text(input.query, 512);
2572
+ const scope = request.tool === "sandbox.search" ? text(input.prefix, 1024) : void 0;
2573
+ if (request.tool === "sandbox.search" && !query) return void 0;
2574
+ return { kind: "query", ...query ? { query } : {}, ...scope ? { scope } : {} };
2575
+ }
2576
+ if (request.tool === "sandbox.apply_patch") {
2577
+ return { kind: "patch", ...patchStats(input.patch) };
2578
+ }
2579
+ const recipeId = text(input.recipeId, 120);
2580
+ return recipeId ? { kind: "recipe", recipeId } : void 0;
2581
+ }
2582
+ function codeToolResultPresentation(request, response2) {
2583
+ const started = codeToolRequestPresentation(request);
2584
+ if (!started || !response2.ok) return started;
2585
+ const details = record3(response2.details);
2586
+ if (started.kind === "read") {
2587
+ return {
2588
+ ...started,
2589
+ ...integer2(details?.startLine) ? { startLine: integer2(details?.startLine) } : {},
2590
+ ...integer2(details?.endLine) ? { endLine: integer2(details?.endLine) } : {},
2591
+ ...excerpt(response2.content) ? { excerpt: excerpt(response2.content) } : {}
2592
+ };
2593
+ }
2594
+ if (started.kind === "list") {
2595
+ const listed = response2.content.split("\n").filter((line) => line && !line.startsWith("\u2026") && !line.startsWith("Workspace ")).map((line) => text(line, 1024)).filter((line) => Boolean(line)).slice(0, 8);
2596
+ return {
2597
+ ...started,
2598
+ ...integer2(details?.count) !== void 0 ? { count: integer2(details?.count) } : {},
2599
+ ...listed.length ? { paths: listed } : {}
2600
+ };
2601
+ }
2602
+ if (started.kind === "query") {
2603
+ const results = request.tool === "sandbox.search" ? searchResults(response2.content) : void 0;
2604
+ const resultExcerpt = request.tool === "sandbox.search" ? void 0 : excerpt(response2.content);
2605
+ return {
2606
+ ...started,
2607
+ ...integer2(details?.count) !== void 0 ? { count: integer2(details?.count) } : {},
2608
+ ...results ? { results } : {},
2609
+ ...resultExcerpt ? { excerpt: resultExcerpt } : {}
2610
+ };
2611
+ }
2612
+ if (started.kind === "patch") {
2613
+ return { ...started, ...paths(details?.paths) ? { paths: paths(details?.paths) } : {} };
2614
+ }
2615
+ const output = response2.content.replace(/^Recipe [^\n]*\.?\s*/u, "");
2616
+ return {
2617
+ ...started,
2618
+ ...integer2(details?.exitCode) !== void 0 ? { exitCode: integer2(details?.exitCode) } : {},
2619
+ ...typeof details?.timedOut === "boolean" ? { timedOut: details.timedOut } : {},
2620
+ ...typeof details?.outputLimitExceeded === "boolean" ? { outputLimitExceeded: details.outputLimitExceeded } : {},
2621
+ ...excerpt(output, true) ? { excerpt: excerpt(output, true) } : {}
2622
+ };
2623
+ }
2624
+
2359
2625
  // src/code-runtime-engine.ts
2360
2626
  var TheseusRuntimeEngine = class {
2361
2627
  constructor(options) {
@@ -2590,18 +2856,29 @@ var TheseusRuntimeEngine = class {
2590
2856
  return {
2591
2857
  execute: async (context, request) => {
2592
2858
  const startedAt = Date.now();
2859
+ const operationId = digestRuntimeValue(`${command.commandId}:${request.requestId}`);
2860
+ const startedPresentation = codeToolRequestPresentation(request);
2593
2861
  await this.#event(
2594
2862
  command,
2595
- { type: "tool", phase: "started", tool: request.tool },
2863
+ {
2864
+ type: "tool",
2865
+ phase: "started",
2866
+ tool: request.tool,
2867
+ operationId,
2868
+ ...startedPresentation ? { presentation: startedPresentation } : {}
2869
+ },
2596
2870
  active.conversationRefs
2597
2871
  ).catch(() => void 0);
2598
2872
  const response2 = await broker.execute(context, request);
2873
+ const completedPresentation = codeToolResultPresentation(request, response2);
2599
2874
  await this.#event(command, {
2600
2875
  type: "tool",
2601
2876
  phase: "completed",
2602
2877
  tool: request.tool,
2603
2878
  ok: response2.ok,
2604
- durationMs: Date.now() - startedAt
2879
+ durationMs: Date.now() - startedAt,
2880
+ operationId,
2881
+ ...completedPresentation ? { presentation: completedPresentation } : {}
2605
2882
  }, active.conversationRefs).catch(() => void 0);
2606
2883
  return response2;
2607
2884
  }
@@ -2677,4 +2954,4 @@ export {
2677
2954
  runGoal,
2678
2955
  TheseusRuntimeEngine
2679
2956
  };
2680
- //# sourceMappingURL=chunk-QZXCQSPZ.js.map
2957
+ //# sourceMappingURL=chunk-ISR434K7.js.map