@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.
@@ -6,7 +6,7 @@ import {
6
6
  assertCodeBuildRecipe,
7
7
  createCodeRuntimeControlClient,
8
8
  runCodeRuntimeHeartbeatLoop
9
- } from "./chunk-QZXCQSPZ.js";
9
+ } from "./chunk-GYWQM76X.js";
10
10
  import {
11
11
  assertPinnedImage,
12
12
  selectContainerEngine
package/dist/node.cjs CHANGED
@@ -1983,7 +1983,10 @@ Never claim a build or test passed unless odla_run_recipe returned that result.`
1983
1983
  var V2_SYSTEM_PROMPT = `You are the coding agent inside an odla Code harness.
1984
1984
  Start by orienting: odla_list shows the files in the workspace and odla_search
1985
1985
  finds a literal string across them. Prefer those over guessing a path.
1986
- Then odla_read a bounded range, and odla_apply_git_diff to mutate.
1986
+ Then odla_read a bounded range, and odla_apply_git_diff to mutate. When you
1987
+ need several independent searches or file ranges, issue those read-only calls
1988
+ together in one turn; their results stay ordered and the harness overlaps them.
1989
+ Never issue odla_apply_git_diff or odla_run_recipe alongside another tool call.
1987
1990
  For mutations, call odla_apply_git_diff with raw git diff text. It must start
1988
1991
  with "diff --git a/<path> b/<path>", include matching "---" and "+++" file
1989
1992
  headers and numbered "@@" hunks, and never use "*** Begin Patch" wrappers.
@@ -2014,18 +2017,26 @@ var SYSTEM_PROMPT_FOR = {
2014
2017
  };
2015
2018
  function codeSkill(opts) {
2016
2019
  let seq = 0;
2020
+ let nextCompletion = 1;
2021
+ const completed = /* @__PURE__ */ new Map();
2017
2022
  const call = async (tool, input, signal) => {
2023
+ const sequence = ++seq;
2018
2024
  const startedAt = Date.now();
2019
2025
  const response2 = await opts.broker.execute(
2020
2026
  { lease: opts.lease, workspaceDir: opts.workspaceDir, signal },
2021
- { requestId: `bench-${tool}-${++seq}`, tool, input }
2027
+ { requestId: `bench-${tool}-${sequence}`, tool, input }
2022
2028
  );
2023
- opts.onToolCall?.({
2029
+ completed.set(sequence, {
2024
2030
  tool,
2025
2031
  ok: response2.ok,
2026
2032
  durationMs: Date.now() - startedAt,
2027
2033
  ...response2.ok ? {} : { error: String(response2.content).slice(0, 300) }
2028
2034
  });
2035
+ while (completed.has(nextCompletion)) {
2036
+ const completion = completed.get(nextCompletion);
2037
+ completed.delete(nextCompletion++);
2038
+ opts.onToolCall?.(completion);
2039
+ }
2029
2040
  return { content: response2.content, isError: !response2.ok };
2030
2041
  };
2031
2042
  const read2 = {
@@ -2041,6 +2052,7 @@ function codeSkill(opts) {
2041
2052
  },
2042
2053
  additionalProperties: false
2043
2054
  },
2055
+ concurrency: "parallel",
2044
2056
  handler: (input, ctx) => call("sandbox.read", input, ctx.signal)
2045
2057
  };
2046
2058
  const applyPatch = {
@@ -2082,6 +2094,7 @@ function codeSkill(opts) {
2082
2094
  },
2083
2095
  additionalProperties: false
2084
2096
  },
2097
+ concurrency: "parallel",
2085
2098
  handler: (input, ctx) => call("sandbox.list", input, ctx.signal)
2086
2099
  };
2087
2100
  const searchFiles = {
@@ -2098,11 +2111,13 @@ function codeSkill(opts) {
2098
2111
  },
2099
2112
  additionalProperties: false
2100
2113
  },
2114
+ concurrency: "parallel",
2101
2115
  handler: (input, ctx) => call("sandbox.search", input, ctx.signal)
2102
2116
  };
2103
2117
  const graphTool = (name, tool, description, required) => ({
2104
2118
  name,
2105
2119
  description,
2120
+ concurrency: "parallel",
2106
2121
  inputSchema: {
2107
2122
  type: "object",
2108
2123
  ...required ? { required: ["query"] } : {},
@@ -2532,11 +2547,30 @@ function response(request, ok, content, details) {
2532
2547
  var import_promises11 = require("fs/promises");
2533
2548
 
2534
2549
  // src/code-tool-discovery.ts
2550
+ var import_node_child_process6 = require("child_process");
2535
2551
  var import_promises9 = require("fs/promises");
2536
2552
  var import_node_path9 = require("path");
2537
2553
  var DEFAULT_MAX_FILES = 2e4;
2538
2554
  var DEFAULT_MAX_RESULTS = 100;
2539
2555
  var DEFAULT_MAX_FILE_BYTES = 512 * 1024;
2556
+ function createWorkspaceFileRegistry(limit = DEFAULT_MAX_FILES, enumerate = registeredFiles) {
2557
+ const cache2 = /* @__PURE__ */ new Map();
2558
+ return {
2559
+ files(root) {
2560
+ const existing = cache2.get(root);
2561
+ if (existing) return existing;
2562
+ const pending = enumerate(root, limit).then((paths) => Object.freeze(paths));
2563
+ cache2.set(root, pending);
2564
+ void pending.catch(() => {
2565
+ if (cache2.get(root) === pending) cache2.delete(root);
2566
+ });
2567
+ return pending;
2568
+ },
2569
+ invalidate(root) {
2570
+ cache2.delete(root);
2571
+ }
2572
+ };
2573
+ }
2540
2574
  async function registeredFiles(root, limit = DEFAULT_MAX_FILES) {
2541
2575
  const paths = [];
2542
2576
  const walk = async (directory) => {
@@ -2567,28 +2601,122 @@ function listWorkspace(paths, options = {}) {
2567
2601
  return scoped.slice(0, max);
2568
2602
  }
2569
2603
  async function searchWorkspace(root, paths, options) {
2570
- const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
2571
- if (!query) throw new TypeError("search query must be a non-empty string");
2604
+ options.signal?.throwIfAborted();
2605
+ if (!options.query) throw new TypeError("search query must be a non-empty string");
2572
2606
  const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
2573
2607
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
2574
2608
  const scoped = listWorkspace(paths, { ...options.prefix ? { prefix: options.prefix } : {}, maxEntries: paths.length });
2609
+ if (scoped.length === 0) return [];
2610
+ try {
2611
+ return await nativeSearch(root, scoped, { ...options, maxResults, maxFileBytes });
2612
+ } catch (error) {
2613
+ options.signal?.throwIfAborted();
2614
+ return fallbackSearch(root, scoped, { ...options, maxResults, maxFileBytes });
2615
+ }
2616
+ }
2617
+ var MAX_NATIVE_ARG_BYTES = 96 * 1024;
2618
+ async function nativeSearch(root, paths, options) {
2619
+ const batches = [];
2620
+ let batch = [];
2621
+ let bytes = 0;
2622
+ for (const path of paths) {
2623
+ const size = Buffer.byteLength(path) + 1;
2624
+ if (batch.length > 0 && bytes + size > MAX_NATIVE_ARG_BYTES) {
2625
+ batches.push(batch);
2626
+ batch = [];
2627
+ bytes = 0;
2628
+ }
2629
+ batch.push(path);
2630
+ bytes += size;
2631
+ }
2632
+ if (batch.length > 0) batches.push(batch);
2633
+ const matches = [];
2634
+ for (const files of batches) {
2635
+ const remaining = options.maxResults - matches.length;
2636
+ if (remaining <= 0) break;
2637
+ matches.push(...await nativeSearchBatch(root, files, options, remaining));
2638
+ }
2639
+ return matches;
2640
+ }
2641
+ function nativeSearchBatch(root, paths, options, remaining) {
2642
+ return new Promise((resolveMatches, reject) => {
2643
+ const args = [
2644
+ "--fixed-strings",
2645
+ "--json",
2646
+ "--no-messages",
2647
+ "--sort=path",
2648
+ `--max-filesize=${options.maxFileBytes}`,
2649
+ "--max-columns=4096",
2650
+ "--max-columns-preview",
2651
+ options.caseSensitive === false ? "--ignore-case" : "--case-sensitive",
2652
+ "--",
2653
+ options.query,
2654
+ ...paths
2655
+ ];
2656
+ const child = (0, import_node_child_process6.spawn)("rg", args, {
2657
+ cwd: root,
2658
+ stdio: ["ignore", "pipe", "ignore"],
2659
+ ...options.signal ? { signal: options.signal } : {}
2660
+ });
2661
+ const matches = [];
2662
+ let carry = "";
2663
+ let stopped = false;
2664
+ const consume = (line) => {
2665
+ if (matches.length >= remaining) return;
2666
+ let event;
2667
+ try {
2668
+ event = JSON.parse(line);
2669
+ } catch {
2670
+ return;
2671
+ }
2672
+ const path = event.data?.path?.text;
2673
+ const lineNumber = event.data?.line_number;
2674
+ const source = event.data?.lines?.text;
2675
+ if (event.type !== "match" || path === void 0 || lineNumber === void 0 || source === void 0) return;
2676
+ matches.push({ path, line: lineNumber, text: source.trim().slice(0, 240) });
2677
+ if (matches.length >= remaining) {
2678
+ stopped = true;
2679
+ child.kill();
2680
+ }
2681
+ };
2682
+ child.stdout.setEncoding("utf8");
2683
+ child.stdout.on("data", (chunk) => {
2684
+ carry += chunk;
2685
+ let newline = carry.indexOf("\n");
2686
+ while (newline >= 0) {
2687
+ consume(carry.slice(0, newline));
2688
+ carry = carry.slice(newline + 1);
2689
+ newline = carry.indexOf("\n");
2690
+ }
2691
+ });
2692
+ child.once("error", reject);
2693
+ child.once("close", (code) => {
2694
+ if (carry) consume(carry);
2695
+ if (stopped || code === 0 || code === 1) resolveMatches(matches);
2696
+ else reject(new Error(`native search exited with status ${code ?? "unknown"}`));
2697
+ });
2698
+ });
2699
+ }
2700
+ async function fallbackSearch(root, scoped, options) {
2701
+ const query = options.caseSensitive === false ? options.query.toLowerCase() : options.query;
2575
2702
  const matches = [];
2576
2703
  for (const path of scoped) {
2577
- if (matches.length >= maxResults) break;
2704
+ options.signal?.throwIfAborted();
2705
+ if (matches.length >= options.maxResults) break;
2578
2706
  let source;
2579
2707
  try {
2580
2708
  source = await (0, import_promises9.readFile)((0, import_node_path9.resolve)(root, path));
2581
2709
  } catch {
2582
2710
  continue;
2583
2711
  }
2584
- if (source.byteLength > maxFileBytes || source.includes(0)) continue;
2712
+ if (source.byteLength > options.maxFileBytes || source.includes(0)) continue;
2585
2713
  const lines = source.toString("utf8").split("\n");
2586
2714
  for (let index = 0; index < lines.length; index += 1) {
2587
2715
  const raw = lines[index];
2588
2716
  const haystack = options.caseSensitive === false ? raw.toLowerCase() : raw;
2589
2717
  if (!haystack.includes(query)) continue;
2590
2718
  matches.push({ path, line: index + 1, text: raw.trim().slice(0, 240) });
2591
- if (matches.length >= maxResults) break;
2719
+ if (matches.length >= options.maxResults) break;
2592
2720
  }
2593
2721
  }
2594
2722
  return matches;
@@ -2613,6 +2741,9 @@ function workspaceGraphs(workspaceDir, paths) {
2613
2741
  cache.set(workspaceDir, built);
2614
2742
  return built;
2615
2743
  }
2744
+ function forgetWorkspaceGraphs(workspaceDir) {
2745
+ cache.delete(workspaceDir);
2746
+ }
2616
2747
  var shortId = (id) => id.slice(id.indexOf(":") + 1);
2617
2748
  function renderOverview(graphs, prefix) {
2618
2749
  const rows = (0, import_graph.rollup)(graphs.graph, import_code4.FILE, prefix === void 0 ? {} : { prefix });
@@ -2659,7 +2790,7 @@ var GRAPH_TOOLS = /* @__PURE__ */ new Set([
2659
2790
  "sandbox.who_imports",
2660
2791
  "sandbox.who_touches"
2661
2792
  ]);
2662
- async function read(context, request, options, policy) {
2793
+ async function read(context, request, options, policy, registry) {
2663
2794
  exactKeys(request.input, ["path", "startLine", "endLine"]);
2664
2795
  const path = stringField(request.input, "path");
2665
2796
  const startLine = optionalInteger(request.input.startLine) ?? 1;
@@ -2667,7 +2798,7 @@ async function read(context, request, options, policy) {
2667
2798
  if (endLine < startLine || endLine - startLine + 1 > (options.maxReadLines ?? 2e3)) {
2668
2799
  throw new TypeError("requested line range exceeds its bound");
2669
2800
  }
2670
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2801
+ const paths = await registry.files(context.workspaceDir);
2671
2802
  if (!paths.includes(path)) {
2672
2803
  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.`);
2673
2804
  }
@@ -2687,13 +2818,13 @@ async function read(context, request, options, policy) {
2687
2818
  }
2688
2819
  return response(request, true, content, { path, startLine, endLine: Math.min(endLine, lines.length) });
2689
2820
  }
2690
- async function list(context, request, options, policy) {
2821
+ async function list(context, request, options, policy, registry) {
2691
2822
  exactKeys(request.input, ["prefix", "maxEntries"]);
2692
2823
  const raw = request.input.prefix;
2693
2824
  const prefix = typeof raw === "string" && raw.length > 0 ? raw : void 0;
2694
2825
  const maxEntries = optionalInteger(request.input.maxEntries) ?? 1e3;
2695
2826
  if (maxEntries > 5e3) throw new TypeError("maxEntries exceeds its bound");
2696
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2827
+ const paths = await registry.files(context.workspaceDir);
2697
2828
  const allowed = await policy.list(policyContext(context, request, options, { paths, ...prefix ? { prefix } : {} }));
2698
2829
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2699
2830
  const entries = listWorkspace(paths, { ...prefix ? { prefix } : {}, maxEntries });
@@ -2711,7 +2842,7 @@ async function list(context, request, options, policy) {
2711
2842
  { count: entries.length, truncated }
2712
2843
  );
2713
2844
  }
2714
- async function search(context, request, options, policy) {
2845
+ async function search(context, request, options, policy, registry) {
2715
2846
  exactKeys(request.input, ["query", "prefix", "maxResults", "caseSensitive"]);
2716
2847
  const query = stringField(request.input, "query");
2717
2848
  if (query.length > 512) throw new TypeError("search query exceeds its bound");
@@ -2720,21 +2851,22 @@ async function search(context, request, options, policy) {
2720
2851
  const maxResults = optionalInteger(request.input.maxResults) ?? 100;
2721
2852
  if (maxResults > 500) throw new TypeError("maxResults exceeds its bound");
2722
2853
  const caseSensitive = request.input.caseSensitive === void 0 ? true : request.input.caseSensitive === true;
2723
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2854
+ const paths = await registry.files(context.workspaceDir);
2724
2855
  const allowed = await policy.search(policyContext(context, request, options, { paths, query, ...prefix ? { prefix } : {} }));
2725
2856
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2726
2857
  const matches = await searchWorkspace(context.workspaceDir, paths, {
2727
2858
  query,
2728
2859
  maxResults,
2729
2860
  caseSensitive,
2730
- ...prefix ? { prefix } : {}
2861
+ ...prefix ? { prefix } : {},
2862
+ ...context.signal ? { signal: context.signal } : {}
2731
2863
  });
2732
2864
  if (!matches.length) return response(request, true, `No match for "${query}".`, { count: 0 });
2733
2865
  return response(request, true, matches.map((match) => `${match.path}:${match.line}: ${match.text}`).join("\n"), {
2734
2866
  count: matches.length
2735
2867
  });
2736
2868
  }
2737
- async function graphQuery(context, request, options, policy) {
2869
+ async function graphQuery(context, request, options, policy, registry) {
2738
2870
  exactKeys(request.input, ["query"]);
2739
2871
  const raw = request.input.query;
2740
2872
  const query = typeof raw === "string" ? raw : "";
@@ -2744,7 +2876,7 @@ async function graphQuery(context, request, options, policy) {
2744
2876
  selector: query
2745
2877
  }));
2746
2878
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2747
- const paths = await registeredFiles(context.workspaceDir, 2e4);
2879
+ const paths = await registry.files(context.workspaceDir);
2748
2880
  const graphs = await workspaceGraphs(context.workspaceDir, paths);
2749
2881
  if (request.tool === "sandbox.overview") {
2750
2882
  return response(request, true, renderOverview(graphs, query || void 0));
@@ -2760,23 +2892,38 @@ function createCodeToolBroker(options) {
2760
2892
  validateOptions(options);
2761
2893
  const recipes = new Map(options.recipes.map((recipe2) => [recipe2.id, recipe2]));
2762
2894
  const policy = createCodePolicyGate(options);
2763
- let tail = Promise.resolve();
2895
+ const registry = createWorkspaceFileRegistry();
2896
+ let barrier = Promise.resolve();
2897
+ const activeReads = /* @__PURE__ */ new Set();
2764
2898
  return {
2765
2899
  execute(context, request) {
2766
- const result = tail.then(() => route(context, request, options, recipes, policy));
2767
- tail = result.then(() => void 0, () => void 0);
2900
+ if (isReadTool(request.tool)) {
2901
+ const result2 = barrier.then(() => route(context, request, options, recipes, policy, registry));
2902
+ const settled = result2.then(() => void 0, () => void 0);
2903
+ activeReads.add(settled);
2904
+ void settled.then(() => {
2905
+ activeReads.delete(settled);
2906
+ });
2907
+ return result2;
2908
+ }
2909
+ const earlierReads = [...activeReads];
2910
+ const result = barrier.then(() => Promise.all(earlierReads)).then(() => route(context, request, options, recipes, policy, registry));
2911
+ barrier = result.then(() => void 0, () => void 0);
2768
2912
  return result;
2769
2913
  }
2770
2914
  };
2771
2915
  }
2772
- async function route(context, request, options, recipes, policy) {
2916
+ function isReadTool(tool) {
2917
+ return tool === "sandbox.read" || tool === "sandbox.list" || tool === "sandbox.search" || GRAPH_TOOLS.has(tool);
2918
+ }
2919
+ async function route(context, request, options, recipes, policy, registry) {
2773
2920
  try {
2774
2921
  if (context.signal?.aborted) throw new TypeError("tool request was cancelled");
2775
- if (request.tool === "sandbox.read") return await read(context, request, options, policy);
2776
- if (request.tool === "sandbox.list") return await list(context, request, options, policy);
2777
- if (request.tool === "sandbox.search") return await search(context, request, options, policy);
2778
- if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy);
2779
- if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy);
2922
+ if (request.tool === "sandbox.read") return await read(context, request, options, policy, registry);
2923
+ if (request.tool === "sandbox.list") return await list(context, request, options, policy, registry);
2924
+ if (request.tool === "sandbox.search") return await search(context, request, options, policy, registry);
2925
+ if (GRAPH_TOOLS.has(request.tool)) return await graphQuery(context, request, options, policy, registry);
2926
+ if (request.tool === "sandbox.apply_patch") return await patch(context, request, options, policy, registry);
2780
2927
  return await recipe(context, request, options, recipes, policy);
2781
2928
  } catch (reason) {
2782
2929
  return response(request, false, toolFailureMessage(reason));
@@ -2791,7 +2938,7 @@ function toolFailureMessage(reason) {
2791
2938
  if (code === "EACCES" || code === "EPERM") return "that path is not readable through this tool";
2792
2939
  return "tool failed closed";
2793
2940
  }
2794
- async function patch(context, request, options, policy) {
2941
+ async function patch(context, request, options, policy, registry) {
2795
2942
  exactKeys(request.input, ["patch"]);
2796
2943
  const value = stringField(request.input, "patch");
2797
2944
  const paths = validateCodePatch(value, options.maxPatchBytes ?? 256 * 1024);
@@ -2801,6 +2948,8 @@ async function patch(context, request, options, policy) {
2801
2948
  const allowed = await policy.patch(policyContext(context, request, options, { patch: value }));
2802
2949
  if (!allowed) return response(request, false, "tool denied by CaMeL policy");
2803
2950
  await applyCodePatch(context.workspaceDir, value, paths);
2951
+ registry.invalidate(context.workspaceDir);
2952
+ forgetWorkspaceGraphs(context.workspaceDir);
2804
2953
  return response(request, true, `Applied patch to ${paths.length} file(s).`, { paths });
2805
2954
  }
2806
2955
  async function recipe(context, request, options, recipes, policy) {