@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.
@@ -1401,7 +1401,10 @@ Never claim a build or test passed unless odla_run_recipe returned that result.`
1401
1401
  var V2_SYSTEM_PROMPT = `You are the coding agent inside an odla Code harness.
1402
1402
  Start by orienting: odla_list shows the files in the workspace and odla_search
1403
1403
  finds a literal string across them. Prefer those over guessing a path.
1404
- Then odla_read a bounded range, and odla_apply_git_diff to mutate.
1404
+ Then odla_read a bounded range, and odla_apply_git_diff to mutate. When you
1405
+ need several independent searches or file ranges, issue those read-only calls
1406
+ together in one turn; their results stay ordered and the harness overlaps them.
1407
+ Never issue odla_apply_git_diff or odla_run_recipe alongside another tool call.
1405
1408
  For mutations, call odla_apply_git_diff with raw git diff text. It must start
1406
1409
  with "diff --git a/<path> b/<path>", include matching "---" and "+++" file
1407
1410
  headers and numbered "@@" hunks, and never use "*** Begin Patch" wrappers.
@@ -1432,18 +1435,26 @@ var SYSTEM_PROMPT_FOR = {
1432
1435
  };
1433
1436
  function codeSkill(opts) {
1434
1437
  let seq = 0;
1438
+ let nextCompletion = 1;
1439
+ const completed = /* @__PURE__ */ new Map();
1435
1440
  const call = async (tool, input, signal) => {
1441
+ const sequence = ++seq;
1436
1442
  const startedAt = Date.now();
1437
1443
  const response2 = await opts.broker.execute(
1438
1444
  { lease: opts.lease, workspaceDir: opts.workspaceDir, signal },
1439
- { requestId: `bench-${tool}-${++seq}`, tool, input }
1445
+ { requestId: `bench-${tool}-${sequence}`, tool, input }
1440
1446
  );
1441
- opts.onToolCall?.({
1447
+ completed.set(sequence, {
1442
1448
  tool,
1443
1449
  ok: response2.ok,
1444
1450
  durationMs: Date.now() - startedAt,
1445
1451
  ...response2.ok ? {} : { error: String(response2.content).slice(0, 300) }
1446
1452
  });
1453
+ while (completed.has(nextCompletion)) {
1454
+ const completion = completed.get(nextCompletion);
1455
+ completed.delete(nextCompletion++);
1456
+ opts.onToolCall?.(completion);
1457
+ }
1447
1458
  return { content: response2.content, isError: !response2.ok };
1448
1459
  };
1449
1460
  const read2 = {
@@ -1459,6 +1470,7 @@ function codeSkill(opts) {
1459
1470
  },
1460
1471
  additionalProperties: false
1461
1472
  },
1473
+ concurrency: "parallel",
1462
1474
  handler: (input, ctx) => call("sandbox.read", input, ctx.signal)
1463
1475
  };
1464
1476
  const applyPatch = {
@@ -1500,6 +1512,7 @@ function codeSkill(opts) {
1500
1512
  },
1501
1513
  additionalProperties: false
1502
1514
  },
1515
+ concurrency: "parallel",
1503
1516
  handler: (input, ctx) => call("sandbox.list", input, ctx.signal)
1504
1517
  };
1505
1518
  const searchFiles = {
@@ -1516,11 +1529,13 @@ function codeSkill(opts) {
1516
1529
  },
1517
1530
  additionalProperties: false
1518
1531
  },
1532
+ concurrency: "parallel",
1519
1533
  handler: (input, ctx) => call("sandbox.search", input, ctx.signal)
1520
1534
  };
