@odla-ai/harness 0.7.0 → 0.7.1

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.
@@ -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,11 +1452,30 @@ 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((paths) => Object.freeze(paths));
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
1480
  const paths = [];
1447
1481
  const walk = async (directory) => {
@@ -1472,28 +1506,122 @@ function listWorkspace(paths, options = {}) {
1472
1506
  return scoped.slice(0, max);
1473
1507
  }
1474
1508
  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");
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
1513
  const scoped = listWorkspace(paths, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths.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, paths, options) {
1524
+ const batches = [];
1525
+ let batch = [];
1526
+ let bytes = 0;
1527
+ for (const path of paths) {
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, paths, 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
+ ...paths
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;
@@ -1778,6 +1906,9 @@ function workspaceGraphs(workspaceDir, paths) {
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,7 +1963,7 @@ 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);
1966
+ const paths = await registry.files(context.workspaceDir);
1836
1967
  if (!paths.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
  }
@@ -1852,13 +1983,13 @@ 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);
1992
+ const paths = await registry.files(context.workspaceDir);
1862
1993
  const allowed = await policy.list(policyContext(context, request, options, { paths, ...prefix ? { prefix } : {} }));
1863
1994
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1864
1995
  const entries = listWorkspace(paths, { ...prefix ? { prefix } : {}, maxEntries });
@@ -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);
2019
+ const paths = await registry.files(context.workspaceDir);
1889
2020
  const allowed = await policy.search(policyContext(context, request, options, { paths, query, ...prefix ? { prefix } : {} }));
1890
2021
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
1891
2022
  const matches = await searchWorkspace(context.workspaceDir, paths, {
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,7 +2041,7 @@ 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);
2044
+ const paths = await registry.files(context.workspaceDir);
1913
2045
  const graphs = await workspaceGraphs(context.workspaceDir, paths);
1914
2046
  if (request.tool === "sandbox.overview") {
1915
2047
  return response(request, true, renderOverview(graphs, query || void 0));
@@ -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,7 +2103,7 @@ 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
2109
  const paths = validateCodePatch(value, options.maxPatchBytes ?? 256 * 1024);
@@ -1966,6 +2113,8 @@ async function patch(context, request, options, policy) {
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
2115
  await applyCodePatch(context.workspaceDir, value, paths);
2116
+ registry.invalidate(context.workspaceDir);
2117
+ forgetWorkspaceGraphs(context.workspaceDir);
1969
2118
  return response(request, true, `Applied patch to ${paths.length} file(s).`, { paths });
1970
2119
  }
1971
2120
  async function recipe(context, request, options, recipes, policy) {
@@ -2677,4 +2826,4 @@ export {
2677
2826
  runGoal,
2678
2827
  TheseusRuntimeEngine
2679
2828
  };
2680
- //# sourceMappingURL=chunk-QZXCQSPZ.js.map
2829
+ //# sourceMappingURL=chunk-GYWQM76X.js.map