@inflexa-ai/harness 0.33.1 → 0.35.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.
- package/dist/agents/conversation-agent.d.ts +7 -3
- package/dist/agents/conversation-agent.js +20 -2
- package/dist/agents/sandbox/shared.d.ts +4 -9
- package/dist/agents/sandbox/shared.js +9 -12
- package/dist/config/environment-stores.d.ts +9 -6
- package/dist/execution/artifact-record.d.ts +9 -5
- package/dist/execution/artifact-record.js +3 -3
- package/dist/execution/reconcile-manifest.js +25 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +6 -6
- package/dist/provenance/collector.js +1 -1
- package/dist/provenance/seam.d.ts +23 -2
- package/dist/provenance/types.d.ts +9 -4
- package/dist/provenance/types.js +7 -2
- package/dist/sandbox/client.d.ts +10 -1
- package/dist/sandbox/create-sandbox.d.ts +12 -0
- package/dist/sandbox/create-sandbox.js +45 -8
- package/dist/sandbox/docker-client.d.ts +11 -0
- package/dist/sandbox/docker-client.js +19 -5
- package/dist/sandbox/farm.d.ts +3 -1
- package/dist/sandbox/farm.js +3 -1
- package/dist/sandbox/image-packages.d.ts +107 -0
- package/dist/sandbox/image-packages.js +107 -0
- package/dist/sandbox/k8s-client.d.ts +11 -0
- package/dist/sandbox/k8s-client.js +20 -4
- package/dist/sandbox/sandbox-error.d.ts +40 -5
- package/dist/sandbox/sandbox-error.js +79 -5
- package/dist/tools/sandbox/list-available-packages.d.ts +18 -16
- package/dist/tools/sandbox/list-available-packages.js +90 -52
- package/dist/tools/workspace/edit-file.d.ts +32 -6
- package/dist/tools/workspace/edit-file.js +101 -15
- package/dist/tools/workspace/mutator.d.ts +68 -32
- package/dist/tools/workspace/mutator.js +203 -55
- package/dist/tools/workspace/run-exec.d.ts +5 -5
- package/dist/tools/workspace/run-exec.js +5 -5
- package/dist/tools/workspace/write-file.d.ts +4 -4
- package/dist/tools/workspace/write-file.js +14 -6
- package/dist/workspace/paths.d.ts +1 -1
- package/dist/workspace/paths.js +1 -1
- package/package.json +1 -1
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
* planning surface reads the pool-scope inventory (`readPoolInventory`) —
|
|
10
10
|
* everything the store holds, whether or not a farm links it yet — because
|
|
11
11
|
* the ask flow marks the packages the POOL does not hold, and the farm of a
|
|
12
|
-
* new analysis is empty. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ## System tools (CLI)
|
|
17
|
-
* samtools, bcftools, ...
|
|
12
|
+
* new analysis is empty. The image inventory record (`image-packages.json`,
|
|
13
|
+
* at the root of the package store) merges into either report when the host
|
|
14
|
+
* can read one: it carries the conda tools and the Node packages the image
|
|
15
|
+
* owns, which the content-addressed store cannot hold.
|
|
18
16
|
*
|
|
19
17
|
* Each entry renders as `name==version` where the source pins a version, and
|
|
20
18
|
* a targeted `names` lookup also carries the store directory and the full
|
|
@@ -23,6 +21,7 @@
|
|
|
23
21
|
*/
|
|
24
22
|
import type { EnvironmentStorePaths, PoolInventoryPackage, PoolInventorySection } from "../../config/environment-stores.js";
|
|
25
23
|
import { type FarmLock } from "../../sandbox/farm.js";
|
|
24
|
+
import { type ImagePackages } from "../../sandbox/image-packages.js";
|
|
26
25
|
/**
|
|
27
26
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
28
27
|
* the known tracks first, then an unknown track at its first occurrence.
|
|
@@ -30,9 +29,19 @@ import { type FarmLock } from "../../sandbox/farm.js";
|
|
|
30
29
|
* targeted `names` path can report the exact store identity.
|
|
31
30
|
*/
|
|
32
31
|
export declare function lockSections(lock: FarmLock): Section[];
|
|
32
|
+
/**
|
|
33
|
+
* Group the image record into its two sections, in the order of the record.
|
|
34
|
+
* An empty track yields no section, so the report never carries a heading
|
|
35
|
+
* with nothing under it.
|
|
36
|
+
*
|
|
37
|
+
* A `system_tools` row renders its `executable` name where the record gives
|
|
38
|
+
* one, because an agent invokes the binary rather than the conda package
|
|
39
|
+
* (the manifest `binaries:` map holds the pairs that differ).
|
|
40
|
+
*/
|
|
41
|
+
export declare function imageSections(record: ImagePackages): Section[];
|
|
33
42
|
/** One package entry of a section, with the store identity where the source records it. */
|
|
34
43
|
export type SectionPackage = PoolInventoryPackage;
|
|
35
|
-
/** One language track of the store, in
|
|
44
|
+
/** One language track of the store, in the section order of its source. The seam shape and the render shape are one. */
|
|
36
45
|
export type Section = PoolInventorySection;
|
|
37
46
|
/** One `names` lookup: present + canonical spelling + track + store identity, or absent. */
|
|
38
47
|
export type CheckedPackage = {
|
|
@@ -65,13 +74,6 @@ export type PackagesResult = {
|
|
|
65
74
|
readonly hasMore: boolean;
|
|
66
75
|
readonly content: string;
|
|
67
76
|
};
|
|
68
|
-
/**
|
|
69
|
-
* Parse `packages.txt` into its sections. `#` lines are the advisory header,
|
|
70
|
-
* `## X` opens a section, and every other non-empty line contributes
|
|
71
|
-
* comma-separated package names to the open section. Unknown section headings
|
|
72
|
-
* are preserved as-is — a downstream image may add its own track.
|
|
73
|
-
*/
|
|
74
|
-
export declare function parsePackagesFile(content: string): Section[];
|
|
75
77
|
/** The tool's input, as parsed from `inputSchema`. */
|
|
76
78
|
export interface PackagesQuery {
|
|
77
79
|
readonly names?: readonly string[];
|
|
@@ -83,7 +85,7 @@ export interface PackagesQuery {
|
|
|
83
85
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
84
86
|
* is only the file read plus this call.
|
|
85
87
|
*/
|
|
86
|
-
export declare function queryPackages(
|
|
88
|
+
export declare function queryPackages(rawSections: readonly Section[], { names, query, language, limit }: PackagesQuery): PackagesResult;
|
|
87
89
|
export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFile" | "imagePackagesFile" | "readPoolInventory"> & {
|
|
88
90
|
/**
|
|
89
91
|
* True when the composition also attaches the `link_packages` tool. The
|
|
@@ -96,7 +98,7 @@ export type ListAvailablePackagesDeps = Pick<EnvironmentStorePaths, "farmLockFil
|
|
|
96
98
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
97
99
|
* when the embedder binds one (a conversation or planning surface), the
|
|
98
100
|
* host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
|
|
99
|
-
*
|
|
101
|
+
* record merges into either report.
|
|
100
102
|
*/
|
|
101
103
|
export declare function createListAvailablePackagesTool(deps?: ListAvailablePackagesDeps): import("../define-tool.js").Tool<{
|
|
102
104
|
names?: string[] | undefined;
|
|
@@ -9,25 +9,23 @@
|
|
|
9
9
|
* planning surface reads the pool-scope inventory (`readPoolInventory`) —
|
|
10
10
|
* everything the store holds, whether or not a farm links it yet — because
|
|
11
11
|
* the ask flow marks the packages the POOL does not hold, and the farm of a
|
|
12
|
-
* new analysis is empty. The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ## System tools (CLI)
|
|
17
|
-
* samtools, bcftools, ...
|
|
12
|
+
* new analysis is empty. The image inventory record (`image-packages.json`,
|
|
13
|
+
* at the root of the package store) merges into either report when the host
|
|
14
|
+
* can read one: it carries the conda tools and the Node packages the image
|
|
15
|
+
* owns, which the content-addressed store cannot hold.
|
|
18
16
|
*
|
|
19
17
|
* Each entry renders as `name==version` where the source pins a version, and
|
|
20
18
|
* a targeted `names` lookup also carries the store directory and the full
|
|
21
19
|
* content hash where the source records them. A full listing carries no
|
|
22
20
|
* hashes — a thousand rows of sha256 would bury the signal.
|
|
23
21
|
*/
|
|
24
|
-
import { readFile } from "node:fs/promises";
|
|
25
22
|
import { ok } from "neverthrow";
|
|
26
23
|
import { z } from "zod";
|
|
27
24
|
import { defineTool } from "../define-tool.js";
|
|
28
25
|
import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
29
26
|
import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
30
27
|
import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
28
|
+
import { IMAGE_PACKAGES_FILE, readImagePackagesFile } from "../../sandbox/image-packages.js";
|
|
31
29
|
/**
|
|
32
30
|
* Where the lock lives when the host mounts the farm at the same path the
|
|
33
31
|
* sandbox does. Both farm container paths are tried, because the path keys on
|
|
@@ -36,15 +34,30 @@ import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
|
36
34
|
* reads the farm somewhere else injects its own path instead.
|
|
37
35
|
*/
|
|
38
36
|
const DEFAULT_FARM_LOCK_FILES = [`${LIBS_CONTAINER_PATH}/farm/inflexa.lock`, `${LIBS_CONTAINER_PATH}/current/inflexa.lock`];
|
|
39
|
-
/**
|
|
40
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Where the image inventory record lives: at the root of the package store,
|
|
39
|
+
* which the catalog build copied it into. The default names the container
|
|
40
|
+
* mountpoint of that store, the same rule as {@link DEFAULT_FARM_LOCK_FILES}
|
|
41
|
+
* — a host whose own process sees the store the way a sandbox does needs no
|
|
42
|
+
* configuration. A host that reads the store somewhere else injects
|
|
43
|
+
* `imagePackagesFile`.
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FILE}`;
|
|
46
|
+
/**
|
|
47
|
+
* The two headings that a lock track and the image record can both carry.
|
|
48
|
+
* Declared once, because {@link LANGUAGE_MATCHERS} keys the `language` filter
|
|
49
|
+
* on the heading text, and {@link foldSections} merges two sources by it: a
|
|
50
|
+
* second spelling of either would split one track into two headings.
|
|
51
|
+
*/
|
|
52
|
+
const CLI_TITLE = "System tools (CLI)";
|
|
53
|
+
const NODE_TITLE = "Node (npm)";
|
|
41
54
|
/** The section title of each known lock track. An unknown track titles itself. */
|
|
42
55
|
const TRACK_TITLES = {
|
|
43
56
|
python: "Python (pip)",
|
|
44
57
|
cran: "R (CRAN)",
|
|
45
58
|
bioconductor: "R (Bioconductor)",
|
|
46
59
|
github: "R (GitHub)",
|
|
47
|
-
node:
|
|
60
|
+
node: NODE_TITLE,
|
|
48
61
|
};
|
|
49
62
|
/**
|
|
50
63
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
@@ -66,6 +79,56 @@ export function lockSections(lock) {
|
|
|
66
79
|
}
|
|
67
80
|
return [...byTrack.entries()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
|
|
68
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Group the image record into its two sections, in the order of the record.
|
|
84
|
+
* An empty track yields no section, so the report never carries a heading
|
|
85
|
+
* with nothing under it.
|
|
86
|
+
*
|
|
87
|
+
* A `system_tools` row renders its `executable` name where the record gives
|
|
88
|
+
* one, because an agent invokes the binary rather than the conda package
|
|
89
|
+
* (the manifest `binaries:` map holds the pairs that differ).
|
|
90
|
+
*/
|
|
91
|
+
export function imageSections(record) {
|
|
92
|
+
const sections = [];
|
|
93
|
+
if (record.system_tools.length > 0) {
|
|
94
|
+
sections.push({
|
|
95
|
+
title: CLI_TITLE,
|
|
96
|
+
packages: record.system_tools.map((tool) => ({ name: tool.executable ?? tool.name, version: tool.version })),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (record.node.length > 0) {
|
|
100
|
+
sections.push({ title: NODE_TITLE, packages: record.node.map((pkg) => ({ name: pkg.name, version: pkg.version })) });
|
|
101
|
+
}
|
|
102
|
+
return sections;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge the sections that share a title, in first-occurrence order, and keep
|
|
106
|
+
* the first entry of each name inside a merged section.
|
|
107
|
+
*
|
|
108
|
+
* Two sources can carry one track: the `node` track of a farm lock and the
|
|
109
|
+
* `node` track of the image record. Rendered as they arrive, a listing would
|
|
110
|
+
* print one heading twice and read as two tracks. The fold keeps the first
|
|
111
|
+
* writer, which is the same rule the `names` index applies, thus the listing
|
|
112
|
+
* and the presence check agree on which entry a colliding name resolves to.
|
|
113
|
+
*/
|
|
114
|
+
function foldSections(sections) {
|
|
115
|
+
const byTitle = new Map();
|
|
116
|
+
for (const section of sections) {
|
|
117
|
+
let open = byTitle.get(section.title);
|
|
118
|
+
if (!open) {
|
|
119
|
+
open = { title: section.title, packages: [], seen: new Set() };
|
|
120
|
+
byTitle.set(section.title, open);
|
|
121
|
+
}
|
|
122
|
+
for (const pkg of section.packages) {
|
|
123
|
+
const key = pkg.name.toLowerCase();
|
|
124
|
+
if (open.seen.has(key))
|
|
125
|
+
continue;
|
|
126
|
+
open.seen.add(key);
|
|
127
|
+
open.packages.push(pkg);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...byTitle.values()].map(({ title, packages }) => ({ title, packages }));
|
|
131
|
+
}
|
|
69
132
|
/**
|
|
70
133
|
* Default cap on a listing — high enough that the real store is never truncated.
|
|
71
134
|
* The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
|
|
@@ -98,39 +161,10 @@ const LANGUAGE_MATCHERS = {
|
|
|
98
161
|
// The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
|
|
99
162
|
r: (t) => /^r\b/i.test(t),
|
|
100
163
|
python: (t) => /^python\b/i.test(t),
|
|
101
|
-
// `
|
|
164
|
+
// `CLI_TITLE` — the conda-installed bioinformatics executables.
|
|
102
165
|
cli: (t) => /system tools|\bcli\b/i.test(t),
|
|
103
166
|
node: (t) => /^node\b/i.test(t),
|
|
104
167
|
};
|
|
105
|
-
/**
|
|
106
|
-
* Parse `packages.txt` into its sections. `#` lines are the advisory header,
|
|
107
|
-
* `## X` opens a section, and every other non-empty line contributes
|
|
108
|
-
* comma-separated package names to the open section. Unknown section headings
|
|
109
|
-
* are preserved as-is — a downstream image may add its own track.
|
|
110
|
-
*/
|
|
111
|
-
export function parsePackagesFile(content) {
|
|
112
|
-
const sections = [];
|
|
113
|
-
for (const line of content.split("\n")) {
|
|
114
|
-
const trimmed = line.trim();
|
|
115
|
-
if (!trimmed)
|
|
116
|
-
continue;
|
|
117
|
-
if (trimmed.startsWith("##")) {
|
|
118
|
-
sections.push({ title: trimmed.slice(2).trim(), packages: [] });
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
if (trimmed.startsWith("#"))
|
|
122
|
-
continue;
|
|
123
|
-
const open = sections.at(-1);
|
|
124
|
-
if (!open)
|
|
125
|
-
continue;
|
|
126
|
-
for (const name of trimmed.split(",")) {
|
|
127
|
-
const pkg = name.trim();
|
|
128
|
-
if (pkg)
|
|
129
|
-
open.packages.push({ name: pkg });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return sections;
|
|
133
|
-
}
|
|
134
168
|
/** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
|
|
135
169
|
function renderPackage(entry) {
|
|
136
170
|
return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
|
|
@@ -160,15 +194,16 @@ function renderListing(sections, limit) {
|
|
|
160
194
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
161
195
|
* is only the file read plus this call.
|
|
162
196
|
*/
|
|
163
|
-
export function queryPackages(
|
|
197
|
+
export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
198
|
+
const sections = foldSections(rawSections);
|
|
164
199
|
// Presence check — the cheap, targeted path. Answers "is X available, and in
|
|
165
200
|
// which track" without returning the catalog.
|
|
166
201
|
if (names && names.length > 0) {
|
|
167
202
|
const index = new Map();
|
|
168
203
|
for (const section of sections) {
|
|
169
204
|
for (const pkg of section.packages) {
|
|
170
|
-
// First writer wins:
|
|
171
|
-
// colliding across tracks resolves to the earliest one.
|
|
205
|
+
// First writer wins: the sections arrive in a fixed order, so a
|
|
206
|
+
// name colliding across tracks resolves to the earliest one.
|
|
172
207
|
if (!index.has(pkg.name.toLowerCase()))
|
|
173
208
|
index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
|
|
174
209
|
}
|
|
@@ -219,7 +254,7 @@ export function queryPackages(sections, { names, query, language, limit }) {
|
|
|
219
254
|
* Create the package inventory over the bound source: the pool-scope reader
|
|
220
255
|
* when the embedder binds one (a conversation or planning surface), the
|
|
221
256
|
* host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
|
|
222
|
-
*
|
|
257
|
+
* record merges into either report.
|
|
223
258
|
*/
|
|
224
259
|
export function createListAvailablePackagesTool(deps = {}) {
|
|
225
260
|
const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
|
|
@@ -284,12 +319,15 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
284
319
|
return "full package list";
|
|
285
320
|
},
|
|
286
321
|
execute: async (input) => {
|
|
287
|
-
// The
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
322
|
+
// The image record merges in when the host can read a valid one.
|
|
323
|
+
// An absent record and an invalid record both merge nothing, and
|
|
324
|
+
// neither is an error: the record is an enrichment, and the farm
|
|
325
|
+
// or pool inventory stays whole without it. A store packed before
|
|
326
|
+
// the record existed carries none. The tool holds no logger, thus
|
|
327
|
+
// an invalid record cannot be reported here — the schema of the
|
|
328
|
+
// harness refuses it, and the report degrades to the tracks it
|
|
329
|
+
// could read.
|
|
330
|
+
const recordSections = readImagePackagesFile(imagePackagesFile).map(imageSections).unwrapOr([]);
|
|
293
331
|
// An unreadable inventory is an expected environment state — model it as an
|
|
294
332
|
// `available: false` data variant telling the caller the set is UNKNOWN,
|
|
295
333
|
// WITH the reason: without it, a structural fault (a damaged dependency
|
|
@@ -301,7 +339,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
301
339
|
}));
|
|
302
340
|
if (pool.kind === "unavailable")
|
|
303
341
|
return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
|
|
304
|
-
return ok(queryPackages([...pool.sections, ...
|
|
342
|
+
return ok(queryPackages([...pool.sections, ...recordSections], input));
|
|
305
343
|
}
|
|
306
344
|
let lock = null;
|
|
307
345
|
for (const candidate of lockCandidates) {
|
|
@@ -312,7 +350,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
312
350
|
if (lock === null) {
|
|
313
351
|
return ok({ available: false, content: UNAVAILABLE_NOTE });
|
|
314
352
|
}
|
|
315
|
-
return ok(queryPackages([...lockSections(lock), ...
|
|
353
|
+
return ok(queryPackages([...lockSections(lock), ...recordSections], input));
|
|
316
354
|
},
|
|
317
355
|
});
|
|
318
356
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `edit_file` —
|
|
3
|
-
*
|
|
2
|
+
* `edit_file` — workspace edit, confined to the agent's writable working
|
|
3
|
+
* directory. Composes the read seam (fetch current content) with a
|
|
4
4
|
* search/replace and the `WorkspaceMutator` seam (write the result) — same
|
|
5
5
|
* resolution + confinement contract as `write_file`, no path logic of its own
|
|
6
6
|
* (see the harness-workspace-tools spec).
|
|
7
7
|
*
|
|
8
8
|
* Edit semantics: replace `old_string` with `new_string`. `old_string` MUST
|
|
9
9
|
* occur in the file; when `replace_all` is false (default) it MUST occur
|
|
10
|
-
* exactly once.
|
|
11
|
-
*
|
|
10
|
+
* exactly once. With `regex=true`, `old_string` is a JS RegExp pattern
|
|
11
|
+
* (compiled with the `gm` flags — multiline anchors), `new_string` may use
|
|
12
|
+
* capture-group references, and exactly one of `replace_all=true` or
|
|
13
|
+
* `expected_matches` gates the write. Provenance hashes the post-edit
|
|
14
|
+
* content — recorded by the mutator.
|
|
12
15
|
*/
|
|
13
16
|
import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
|
|
14
17
|
import type { WorkspaceMutator, WriteFileResult } from "./mutator.js";
|
|
@@ -23,6 +26,21 @@ export type EditFileResult = {
|
|
|
23
26
|
readonly status: "not_unique";
|
|
24
27
|
readonly path: string;
|
|
25
28
|
readonly occurrences: number;
|
|
29
|
+
} | {
|
|
30
|
+
readonly status: "invalid_pattern";
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly pattern: string;
|
|
33
|
+
readonly error: string;
|
|
34
|
+
} | {
|
|
35
|
+
readonly status: "invalid_arguments";
|
|
36
|
+
readonly path: string;
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly status: "match_count_mismatch";
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly expected: number;
|
|
42
|
+
readonly actual: number;
|
|
43
|
+
readonly lines: readonly number[];
|
|
26
44
|
} | Exclude<WriteFileResult, {
|
|
27
45
|
status: "ok";
|
|
28
46
|
}> | {
|
|
@@ -30,17 +48,25 @@ export type EditFileResult = {
|
|
|
30
48
|
readonly path: string;
|
|
31
49
|
readonly replacements: number;
|
|
32
50
|
readonly bytesWritten: number;
|
|
51
|
+
/** Regex mode only: 1-based line numbers where the matches started. */
|
|
52
|
+
readonly lines?: readonly number[];
|
|
33
53
|
};
|
|
34
54
|
export interface EditFileDeps {
|
|
35
55
|
readonly mutator: WorkspaceMutator;
|
|
36
56
|
/** Read seam used to fetch current file content for search/replace. */
|
|
37
57
|
readonly workspaceFilesystem: WorkspaceFilesystem;
|
|
38
|
-
/**
|
|
39
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Absolute host working directory — relative read paths resolve here. Omit
|
|
60
|
+
* for the conversation agent: the read seam then defaults to the analysis
|
|
61
|
+
* root, matching the session-scoped mutator's write prefix.
|
|
62
|
+
*/
|
|
63
|
+
readonly workingDir?: string;
|
|
40
64
|
}
|
|
41
65
|
export declare function createEditFileTool(deps: EditFileDeps): import("../define-tool.js").Tool<{
|
|
42
66
|
path: string;
|
|
43
67
|
old_string: string;
|
|
44
68
|
new_string: string;
|
|
45
69
|
replace_all: boolean;
|
|
70
|
+
regex: boolean;
|
|
71
|
+
expected_matches?: number | undefined;
|
|
46
72
|
}, EditFileResult>;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `edit_file` —
|
|
3
|
-
*
|
|
2
|
+
* `edit_file` — workspace edit, confined to the agent's writable working
|
|
3
|
+
* directory. Composes the read seam (fetch current content) with a
|
|
4
4
|
* search/replace and the `WorkspaceMutator` seam (write the result) — same
|
|
5
5
|
* resolution + confinement contract as `write_file`, no path logic of its own
|
|
6
6
|
* (see the harness-workspace-tools spec).
|
|
7
7
|
*
|
|
8
8
|
* Edit semantics: replace `old_string` with `new_string`. `old_string` MUST
|
|
9
9
|
* occur in the file; when `replace_all` is false (default) it MUST occur
|
|
10
|
-
* exactly once.
|
|
11
|
-
*
|
|
10
|
+
* exactly once. With `regex=true`, `old_string` is a JS RegExp pattern
|
|
11
|
+
* (compiled with the `gm` flags — multiline anchors), `new_string` may use
|
|
12
|
+
* capture-group references, and exactly one of `replace_all=true` or
|
|
13
|
+
* `expected_matches` gates the write. Provenance hashes the post-edit
|
|
14
|
+
* content — recorded by the mutator.
|
|
12
15
|
*/
|
|
13
16
|
import { ok } from "neverthrow";
|
|
14
17
|
import { z } from "zod";
|
|
@@ -22,15 +25,31 @@ const EditFileInputSchema = z.object({
|
|
|
22
25
|
old_string: z
|
|
23
26
|
.string()
|
|
24
27
|
.min(1)
|
|
25
|
-
.describe("The exact text to find and replace
|
|
26
|
-
"
|
|
28
|
+
.describe("The exact text to find and replace — or, when regex=true, a " +
|
|
29
|
+
"JavaScript regular expression pattern. In exact mode with " +
|
|
30
|
+
"replace_all=false (default), it must occur exactly once — include " +
|
|
27
31
|
"enough surrounding context to make it unique."),
|
|
28
|
-
new_string: z.string().describe("The replacement text."),
|
|
32
|
+
new_string: z.string().describe("The replacement text. When regex=true it may use capture-group references such as $1."),
|
|
29
33
|
replace_all: z
|
|
30
34
|
.boolean()
|
|
31
35
|
.optional()
|
|
32
36
|
.default(false)
|
|
33
37
|
.describe("If true, replace every occurrence. If false (default), old_string " + "must occur exactly once or the edit returns a `not_unique` data variant."),
|
|
38
|
+
regex: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.default(false)
|
|
42
|
+
.describe("If true, old_string is a JavaScript regular expression compiled with " +
|
|
43
|
+
"the `gm` flags (^ and $ match line boundaries). Requires exactly one " +
|
|
44
|
+
"of replace_all=true or expected_matches."),
|
|
45
|
+
expected_matches: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("Regex mode only: the exact number of matches you expect. On a " +
|
|
51
|
+
"mismatch the edit writes nothing and returns the actual count with " +
|
|
52
|
+
"the matching line numbers."),
|
|
34
53
|
});
|
|
35
54
|
function replaceString(content, oldString, newString, replaceAll) {
|
|
36
55
|
if (!content.includes(oldString))
|
|
@@ -54,48 +73,115 @@ function replaceString(content, oldString, newString, replaceAll) {
|
|
|
54
73
|
replacements: 1,
|
|
55
74
|
};
|
|
56
75
|
}
|
|
76
|
+
/** 1-based line numbers of the given start offsets (ascending), deduplicated. */
|
|
77
|
+
function lineNumbersAt(content, offsets) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
let line = 1;
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
for (const offset of offsets) {
|
|
82
|
+
for (; cursor < offset; cursor++) {
|
|
83
|
+
if (content.charCodeAt(cursor) === 10)
|
|
84
|
+
line++;
|
|
85
|
+
}
|
|
86
|
+
if (lines[lines.length - 1] !== line)
|
|
87
|
+
lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
function replaceRegex(content, pattern, replacement, expected) {
|
|
92
|
+
let compiled;
|
|
93
|
+
try {
|
|
94
|
+
compiled = new RegExp(pattern, "gm");
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return { kind: "invalid_pattern", error: err instanceof Error ? err.message : String(err) };
|
|
98
|
+
}
|
|
99
|
+
const matches = [...content.matchAll(compiled)];
|
|
100
|
+
if (matches.length === 0)
|
|
101
|
+
return { kind: "not_found" };
|
|
102
|
+
const lines = lineNumbersAt(content, matches.map((match) => match.index));
|
|
103
|
+
if (expected !== undefined && matches.length !== expected) {
|
|
104
|
+
return { kind: "count_mismatch", expected, actual: matches.length, lines };
|
|
105
|
+
}
|
|
106
|
+
return { kind: "ok", content: content.replace(compiled, replacement), replacements: matches.length, lines };
|
|
107
|
+
}
|
|
57
108
|
export function createEditFileTool(deps) {
|
|
58
109
|
return defineTool({
|
|
59
110
|
id: "edit_file",
|
|
60
|
-
//
|
|
111
|
+
// The mutator wraps the disk mutation in `ctx.runStep` itself, so the
|
|
112
|
+
// body runs unwrapped in the workflow body (see the harness-tools spec).
|
|
61
113
|
executionMode: "workflow",
|
|
62
114
|
description: "Edit a file in your working directory by replacing specific text. " +
|
|
63
115
|
"Read the file first to get the exact text. When replace_all is false " +
|
|
64
|
-
"(default), old_string must occur exactly once.
|
|
65
|
-
"
|
|
66
|
-
"
|
|
116
|
+
"(default), old_string must occur exactly once. For a bulk pattern " +
|
|
117
|
+
"edit set regex=true: old_string is a JS regex, new_string may use $1 " +
|
|
118
|
+
"references, and exactly one of replace_all=true or expected_matches " +
|
|
119
|
+
"gates the write. Returns `out_of_prefix` / `out_of_scope` / " +
|
|
120
|
+
"`file_not_found` / `not_found` / `not_unique` / `match_count_mismatch` " +
|
|
121
|
+
"data variants — never throws on expected outcomes.",
|
|
67
122
|
inputSchema: EditFileInputSchema,
|
|
68
123
|
// The path only. The replaced text is file content, not a description.
|
|
69
124
|
describeCall: ({ path }) => path,
|
|
70
|
-
execute: async ({ path, old_string, new_string, replace_all }, ctx) => {
|
|
125
|
+
execute: async ({ path, old_string, new_string, replace_all, regex, expected_matches }, ctx) => {
|
|
126
|
+
const replaceAll = replace_all === true;
|
|
127
|
+
const useRegex = regex === true;
|
|
128
|
+
if (!useRegex && expected_matches !== undefined) {
|
|
129
|
+
return ok({ status: "invalid_arguments", path, reason: "expected_matches requires regex=true." });
|
|
130
|
+
}
|
|
131
|
+
if (useRegex && (expected_matches !== undefined) === replaceAll) {
|
|
132
|
+
return ok({
|
|
133
|
+
status: "invalid_arguments",
|
|
134
|
+
path,
|
|
135
|
+
reason: "regex=true requires exactly one of replace_all=true or expected_matches.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
71
138
|
const read = unwrapOrThrow(await deps.workspaceFilesystem.readFile({
|
|
72
139
|
session: ctx.session,
|
|
73
140
|
path,
|
|
74
|
-
workingDir: deps.workingDir,
|
|
141
|
+
...(deps.workingDir !== undefined ? { workingDir: deps.workingDir } : {}),
|
|
75
142
|
}));
|
|
76
143
|
if (read.kind === "not_found")
|
|
77
144
|
return ok({ status: "file_not_found", path });
|
|
78
145
|
if (read.kind === "out_of_scope")
|
|
79
146
|
return ok({ status: "out_of_scope", path });
|
|
80
|
-
const
|
|
147
|
+
const content = read.content.toString("utf8");
|
|
148
|
+
const replaced = useRegex
|
|
149
|
+
? replaceRegex(content, old_string, new_string, expected_matches)
|
|
150
|
+
: replaceString(content, old_string, new_string, replaceAll);
|
|
81
151
|
if (replaced.kind === "not_found")
|
|
82
152
|
return ok({ status: "not_found", path });
|
|
83
153
|
if (replaced.kind === "not_unique") {
|
|
84
154
|
return ok({ status: "not_unique", path, occurrences: replaced.count });
|
|
85
155
|
}
|
|
156
|
+
if (replaced.kind === "invalid_pattern") {
|
|
157
|
+
return ok({ status: "invalid_pattern", path, pattern: old_string, error: replaced.error });
|
|
158
|
+
}
|
|
159
|
+
if (replaced.kind === "count_mismatch") {
|
|
160
|
+
return ok({
|
|
161
|
+
status: "match_count_mismatch",
|
|
162
|
+
path,
|
|
163
|
+
expected: replaced.expected,
|
|
164
|
+
actual: replaced.actual,
|
|
165
|
+
lines: replaced.lines,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
86
168
|
const result = await deps.mutator.writeFile({
|
|
87
169
|
path,
|
|
88
170
|
content: replaced.content,
|
|
89
171
|
toolName: "edit_file",
|
|
90
|
-
|
|
172
|
+
invocationId: ctx.invocationId,
|
|
173
|
+
runStep: ctx.runStep,
|
|
174
|
+
session: ctx.session,
|
|
91
175
|
});
|
|
92
176
|
if (result.status !== "ok")
|
|
93
177
|
return ok(result);
|
|
178
|
+
const lines = replaced.lines;
|
|
94
179
|
return ok({
|
|
95
180
|
status: "ok",
|
|
96
181
|
path: result.path,
|
|
97
182
|
replacements: replaced.replacements,
|
|
98
183
|
bytesWritten: result.bytesWritten,
|
|
184
|
+
...(lines === undefined ? {} : { lines }),
|
|
99
185
|
});
|
|
100
186
|
},
|
|
101
187
|
});
|