1521
1535
  const graphTool = (name, tool, description, required) => ({
1522
1536
  name,
1523
1537
  description,
1538
+ concurrency: "parallel",
1524
1539
  inputSchema: {
1525
1540
  type: "object",
1526
1541
  ...required ? { required: ["query"] } : {},
@@ -1950,11 +1965,30 @@ function response(request, ok, content, details) {
1950
1965
  var import_promises9 = require("fs/promises");
1951
1966
 
1952
1967
  // src/code-tool-discovery.ts
1968
+ var import_node_child_process5 = require("child_process");
1953
1969
  var import_promises7 = require("fs/promises");
1954
1970
  var import_node_path8 = require("path");
1955
1971
  var DEFAULT_MAX_FILES = 2e4;
1956
1972
  var DEFAULT_MAX_RESULTS = 100;
1957
1973
  var DEFAULT_MAX_FILE_BYTES = 512 * 1024;
1974
+ function createWorkspaceFileRegistry(limit = DEFAULT_MAX_FILES, enumerate = registeredFiles) {
1975
+ const cache2 = /* @__PURE__ */ new Map();
1976
+ return {
1977
+ files(root) {
1978
+ const existing = cache2.get(root);
1979
+ if (existing) return existing;
1980
+ const pending = enumerate(root, limit).then((paths) => Object.freeze(paths));
1981
+ cache2.set(root, pending);
1982
+ void pending.catch(() => {
1983
+ if (cache2.get(root) === pending) cache2.delete(root);
1984
+ });
1985
+ return pending;
1986
+ },
1987
+ invalidate(root) {
1988
+ cache2.delete(root);
1989
+ }
1990
+ };
1991
+ }
1958
1992
  async function registeredFiles(root, limit = DEFAULT_MAX_FILES) {
1959
1993
  const paths = [];
1960
1994
  const walk = async (directory) => {
@@ -1985,28 +2019,122 @@ function listWorkspace(paths, options = {}) {
1985
2019
  return scoped.slice(0, max);
1986
2020
  }
1987
2021
  async function searchWorkspace(root, paths, options) {
1988
- const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
1989
- if (!query) throw new TypeError("search query must be a non-empty string");
2022
+ options.signal?.throwIfAborted();
2023
+ if (!options.query) throw new TypeError("search query must be a non-empty string");
1990
2024
  const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
1991
2025
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
1992
2026
  const scoped = listWorkspace(paths, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths.length });
2027
+ if (scoped.length === 0) return [];
2028
+ try {
2029
+ return await nativeSearch(root, scoped, { ...options, maxResults, maxFileBytes });
2030
+ } catch (error) {
2031
+ options.signal?.throwIfAborted();
2032
+ return fallbackSearch(root, scoped, { ...options, maxResults, maxFileBytes });
2033
+ }
2034
+ }
2035
+ var MAX_NATIVE_ARG_BYTES = 96 * 1024;
2036
+ async function nativeSearch(root, paths, options) {
2037
+ const batches = [];
2038
+ let batch = [];
2039
+ let bytes = 0;
2040
+ for (const path of paths) {
2041
+ const size = Buffer.byteLength(path) + 1;
2042
+ if (batch.length > 0 && bytes + size > MAX_NATIVE_ARG_BYTES) {
2043
+ batches.push(batch);
2044
+ batch = [];
2045
+ bytes = 0;
2046
+ }
2047
+ batch.push(path);
2048
+ bytes += size;
2049
+ }
2050
+ if (batch.length > 0) batches.push(batch);
2051
+ const matches = [];
2052
+ for (const files of batches) {
2053
+ const remaining = options.maxResults - matches.length;
2054
+ if (remaining <= 0) break;
2055
+ matches.push(...await nativeSearchBatch(root, files, options, remaining));
2056
+ }
2057
+ return matches;
2058
+ }
2059
+ function nativeSearchBatch(root, paths, options, remaining) {
2060
+ return new Promise((resolveMatches, reject) => {
2061
+ const args = [
2062
+ "--fixed-strings",
2063
+ "--json",
2064
+ "--no-messages",
2065
+ "--sort=path",
2066
+ `--max-filesize=${options.maxFileBytes}`,
2067
+ "--max-columns=4096",
2068
+ "--max-columns-preview",
2069
+ options.caseSensitive === false ? "--ignore-case" : "--case-sensitive",
2070
+ "--",
2071
+ options.query,
2072
+ ...paths
2073
+ ];
2074
+ const child = (0, import_node_child_process5.spawn)("rg", args, {
2075
+ cwd: root,
2076
+ stdio: ["ignore", "pipe", "ignore"],
2077
+ ...options.signal ? { signal: options.signal } : {}
2078
+ });
2079
+ const matches = [];
2080
+ let carry = "";
2081
+ let stopped = false;
2082
+ const consume = (line) => {
2083
+ if (matches.length >= remaining) return;
2084
+ let event;
2085
+ try {
2086
+ event = JSON.parse(line);
2087
+ } catch {
2088
+ return;
2089
+ }
2090
+ const path = event.data?.path?.text;
2091
+ const lineNumber = event.data?.line_number;
2092
+ const source = event.data?.lines?.text;
2093
+ if (event.type !== "match" || path === void 0 || lineNumber === void 0 || source === void 0) return;
2094
+ matches.push({ path, line: lineNumber, text: source.trim().slice(0, 240) });
2095
+ if (matches.length >= remaining) {
2096
+ stopped = true;
2097
+ child.kill();
2098
+ }
2099
+ };
2100
+ child.stdout.setEncoding("utf8");
2101
+ child.stdout.on("data", (chunk) => {
2102
+ carry += chunk;
2103
+ let newline = carry.indexOf("\n");
2104
+ while (newline >= 0) {
2105
+ consume(carry.slice(0, newline));
2106
+ carry = carry.slice(newline + 1);
2107
+ newline = carry.indexOf("\n");
2108
+ }
2109
+ });
2110
+ child.once("error", reject);
2111
+ child.once("close", (code) => {
2112
+ if (carry) consume(carry);
2113
+ if (stopped || code === 0 || code === 1) resolveMatches(matches);
2114
+ else reject(new Error(`native search exited with status ${code ?? "unknown"}`));
2115
+ });
2116
+ });
2117
+ }
2118
+ async function fallbackSearch(root, scoped, options) {
2119
+ const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
1993
2120
  const matches = [];
1994
2121
  for (const path of scoped) {
1995
- if (matches.length >= maxResults) break;
2122
+ options.signal?.throwIfAborted();
2123
+ if (matches.length >= options.maxResults) break;
1996
2124
  let source;
1997
2125
  try {
1998
2126
  source = await (0, import_promises7.readFile)((0, import_node_path8.resolve)(root, path));
1999
2127
  } catch {
2000
2128
  continue;
2001
2129
  }
2002
- if (source.byteLength > maxFileBytes || source.includes(0)) continue;
2130
+ if (source.byteLength > options.maxFileBytes || source.includes(0)) continue;
2003
2131
  const lines = source.toString("utf8").split("\n");
2004
2132
  for (let index = 0; index < lines.length; index += 1) {
2005
2133
  const raw = lines[index];
2006
2134
  const haystack = options.caseSensitive === false ? raw.toLowerCase() : raw;
2007
2135
  if (!haystack.includes(query)) continue;
2008
2136
  matches.push({ path, line: index + 1, text: raw.trim().slice(0, 240) });
2009
- if (matches.length >= maxResults) break;
2137
+ if (matches.length >= options.maxResults) break;
2010
2138
  }
2011
2139
  }
2012
2140
  return matches;
@@ -2031,6 +2159,9 @@ function workspaceGraphs(workspaceDir, paths) {
2031
2159
  cache.set(workspaceDir, built);
2032
2160
  return built;
2033
2161
  }
2162
+ function forgetWorkspaceGraphs(workspaceDir) {
2163
+ cache.delete(workspaceDir);
2164
+ }
2034
2165
  var shortId = (id) => id.slice(id.indexOf(":") + 1);
2035
2166
  function renderOverview(graphs, prefix) {
2036
2167
  const rows = (0, import_graph.rollup)(graphs.graph, import_code4.FILE, prefix === void 0 ? {} : { prefix });
@@ -2077,7 +2208,7 @@ var GRAPH_TOOLS = /* @__PURE__ */ new Set([
2077
2208
  "sandbox.who_imports",
2078
2209
  "sandbox.who_touches"
2079
2210
  ]);
2080
- async function read(context, request, options, policy) {
2211
+ async function read(context, request, options, policy, registry) {
2081
2212
  exactKeys(request.input, ["path", "startLine", "endLine"]);
2082
2213
  const path = stringField(request.input, "path");
2083
2214
  const startLine = optionalInteger(request.input.startLine) ?? 1;
@@ -2085,7 +2216,7 @@ async function read(context, request, options, policy) {
2085
2216
  if (endLine < startLine || endLine - startLine + 1 > (options.maxReadLines ?? 2e3)) {
2086
2217
  throw new TypeError("requested line range exceeds its bound");
2087
2218
  }
2088
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2219
+ const paths = await registry.files(context.workspaceDir);
2089
2220
  if (!paths.includes(path)) {
2090
2221
  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.`);
2091
2222
  }
@@ -2105,13 +2236,13 @@ async function read(context, request, options, policy) {
2105
2236
  }
2106
2237
  return response(request, true, content, { path, startLine, endLine: Math.min(endLine, lines.length) });
2107
2238
  }
2108
- async function list(context, request, options, policy) {
2239
+ async function list(context, request, options, policy, registry) {
2109
2240
  exactKeys(request.input, ["prefix", "maxEntries"]);
2110
2241
  const raw = request.input.prefix;
2111
2242
  const prefix = typeof raw === "string" && raw.length > 0 ? raw : void 0;
2112
2243
  const maxEntries = optionalInteger(request.input.maxEntries) ?? 1e3;
2113
2244
  if (maxEntries > 5e3) throw new TypeError("maxEntries exceeds its bound");
2114
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2245
+ const paths = await registry.files(context.workspaceDir);
2115
2246
  const allowed = await policy.list(policyContext(context, request, options, { paths, ...prefix ? { prefix } : {} }));
2116
2247
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2117
2248
  const entries = listWorkspace(paths, { ...prefix ? { prefix } : {}, maxEntries });
@@ -2129,7 +2260,7 @@ async function list(context, request, options, policy) {
2129
2260
  { count: entries.length, truncated }
2130
2261
  );
2131
2262
  }
2132
- async function search(context, request, options, policy) {
2263
+ async function search(context, request, options, policy, registry) {
2133
2264
  exactKeys(request.input, ["query", "prefix", "maxResults", "caseSensitive"]);
2134
2265
  const query = stringField(request.input, "query");
2135
2266
  if (query.length > 512) throw new TypeError("search query exceeds its bound");
@@ -2138,21 +2269,22 @@ async function search(context, request, options, policy) {
2138
2269
  const maxResults = optionalInteger(request.input.maxResults) ?? 100;
2139
2270
  if (maxResults > 500) throw new TypeError("maxResults exceeds its bound");
2140
2271
  const caseSensitive = request.input.caseSensitive === void 0 ? true : request.input.caseSensitive === true;
2141
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2272
+ const paths = await registry.files(context.workspaceDir);
2142
2273
  const allowed = await policy.search(policyContext(context, request, options, { paths, query, ...prefix ? { prefix } : {} }));
2143
2274
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2144
2275
  const matches = await searchWorkspace(context.workspaceDir, paths, {
2145
2276
  query,
2146
2277
  maxResults,
2147
2278
  caseSensitive,
2148
- ...prefix ? { prefix } : {}
2279
+ ...prefix ? { prefix } : {},
2280
+ ...context.signal ? { signal: context.signal } : {}
2149
2281
  });
2150
2282
  if (!matches.length) return response(request, true, `No match for "${query}".`, { count: 0 });
2151
2283
  return response(request, true, matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join("\n"), {
2152
2284
  count: matches.length
2153
2285
  });
2154
2286
  }
2155
- async function graphQuery(context, request, options, policy) {
2287
+ async function graphQuery(context, request, options, policy, registry) {
2156
2288
  exactKeys(request.input, ["query"]);
2157
2289
  const raw = request.input.query;
2158
2290
  const query = typeof raw === "string" ? raw : "";
@@ -2162,7 +2294,7 @@ async function graphQuery(context, request, options, policy) {
2162
2294
  selector: query
2163
2295
  }));
2164
2296
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2165
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2297
+ const paths = await registry.files(context.workspaceDir);
2166
2298
  const graphs = await workspaceGraphs(context.workspaceDir, paths);
2167
2299
  if (request.tool === "sandbox.overview") {
2168
2300
  return response(request, true, renderOverview(graphs, query || void 0));
@@ -2178,23 +2310,38 @@ function createCodeToolBroker(options) {
2178
2310
  validateOptions(options);
2179
2311
  const recipes = new Map(options.recipes.map((recipe2) => [recipe2.id, recipe2]));
2180
2312
  const policy = createCodePolicyGate(options);
2181
- let tail = Promise.resolve();
2313
+ const registry = createWorkspaceFileRegistry();
2314
+ let barrier = Promise.resolve();
2315
+ const activeReads = /* @__PURE__ */ new Set();
2182
2316
  return {
2183
2317
  execute(context, request) {
2184
- const result = tail.then(() => route(context, request, options, recipes, policy));
2185
- tail = result.then(() => void 0, () => void 0);
2318
+ if (isReadTool(request.tool)) {
2319
+ const result2 = barrier.then(() => route(context, request, options, recipes, policy, registry));
2320
+ const settled = result2.then(() => void 0, () => void 0);
2321
+ activeReads.add(settled);
2322
+ void settled.then(() => {
2323
+ activeReads.delete(settled);
2324
+ });
2325
+ return result2;
2326
+ }
2327
+ const earlierReads = [...activeReads];
2328
+ const result = barrier.then(() => Promise.all(earlierReads)).then(() => route(context, request, options, recipes, policy, registry));
2329
+ barrier = result.then(() => void 0, () => void 0);
2186
2330
  return result;
2187
2331
  }
2188
2332
  };
2189
2333
  }
2190
- async function route(context, request, options, recipes, policy) {
2334
+ function isReadTool(tool) {
2335
+ return tool === "sandbox.read" || tool === "sandbox.list" || tool === "sandbox.search" || GRAPH_TOOLS.has(tool);
2336
+ }
2337
+ async function route(context, request, options, recipes, policy, registry) {
2191
2338
  try {
2192
2339
  if (context.signal?.aborted) throw new TypeError("tool request was cancelled");
2193
- if (request.tool === "sandbox.read") return await read(context, request, options, policy);
2194
- if (request.tool === "sandbox.list") return await list(context, request, options, policy);
2195
- if (request.tool === "sandbox.search") return await search(context, request, options, policy);
2196
- if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy);
2197
- if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy);
2340
+ if (request.tool === "sandbox.read") return await read(context, request, options, policy, registry);
2341
+ if (request.tool === "sandbox.list") return await list(context, request, options, policy, registry);
2342
+ if (request.tool === "sandbox.search") return await search(context, request, options, policy, registry);
2343
+ if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy, registry);
2344
+ if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy, registry);
2198
2345
  return await recipe(context, request, options, recipes, policy);
2199
2346
  } catch (reason) {
2200
2347
  return response(request, false, toolFailureMessage(reason));
@@ -2209,7 +2356,7 @@ function toolFailureMessage(reason) {
2209
2356
  if (code === "EACCES" || code === "EPERM") return "that path is not readable through this tool";
2210
2357
  return "tool failed closed";
2211
2358
  }
2212
- async function patch(context, request, options, policy) {
2359
+ async function patch(context, request, options, policy, registry) {
2213
2360
  exactKeys(request.input, ["patch"]);
2214
2361
  const value = stringField(request.input, "patch");
2215
2362
  const paths = validateCodePatch(value, options.maxPatchBytes ?? 256 * 1024);
@@ -2219,6 +2366,8 @@ async function patch(context, request, options, policy) {
2219
2366
  const allowed = await policy.patch(policyContext(context, request, options, { patch: value }));
2220
2367
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2221
2368
  await applyCodePatch(context.workspaceDir, value, paths);
2369
+ registry.invalidate(context.workspaceDir);
2370
+ forgetWorkspaceGraphs(context.workspaceDir);
2222
2371
  return response(request, true, `Applied patch to ${paths.length} file(s).`, { paths });
2223
2372
  }
2224
2373
  async function recipe(context, request, options, recipes, policy) {