@hydraharness/harness-tool-str-replace-editor 0.0.0-stage → 0.1.1-rc.7
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/LICENSE +21 -0
- package/README.md +49 -2
- package/lib/index.js +345 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +61 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hydraharness/harness-tool-str-replace-editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface.
|
|
4
|
+
|
|
5
|
+
## Config
|
|
6
|
+
|
|
7
|
+
| Key | Default | Meaning |
|
|
8
|
+
|---|---:|---|
|
|
9
|
+
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
|
|
10
|
+
| `description` | Editor command guide | Model-facing tool description. |
|
|
11
|
+
|
|
12
|
+
## Tool
|
|
13
|
+
|
|
14
|
+
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
|
15
|
+
|
|
16
|
+
## Model Experience
|
|
17
|
+
|
|
18
|
+
### Tool schema
|
|
19
|
+
|
|
20
|
+
#### What the model sees
|
|
21
|
+
|
|
22
|
+
The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#hydraharness-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section.
|
|
23
|
+
|
|
24
|
+
#### Token effect
|
|
25
|
+
|
|
26
|
+
Fixed schema cost while `str_replace_editor` is visible.
|
|
27
|
+
|
|
28
|
+
#### KV Cache effect
|
|
29
|
+
|
|
30
|
+
Prefix-stable while the configured description and schema remain unchanged.
|
|
31
|
+
|
|
32
|
+
### Tool results
|
|
33
|
+
|
|
34
|
+
#### What the model sees
|
|
35
|
+
|
|
36
|
+
Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
|
|
37
|
+
|
|
38
|
+
#### Token effect
|
|
39
|
+
|
|
40
|
+
Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
|
|
41
|
+
|
|
42
|
+
#### KV Cache effect
|
|
43
|
+
|
|
44
|
+
Append-only tool results follow the reusable request prefix.
|
|
45
|
+
|
|
46
|
+
## Known Limitations and Deferred Work
|
|
47
|
+
|
|
48
|
+
- Operations target UTF-8 text; binary files are unsupported.
|
|
49
|
+
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
|
|
50
|
+
- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import z from "@hydraharness/schemastery";
|
|
3
|
+
import { FsError } from "@hydraharness/harness-fs";
|
|
4
|
+
import { sandboxDenialMarker } from "@hydraharness/harness-sandbox";
|
|
5
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
6
|
+
//#region lib/types/index.js
|
|
7
|
+
/**
|
|
8
|
+
* Model-facing `str_replace_editor` over the Harness filesystem seam.
|
|
9
|
+
* @module @hydraharness/harness-tool-str-replace-editor
|
|
10
|
+
*/
|
|
11
|
+
const TRUNCATED_MESSAGE = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>";
|
|
12
|
+
const DEFAULT_DESCRIPTION = `
|
|
13
|
+
Custom editing tool for viewing, creating and editing files
|
|
14
|
+
* State is persistent across command calls and discussions with the user
|
|
15
|
+
* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep
|
|
16
|
+
* The \`create\` command cannot be used if the specified \`path\` already exists as a file
|
|
17
|
+
* If a \`command\` generates a long output, it will be truncated and marked with \`<response clipped>\`
|
|
18
|
+
* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit \`str_replace.new_str\` rather than setting it to null when deleting a match
|
|
19
|
+
|
|
20
|
+
Notes for using the \`str_replace\` command:
|
|
21
|
+
* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
|
22
|
+
* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique
|
|
23
|
+
* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
|
|
24
|
+
`.trim();
|
|
25
|
+
function maybeTruncate(content, maxOutputChars) {
|
|
26
|
+
return content.length <= maxOutputChars ? content : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE;
|
|
27
|
+
}
|
|
28
|
+
function codepointCompare(left, right) {
|
|
29
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
function matchOffsets(content, search) {
|
|
32
|
+
const offsets = [];
|
|
33
|
+
let offset = 0;
|
|
34
|
+
while (true) {
|
|
35
|
+
const match = content.indexOf(search, offset);
|
|
36
|
+
if (match < 0) return offsets;
|
|
37
|
+
offsets.push(match);
|
|
38
|
+
offset = match + search.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function lineNumbersAt(content, offsets) {
|
|
42
|
+
let line = 1;
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
return offsets.map((offset) => {
|
|
45
|
+
while (cursor < offset) {
|
|
46
|
+
if (content[cursor] === "\n") line += 1;
|
|
47
|
+
cursor += 1;
|
|
48
|
+
}
|
|
49
|
+
return line;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
var MutationPolicy = class {
|
|
53
|
+
policy;
|
|
54
|
+
constructor(ctx) {
|
|
55
|
+
this.policy = ctx.fs.sandboxMode === void 0 ? void 0 : ctx.get("sandboxPolicy");
|
|
56
|
+
if (ctx.fs.sandboxMode !== void 0 && this.policy === void 0) throw new Error("tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing");
|
|
57
|
+
}
|
|
58
|
+
resolve(exec) {
|
|
59
|
+
return this.policy?.resolve({ ...exec.agent === void 0 ? {} : { session: exec.agent.session } });
|
|
60
|
+
}
|
|
61
|
+
mapError(error, policy) {
|
|
62
|
+
if (!(error instanceof FsError) || error.code !== "FS_SANDBOX_DENIED") return error;
|
|
63
|
+
const mode = policy.mode;
|
|
64
|
+
return new FsError(sandboxDenialMarker(mode), "FS_SANDBOX_DENIED", { cause: error });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
async function resolveTarget(ctx, path, signal) {
|
|
68
|
+
if (path.trim().length === 0) throw new Error("path must be a non-empty string");
|
|
69
|
+
if (!isAbsolute(path)) throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`);
|
|
70
|
+
return ctx.fs.resolve(path, { signal });
|
|
71
|
+
}
|
|
72
|
+
async function statExisting(ctx, target, command, exec) {
|
|
73
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
74
|
+
if (info === void 0) {
|
|
75
|
+
ctx.emit("fs/observed", target, { kind: "absent" }, exec);
|
|
76
|
+
throw new FsError(`The path ${target.displayPath} does not exist. Please provide a valid path.`, "FS_NOT_FOUND");
|
|
77
|
+
}
|
|
78
|
+
if (info.type === "directory" && command !== "view") throw new FsError(`The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`, "FS_NOT_REGULAR_FILE");
|
|
79
|
+
return info;
|
|
80
|
+
}
|
|
81
|
+
function requiredForCommand(value, parameter, command, allowEmpty = true) {
|
|
82
|
+
if (value === void 0) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`);
|
|
83
|
+
if (!allowEmpty && value.length === 0) throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function formatFileView(path, content, maxOutputChars, viewRange) {
|
|
87
|
+
const allLines = content.split("\n");
|
|
88
|
+
let lines = allLines;
|
|
89
|
+
let initialLine = 1;
|
|
90
|
+
let finalLine;
|
|
91
|
+
let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`;
|
|
92
|
+
if (viewRange !== void 0) {
|
|
93
|
+
const [requestedInitialLine, requestedFinalLine] = viewRange;
|
|
94
|
+
if (viewRange.length !== 2 || requestedInitialLine === void 0 || requestedFinalLine === void 0 || !viewRange.every(Number.isInteger)) throw new Error("Invalid `view_range`. It should be a list of two integers.");
|
|
95
|
+
initialLine = requestedInitialLine;
|
|
96
|
+
finalLine = requestedFinalLine;
|
|
97
|
+
if (initialLine < 1 || initialLine > allLines.length) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`);
|
|
98
|
+
if (finalLine > allLines.length) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``);
|
|
99
|
+
if (finalLine !== -1 && finalLine < initialLine) throw new Error(`Invalid \`view_range\`: [${viewRange.join(", ")}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``);
|
|
100
|
+
lines = finalLine === -1 ? allLines.slice(initialLine - 1) : allLines.slice(initialLine - 1, finalLine);
|
|
101
|
+
prompt += ` with view_range=[${initialLine}, ${finalLine}]`;
|
|
102
|
+
}
|
|
103
|
+
const numbered = lines.map((line, index) => `${String(initialLine + index).padStart(6, " ")} ${line}`).join("\n");
|
|
104
|
+
return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars);
|
|
105
|
+
}
|
|
106
|
+
async function listDirectory(ctx, target, maxOutputChars, exec) {
|
|
107
|
+
async function visit(dir, depth) {
|
|
108
|
+
const entries = await ctx.fs.listDir(dir, exec.signal);
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (const entry of entries.filter((candidate) => !candidate.name.startsWith(".") && candidate.name !== "node_modules" && candidate.name !== "__pycache__")) {
|
|
111
|
+
const type = entry.type === "directory" ? "d" : entry.type === "file" ? "f" : "?";
|
|
112
|
+
rows.push(`${type}\t${entry.target.displayPath}`);
|
|
113
|
+
if (entry.type === "directory" && depth < 2) rows.push(...await visit(entry.target, depth + 1));
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)];
|
|
118
|
+
rows.sort((left, right) => {
|
|
119
|
+
return codepointCompare(left.slice(left.indexOf(" ") + 1), right.slice(right.indexOf(" ") + 1));
|
|
120
|
+
});
|
|
121
|
+
const listing = maybeTruncate(rows.join("\n") + "\n", maxOutputChars);
|
|
122
|
+
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`;
|
|
123
|
+
}
|
|
124
|
+
async function viewPath(ctx, path, viewRange, maxOutputChars, exec) {
|
|
125
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
126
|
+
const info = await statExisting(ctx, target, "view", exec);
|
|
127
|
+
if (info.type === "directory") {
|
|
128
|
+
if (viewRange !== void 0) throw new Error("The `view_range` parameter is not allowed when `path` points to a directory.");
|
|
129
|
+
return listDirectory(ctx, target, maxOutputChars, exec);
|
|
130
|
+
}
|
|
131
|
+
if (info.type !== "file") throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, "FS_NOT_REGULAR_FILE");
|
|
132
|
+
const content = await ctx.fs.readText(target, exec.signal);
|
|
133
|
+
ctx.emit("fs/observed", target, {
|
|
134
|
+
kind: "present",
|
|
135
|
+
version: info.version
|
|
136
|
+
}, exec);
|
|
137
|
+
return formatFileView(target.displayPath, content, maxOutputChars, viewRange);
|
|
138
|
+
}
|
|
139
|
+
async function createFile(ctx, policy, path, fileText, exec) {
|
|
140
|
+
const content = requiredForCommand(fileText, "file_text", "create");
|
|
141
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
142
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
143
|
+
if (await ctx.fs.stat(target, exec.signal) !== void 0) throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`);
|
|
144
|
+
const intent = await ctx.waterfall("fs/write-intent", target, exec, () => ({ kind: "createIfAbsent" }));
|
|
145
|
+
let outcome;
|
|
146
|
+
try {
|
|
147
|
+
outcome = await ctx.fs.writeText(target, content, intent, exec.signal, sandboxPolicy);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
150
|
+
}
|
|
151
|
+
ctx.emit("fs/observed", target, {
|
|
152
|
+
kind: "present",
|
|
153
|
+
version: outcome.version
|
|
154
|
+
}, exec);
|
|
155
|
+
return `New file created successfully at: ${target.displayPath}`;
|
|
156
|
+
}
|
|
157
|
+
async function replaceInFile(ctx, policy, path, oldStr, newStr, exec) {
|
|
158
|
+
if (newStr === null) throw new Error("Parameter `new_str` must be omitted or contain a string for command: str_replace");
|
|
159
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
160
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
161
|
+
const intent = await ctx.waterfall("fs/edit-intent", target, exec, () => void 0);
|
|
162
|
+
const oldValue = requiredForCommand(oldStr, "old_str", "str_replace", false);
|
|
163
|
+
const newValue = newStr ?? "";
|
|
164
|
+
const info = await statExisting(ctx, target, "str_replace", exec);
|
|
165
|
+
if (info.type !== "file") throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
166
|
+
const before = await ctx.fs.readText(target, exec.signal);
|
|
167
|
+
const offsets = matchOffsets(before, oldValue);
|
|
168
|
+
const offset = offsets[0];
|
|
169
|
+
if (offset === void 0) throw new FsError(`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, "FS_EDIT_NOT_FOUND");
|
|
170
|
+
if (offsets.length > 1) throw new FsError(`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lineNumbersAt(before, offsets).join(", ")}]. Please ensure it is unique`, "FS_AMBIGUOUS_EDIT");
|
|
171
|
+
let outcome;
|
|
172
|
+
try {
|
|
173
|
+
outcome = await ctx.fs.writeText(target, before.slice(0, offset) + newValue + before.slice(offset + oldValue.length), intent === void 0 ? {
|
|
174
|
+
kind: "replaceIfVersion",
|
|
175
|
+
version: info.version
|
|
176
|
+
} : {
|
|
177
|
+
kind: "replaceIfVersion",
|
|
178
|
+
version: intent.version
|
|
179
|
+
}, exec.signal, sandboxPolicy);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
182
|
+
}
|
|
183
|
+
ctx.emit("fs/observed", target, {
|
|
184
|
+
kind: "present",
|
|
185
|
+
version: outcome.version
|
|
186
|
+
}, exec);
|
|
187
|
+
return `The file ${target.displayPath} has been edited successfully.`;
|
|
188
|
+
}
|
|
189
|
+
async function insertInFile(ctx, policy, path, insertLine, newStr, exec) {
|
|
190
|
+
if (insertLine === void 0) throw new Error("Parameter `insert_line` is required for command: insert");
|
|
191
|
+
const value = requiredForCommand(newStr, "new_str", "insert");
|
|
192
|
+
const sandboxPolicy = policy.resolve(exec);
|
|
193
|
+
const target = await resolveTarget(ctx, path, exec.signal);
|
|
194
|
+
const intent = await ctx.waterfall("fs/edit-intent", target, exec, () => void 0);
|
|
195
|
+
const info = await statExisting(ctx, target, "insert", exec);
|
|
196
|
+
if (info.type !== "file") throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
197
|
+
const lines = (await ctx.fs.readText(target, exec.signal)).split("\n");
|
|
198
|
+
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) throw new Error(`Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`);
|
|
199
|
+
const after = [
|
|
200
|
+
...lines.slice(0, insertLine),
|
|
201
|
+
...value.split("\n"),
|
|
202
|
+
...lines.slice(insertLine)
|
|
203
|
+
].join("\n");
|
|
204
|
+
const expected = intent === void 0 ? {
|
|
205
|
+
kind: "replaceIfVersion",
|
|
206
|
+
version: info.version
|
|
207
|
+
} : {
|
|
208
|
+
kind: "replaceIfVersion",
|
|
209
|
+
version: intent.version
|
|
210
|
+
};
|
|
211
|
+
let outcome;
|
|
212
|
+
try {
|
|
213
|
+
outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw policy.mapError(error, sandboxPolicy);
|
|
216
|
+
}
|
|
217
|
+
ctx.emit("fs/observed", target, {
|
|
218
|
+
kind: "present",
|
|
219
|
+
version: outcome.version
|
|
220
|
+
}, exec);
|
|
221
|
+
return `The file ${target.displayPath} has been edited successfully.`;
|
|
222
|
+
}
|
|
223
|
+
function presentEditorCall(args) {
|
|
224
|
+
switch (args.command) {
|
|
225
|
+
case "view": return {
|
|
226
|
+
card: "generic",
|
|
227
|
+
title: `view ${args.path}`,
|
|
228
|
+
kind: "read",
|
|
229
|
+
locations: [{ path: args.path }]
|
|
230
|
+
};
|
|
231
|
+
case "create": return {
|
|
232
|
+
card: "diff",
|
|
233
|
+
title: `create ${args.path}`,
|
|
234
|
+
diffs: [{
|
|
235
|
+
path: args.path,
|
|
236
|
+
oldText: null,
|
|
237
|
+
newText: args.file_text ?? ""
|
|
238
|
+
}],
|
|
239
|
+
locations: [{ path: args.path }]
|
|
240
|
+
};
|
|
241
|
+
case "str_replace": return {
|
|
242
|
+
card: "diff",
|
|
243
|
+
title: `str_replace ${args.path}`,
|
|
244
|
+
diffs: [{
|
|
245
|
+
path: args.path,
|
|
246
|
+
oldText: args.old_str ?? null,
|
|
247
|
+
newText: args.new_str ?? ""
|
|
248
|
+
}],
|
|
249
|
+
locations: [{ path: args.path }]
|
|
250
|
+
};
|
|
251
|
+
case "insert": return {
|
|
252
|
+
card: "generic",
|
|
253
|
+
title: `insert ${args.path}`,
|
|
254
|
+
kind: "edit",
|
|
255
|
+
locations: [{
|
|
256
|
+
path: args.path,
|
|
257
|
+
...args.insert_line === void 0 || args.insert_line === null ? {} : { line: Math.max(1, args.insert_line + 1) }
|
|
258
|
+
}]
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Register the model-facing `str_replace_editor` tool. */
|
|
263
|
+
function registerStrReplaceEditor(ctx, config) {
|
|
264
|
+
const policy = new MutationPolicy(ctx);
|
|
265
|
+
ctx.tools.register(defineTool({
|
|
266
|
+
name: "str_replace_editor",
|
|
267
|
+
description: config.description,
|
|
268
|
+
parameters: {
|
|
269
|
+
command: {
|
|
270
|
+
type: "string",
|
|
271
|
+
required: true,
|
|
272
|
+
enum: [
|
|
273
|
+
"view",
|
|
274
|
+
"create",
|
|
275
|
+
"str_replace",
|
|
276
|
+
"insert"
|
|
277
|
+
],
|
|
278
|
+
description: "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`."
|
|
279
|
+
},
|
|
280
|
+
path: {
|
|
281
|
+
type: "string",
|
|
282
|
+
required: true,
|
|
283
|
+
description: "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
|
284
|
+
},
|
|
285
|
+
file_text: {
|
|
286
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
287
|
+
description: "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
|
288
|
+
},
|
|
289
|
+
insert_line: {
|
|
290
|
+
oneOf: [{ type: "integer" }, { type: "null" }],
|
|
291
|
+
description: "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
|
292
|
+
},
|
|
293
|
+
new_str: {
|
|
294
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
295
|
+
description: "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
|
296
|
+
},
|
|
297
|
+
old_str: {
|
|
298
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
299
|
+
description: "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
|
300
|
+
},
|
|
301
|
+
view_range: {
|
|
302
|
+
oneOf: [{
|
|
303
|
+
type: "array",
|
|
304
|
+
items: { type: "integer" }
|
|
305
|
+
}, { type: "null" }],
|
|
306
|
+
description: "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
output: {
|
|
310
|
+
schema: { type: "string" },
|
|
311
|
+
render: (_args, value) => [{
|
|
312
|
+
type: "text",
|
|
313
|
+
text: value
|
|
314
|
+
}]
|
|
315
|
+
},
|
|
316
|
+
async execute(args, exec) {
|
|
317
|
+
switch (args.command) {
|
|
318
|
+
case "view": return viewPath(ctx, args.path, args.view_range ?? void 0, config.maxOutputChars, exec);
|
|
319
|
+
case "create": return createFile(ctx, policy, args.path, args.file_text ?? void 0, exec);
|
|
320
|
+
case "str_replace": return replaceInFile(ctx, policy, args.path, args.old_str ?? void 0, args.new_str, exec);
|
|
321
|
+
case "insert": return insertInFile(ctx, policy, args.path, args.insert_line ?? void 0, args.new_str ?? void 0, exec);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
presentCall: presentEditorCall
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
const name = "tool-str-replace-editor";
|
|
328
|
+
const inject = ["tools", "fs"];
|
|
329
|
+
/** Runtime configuration schema for the string-replacement editor tool. */
|
|
330
|
+
const Config = z.object({
|
|
331
|
+
maxOutputChars: z.number().default(16e3),
|
|
332
|
+
description: z.string().default(DEFAULT_DESCRIPTION)
|
|
333
|
+
});
|
|
334
|
+
/** Register one `str_replace_editor` tool over `ctx.fs`. */
|
|
335
|
+
function apply(ctx, config) {
|
|
336
|
+
const resolved = {
|
|
337
|
+
maxOutputChars: config.maxOutputChars ?? 16e3,
|
|
338
|
+
description: config.description ?? DEFAULT_DESCRIPTION
|
|
339
|
+
};
|
|
340
|
+
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) throw new Error("tool-str-replace-editor: maxOutputChars must be a positive safe integer");
|
|
341
|
+
if (resolved.description.trim().length === 0) throw new Error("tool-str-replace-editor: description must be non-empty");
|
|
342
|
+
registerStrReplaceEditor(ctx, resolved);
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
export { Config, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-str-replace-editor`.
|
|
4
|
+
* @module @hydraharness/harness-tool-str-replace-editor/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-tool-str-replace-editor";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "tool-str-replace-editor-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the tool adapter owns no independent durable state;
|
|
13
|
+
* filesystem mutation relations stay with the provider and policy plugins.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing `str_replace_editor` over the Harness filesystem seam.
|
|
3
|
+
* @module @hydraharness/harness-tool-str-replace-editor
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
import z from '@hydraharness/schemastery';
|
|
7
|
+
export declare const name = "tool-str-replace-editor";
|
|
8
|
+
export declare const inject: string[];
|
|
9
|
+
/** Configuration for the string-replacement editor tool. */
|
|
10
|
+
export interface Config {
|
|
11
|
+
/** Maximum returned view characters before clipping (default 16000). */
|
|
12
|
+
maxOutputChars?: number;
|
|
13
|
+
/** Model-facing tool description. */
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Runtime configuration schema for the string-replacement editor tool. */
|
|
17
|
+
export declare const Config: z<Config>;
|
|
18
|
+
/** Register one `str_replace_editor` tool over `ctx.fs`. */
|
|
19
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-tool-str-replace-editor`.
|
|
3
|
+
* @module @hydraharness/harness-tool-str-replace-editor/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "tool-str-replace-editor-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydraharness/harness-tool-str-replace-editor",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
3
|
+
"description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Let the agent view files, create files, replace exact text, or insert lines."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.7",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/fs/tool-str-replace-editor"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"lib/index.js",
|
|
34
|
+
"lib/invariant.js",
|
|
35
|
+
"lib/types/**/*.d.ts"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@hydraharness/harness-fs": "^0.1.1-rc.7",
|
|
40
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
41
|
+
"@hydraharness/harness-sandbox": "^0.1.1-rc.7",
|
|
42
|
+
"@hydraharness/harness-sandbox-policy": "^0.1.1-rc.7",
|
|
43
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
44
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.7"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@hydraharness/schemastery": "^3.18.2"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@hydraharness/harness-agent": "^0.1.1-rc.7",
|
|
51
|
+
"@hydraharness/harness-fs": "^0.1.1-rc.7",
|
|
52
|
+
"@hydraharness/harness-fs-local": "^0.1.1-rc.7",
|
|
53
|
+
"@hydraharness/harness-fs-sandbox": "^0.1.1-rc.7",
|
|
54
|
+
"@hydraharness/harness-fs-observation-policy": "^0.1.1-rc.7",
|
|
55
|
+
"@hydraharness/harness-llm": "^0.1.1-rc.7",
|
|
56
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.7",
|
|
57
|
+
"@hydraharness/harness-sandbox": "^0.1.1-rc.7",
|
|
58
|
+
"@hydraharness/harness-system-prompt": "^0.1.1-rc.7",
|
|
59
|
+
"@hydraharness/harness-sandbox-policy": "^0.1.1-rc.7",
|
|
60
|
+
"@hydraharness/harness-session": "^0.1.1-rc.7",
|
|
61
|
+
"@hydraharness/harness-tools": "^0.1.1-rc.7",
|
|
62
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
63
|
+
}
|
|
6
64
|
}
|