@sema-agent/core 2.1.0 → 2.3.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/observer.d.ts +14 -0
- package/dist/agents/observer.js +58 -9
- package/dist/agents/send-message-tool.js +55 -10
- package/dist/agents/subagent.d.ts +1 -0
- package/dist/agents/subagent.js +69 -15
- package/dist/agents/teacher.js +51 -23
- package/dist/core/context-edit.js +16 -3
- package/dist/core/file-snapshot-store.js +10 -1
- package/dist/core/runner/assemble-result.d.ts +1 -0
- package/dist/core/runner/assemble-result.js +12 -8
- package/dist/core/runner/prepare-task.d.ts +1 -0
- package/dist/core/runner/prepare-task.js +40 -10
- package/dist/core/runner/runtask.js +27 -5
- package/dist/core/runner/synthetic-tools.d.ts +1 -0
- package/dist/core/runner/synthetic-tools.js +18 -15
- package/dist/core/runner/turn-attachments.d.ts +12 -2
- package/dist/core/runner/turn-attachments.js +33 -3
- package/dist/core/task-registry-agent.d.ts +11 -1
- package/dist/core/task-registry-agent.js +40 -3
- package/dist/core/task-registry-monitor.js +149 -29
- package/dist/core/task-registry-shared.d.ts +25 -2
- package/dist/core/task-registry-shared.js +26 -2
- package/dist/core/task-registry.d.ts +5 -0
- package/dist/core/task-registry.js +25 -26
- package/dist/core/tools.d.ts +2 -0
- package/dist/core/tools.js +9 -0
- package/dist/core/types.d.ts +3 -1
- package/dist/core/workflow-journal-store.d.ts +16 -0
- package/dist/core/workflow-journal-store.js +28 -0
- package/dist/engine/session/memory-repo.js +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow-size-guideline.d.ts +6 -1
- package/dist/orchestration/workflow-size-guideline.js +19 -9
- package/dist/orchestration/workflow.d.ts +16 -0
- package/dist/orchestration/workflow.js +80 -20
- package/dist/prompt-assembly/assemble.js +3 -7
- package/dist/prompt-assembly/packs/sema-default.js +8 -5
- package/dist/prompts/coordinator.d.ts +1 -1
- package/dist/prompts/coordinator.js +45 -0
- package/dist/prompts/default.d.ts +4 -5
- package/dist/prompts/default.js +16 -18
- package/dist/prompts/simple-sections.d.ts +3 -1
- package/dist/prompts/simple-sections.js +11 -1
- package/dist/stores/file/workflow-journal-store.d.ts +7 -1
- package/dist/stores/file/workflow-journal-store.js +70 -33
- package/dist/tools/fs/bash-readonly-classifier.js +20 -1
- package/dist/tools/fs/fs-bash.d.ts +1 -0
- package/dist/tools/fs/fs-bash.js +10 -3
- package/dist/tools/fs/fs-read.js +10 -10
- package/dist/tools/fs/fs-search-tools.js +42 -7
- package/dist/tools/fs/fs-write.js +18 -6
- package/dist/tools/fs/index.d.ts +1 -0
- package/dist/tools/fs/index.js +2 -1
- package/dist/tools/fs/safety.d.ts +4 -0
- package/dist/tools/fs/safety.js +102 -6
- package/dist/tools/fs/search.d.ts +1 -0
- package/dist/tools/fs/search.js +23 -3
- package/dist/tools/monitor.d.ts +2 -0
- package/dist/tools/monitor.js +3 -1
- package/package.json +3 -2
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { callKeyOrdinal } from "../../core/workflow-journal-store.js";
|
|
5
5
|
import { AppendLog } from "./fs-atomic.js";
|
|
6
|
-
import { canonicalStoreKey, sanitizePathComponent } from "./fs-atomic.js";
|
|
6
|
+
import { canonicalStoreKey, sanitizePathComponent, writeThenLink } from "./fs-atomic.js";
|
|
7
7
|
import { oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
8
8
|
export { MAX_JOURNAL_RESULT_BYTES, oversizeJournalResult } from "../../core/workflow-journal-store.js";
|
|
9
9
|
export const RESUME_CLAIM_TTL_MS = 60 * 60 * 1000;
|
|
10
|
+
export const RESUME_CLAIM_RELEASE_GRACE_MS = 5_000;
|
|
11
|
+
export const RESUME_CLAIM_SWEEP_INTERVAL_MS = 60_000;
|
|
12
|
+
export const RESUME_CLAIM_REAP_GRACE_MS = 5 * 60_000;
|
|
10
13
|
const sharedJournalDirs = new Map();
|
|
11
14
|
const MAX_OPEN_JOURNAL_LOGS = 64;
|
|
12
15
|
export class FileWorkflowJournalStore {
|
|
@@ -16,6 +19,7 @@ export class FileWorkflowJournalStore {
|
|
|
16
19
|
shared;
|
|
17
20
|
sharedKey;
|
|
18
21
|
closed = false;
|
|
22
|
+
lastClaimSweepAt = 0;
|
|
19
23
|
get logs() {
|
|
20
24
|
return this.shared.logs;
|
|
21
25
|
}
|
|
@@ -122,20 +126,63 @@ export class FileWorkflowJournalStore {
|
|
|
122
126
|
const scopeTag = createHash("sha256").update(scope).digest("hex").slice(0, 16);
|
|
123
127
|
return join(this.claimsDir, `${sanitizePathComponent(sourceRunId)}.${scopeTag}.json`);
|
|
124
128
|
}
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
readClaimRaw(path) {
|
|
130
|
+
let raw;
|
|
131
|
+
try {
|
|
132
|
+
raw = readFileSync(path, "utf8");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
127
135
|
return undefined;
|
|
136
|
+
}
|
|
128
137
|
try {
|
|
129
|
-
const rec = JSON.parse(
|
|
138
|
+
const rec = JSON.parse(raw);
|
|
130
139
|
if (typeof rec.holder !== "string" || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
|
|
131
140
|
return undefined;
|
|
132
141
|
}
|
|
133
|
-
return rec;
|
|
142
|
+
return { raw, rec };
|
|
134
143
|
}
|
|
135
144
|
catch {
|
|
136
145
|
return undefined;
|
|
137
146
|
}
|
|
138
147
|
}
|
|
148
|
+
unlinkIfUnchanged(path, expectedRaw) {
|
|
149
|
+
try {
|
|
150
|
+
if (expectedRaw !== undefined && readFileSync(path, "utf8") !== expectedRaw)
|
|
151
|
+
return;
|
|
152
|
+
unlinkSync(path);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
sweepExpiredClaims() {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (now - this.lastClaimSweepAt < RESUME_CLAIM_SWEEP_INTERVAL_MS)
|
|
160
|
+
return;
|
|
161
|
+
this.lastClaimSweepAt = now;
|
|
162
|
+
let names;
|
|
163
|
+
try {
|
|
164
|
+
names = readdirSync(this.claimsDir);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
const path = join(this.claimsDir, name);
|
|
171
|
+
try {
|
|
172
|
+
const existing = this.readClaimRaw(path);
|
|
173
|
+
if (existing === undefined) {
|
|
174
|
+
if (now - statSync(path).mtimeMs > RESUME_CLAIM_TTL_MS)
|
|
175
|
+
unlinkSync(path);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (existing.rec.expiresAt + RESUME_CLAIM_REAP_GRACE_MS > now)
|
|
179
|
+
continue;
|
|
180
|
+
this.unlinkIfUnchanged(path, existing.raw);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
139
186
|
async resumeClaim(input) {
|
|
140
187
|
const { sourceRunId, newRunId, scope } = input;
|
|
141
188
|
let path;
|
|
@@ -146,40 +193,32 @@ export class FileWorkflowJournalStore {
|
|
|
146
193
|
return { granted: true };
|
|
147
194
|
}
|
|
148
195
|
mkdirSync(this.claimsDir, { recursive: true, mode: 0o700 });
|
|
196
|
+
this.sweepExpiredClaims();
|
|
197
|
+
const record = { sourceRunId, holder: newRunId, expiresAt: Date.now() + RESUME_CLAIM_TTL_MS };
|
|
198
|
+
const payload = JSON.stringify(record);
|
|
149
199
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
150
|
-
const record = { sourceRunId, holder: newRunId, expiresAt: Date.now() + RESUME_CLAIM_TTL_MS };
|
|
151
200
|
try {
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
writeSync(fd, JSON.stringify(record));
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
closeSync(fd);
|
|
158
|
-
}
|
|
201
|
+
writeThenLink(path, payload);
|
|
159
202
|
return { granted: true };
|
|
160
203
|
}
|
|
161
204
|
catch (err) {
|
|
162
205
|
if (err.code !== "EEXIST")
|
|
163
206
|
throw err;
|
|
164
207
|
}
|
|
165
|
-
const existing = this.
|
|
166
|
-
if (existing !== undefined && existing.holder === newRunId) {
|
|
208
|
+
const existing = this.readClaimRaw(path);
|
|
209
|
+
if (existing !== undefined && existing.rec.holder === newRunId) {
|
|
167
210
|
return { granted: true };
|
|
168
211
|
}
|
|
169
|
-
if (existing === undefined || existing.expiresAt <= Date.now()) {
|
|
170
|
-
|
|
171
|
-
unlinkSync(path);
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
}
|
|
212
|
+
if (existing === undefined || existing.rec.expiresAt <= Date.now()) {
|
|
213
|
+
this.unlinkIfUnchanged(path, existing?.raw);
|
|
175
214
|
continue;
|
|
176
215
|
}
|
|
177
|
-
return { granted: false, holder: existing.holder };
|
|
216
|
+
return { granted: false, holder: existing.rec.holder };
|
|
178
217
|
}
|
|
179
|
-
const winner = this.
|
|
180
|
-
if (winner
|
|
218
|
+
const winner = this.readClaimRaw(path);
|
|
219
|
+
if (winner !== undefined && winner.rec.holder === newRunId)
|
|
181
220
|
return { granted: true };
|
|
182
|
-
return { granted: false, holder: winner.holder };
|
|
221
|
+
return { granted: false, holder: winner?.rec.holder };
|
|
183
222
|
}
|
|
184
223
|
async releaseResumeClaim(input) {
|
|
185
224
|
let path;
|
|
@@ -189,14 +228,12 @@ export class FileWorkflowJournalStore {
|
|
|
189
228
|
catch {
|
|
190
229
|
return;
|
|
191
230
|
}
|
|
192
|
-
const existing = this.
|
|
193
|
-
if (existing === undefined || existing.holder !== input.newRunId)
|
|
231
|
+
const existing = this.readClaimRaw(path);
|
|
232
|
+
if (existing === undefined || existing.rec.holder !== input.newRunId)
|
|
194
233
|
return;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
catch {
|
|
199
|
-
}
|
|
234
|
+
if (existing.rec.expiresAt - Date.now() <= RESUME_CLAIM_RELEASE_GRACE_MS)
|
|
235
|
+
return;
|
|
236
|
+
this.unlinkIfUnchanged(path, existing.raw);
|
|
200
237
|
}
|
|
201
238
|
async deleteByRun(runId) {
|
|
202
239
|
const log = this.logs.get(runId);
|
|
@@ -31,6 +31,24 @@ export function coarseReadonlyCheck(command, allow) {
|
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
|
|
34
|
+
function foldQuoteRemovalToken(tok) {
|
|
35
|
+
if (!tok.includes('"') && !tok.includes("'"))
|
|
36
|
+
return tok;
|
|
37
|
+
let out = "";
|
|
38
|
+
let open;
|
|
39
|
+
for (const ch of tok) {
|
|
40
|
+
if (open === undefined && (ch === '"' || ch === "'")) {
|
|
41
|
+
open = ch;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (open === ch) {
|
|
45
|
+
open = undefined;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += ch;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
34
52
|
export function classifyCompoundReadonly(command, allow) {
|
|
35
53
|
const trimmed = command.trim();
|
|
36
54
|
if (!trimmed)
|
|
@@ -111,7 +129,8 @@ export function classifyCompoundReadonly(command, allow) {
|
|
|
111
129
|
};
|
|
112
130
|
const STDIN_FILE_FLOOR = { cat: 1, grep: 2, head: 1, tail: 1, wc: 1, cut: 1, tr: Infinity };
|
|
113
131
|
for (let si = 0; si < segments.length; si++) {
|
|
114
|
-
const toks = segments[si].trim().split(/\s+/).filter((t) => t.length > 0)
|
|
132
|
+
const toks = segments[si].trim().split(/\s+/).filter((t) => t.length > 0)
|
|
133
|
+
.map(foldQuoteRemovalToken);
|
|
115
134
|
if (toks.length === 0)
|
|
116
135
|
continue;
|
|
117
136
|
const name = toks[0];
|
|
@@ -25,6 +25,7 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
|
|
|
25
25
|
detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
|
|
26
26
|
execClamp?: ExecClampOption;
|
|
27
27
|
autoBackgroundOnTimeout?: boolean;
|
|
28
|
+
oneShot?: boolean;
|
|
28
29
|
}): AgentTool;
|
|
29
30
|
export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption): AgentTool;
|
|
30
31
|
export declare function createEnvTaskOutputTool(env: ExecutionEnv): AgentTool;
|
package/dist/tools/fs/fs-bash.js
CHANGED
|
@@ -461,6 +461,11 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
461
461
|
const interimNote = outputFile !== undefined
|
|
462
462
|
? ` Output file: ${outputFile} (full output is appended there — Read it any time).`
|
|
463
463
|
: ` Use TaskOutput("${taskId}") to check interim output.`;
|
|
464
|
+
if (taskOpts.oneShot === true && onNotify !== undefined) {
|
|
465
|
+
return (`Command running in background; task_id=${taskId}.${interimNote} This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }). If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget. TaskStop("${taskId}") to stop.` +
|
|
466
|
+
budgetNote +
|
|
467
|
+
lifetimeNote);
|
|
468
|
+
}
|
|
464
469
|
return onNotify !== undefined
|
|
465
470
|
? `Command running in background; task_id=${taskId}.${interimNote} You will be notified when it completes — do not poll; ` +
|
|
466
471
|
`end your turn if nothing else is pending. TaskStop("${taskId}") to stop.` +
|
|
@@ -554,9 +559,11 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
|
|
|
554
559
|
? `Output file: ${outputFile} (full output is appended there — Read it any time). `
|
|
555
560
|
: `Use TaskOutput("${taskId}") to read its output. `) +
|
|
556
561
|
`TaskStop("${taskId}") to stop it. ` +
|
|
557
|
-
(onNotify !== undefined
|
|
558
|
-
? `
|
|
559
|
-
:
|
|
562
|
+
(taskOpts.oneShot === true && onNotify !== undefined
|
|
563
|
+
? `This is a ONE-SHOT submission — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: TaskOutput("${taskId}", { block: true }).`
|
|
564
|
+
: onNotify !== undefined
|
|
565
|
+
? `You will be notified when it completes — do not poll.`
|
|
566
|
+
: `Poll TaskOutput until its status is no longer "running".`) +
|
|
560
567
|
adoptBudgetNote +
|
|
561
568
|
(tail ? `\n--- output so far (tail) ---\n${tail}` : ""),
|
|
562
569
|
details: { type: "bash", detached: true, task_id: taskId, ...(description !== undefined ? { description } : {}), ...(outputFile !== undefined ? { output_file: outputFile } : {}), ...(cause === "timeout" ? { autoBackgrounded: true } : {}) },
|
package/dist/tools/fs/fs-read.js
CHANGED
|
@@ -15,10 +15,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
15
15
|
"\n" +
|
|
16
16
|
"- `file_path` may be relative (resolved against the tracked working directory) or absolute (within the configured roots).\n" +
|
|
17
17
|
"- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call.\n" +
|
|
18
|
-
"-
|
|
18
|
+
"- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" +
|
|
19
19
|
"- Results are returned using cat -n format, with line numbers starting at 1\n" +
|
|
20
20
|
"- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
|
|
21
|
-
"- Reading a directory, a missing file, or an empty file returns an error or
|
|
21
|
+
"- Reading a directory, a missing file, or an empty file returns an error or system reminder rather than content.\n" +
|
|
22
22
|
"- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
|
|
23
23
|
"- You must read a file before editing it.\n" +
|
|
24
24
|
"- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
|
|
@@ -26,7 +26,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
26
26
|
"\n" +
|
|
27
27
|
"Usage:\n" +
|
|
28
28
|
"- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call\n" +
|
|
29
|
-
"-
|
|
29
|
+
"- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" +
|
|
30
30
|
"- Results are returned using cat -n format, with each line prefixed by its line number and a tab\n" +
|
|
31
31
|
"- This tool allows the model to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually.\n" +
|
|
32
32
|
"- This tool can only read files, not directories. To read a directory, use Glob/Grep or an ls command via the Bash tool.\n" +
|
|
@@ -34,7 +34,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
34
34
|
"- Reads PDFs (.pdf): the document is provided to the model directly (text layer included). Use the `pages` parameter (e.g., \"1-5\") to read a page range; a PDF whose detected page count exceeds 10 requires `pages`. Maximum 20 pages per request.\n" +
|
|
35
35
|
"- Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
|
|
36
36
|
"- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
|
|
37
|
-
"- If you read a file that exists but has empty contents you will receive a warning in place of file contents.\n" +
|
|
37
|
+
"- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.\n" +
|
|
38
38
|
"- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
|
|
39
39
|
parameters: Type.Object({
|
|
40
40
|
...FILE_PATH_PARAMS,
|
|
@@ -129,7 +129,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
129
129
|
`(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
130
130
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
131
131
|
}
|
|
132
|
-
if (!isNb && info.value.size > MAX_READ_BYTES &&
|
|
132
|
+
if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
|
|
133
133
|
return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
134
134
|
}
|
|
135
135
|
}
|
|
@@ -147,7 +147,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
147
147
|
`(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
|
|
148
148
|
`Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
|
|
149
149
|
}
|
|
150
|
-
if (!isNb && readSize > MAX_READ_BYTES &&
|
|
150
|
+
if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
|
|
151
151
|
return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
|
|
152
152
|
}
|
|
153
153
|
if (pdfMagicMatches(readBin.value)) {
|
|
@@ -158,7 +158,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
158
158
|
return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
|
|
159
159
|
}
|
|
160
160
|
const content = decoded.text;
|
|
161
|
-
if (isBinaryContent(content.slice(0,
|
|
161
|
+
if (isBinaryContent(content.slice(0, 8192))) {
|
|
162
162
|
return errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
|
|
163
163
|
`If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`);
|
|
164
164
|
}
|
|
@@ -193,10 +193,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
193
193
|
` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`);
|
|
194
194
|
}
|
|
195
195
|
const prevNb = state.get(r.key);
|
|
196
|
-
if (prevNb?.seededFromContext && prevNb.hash === hash) {
|
|
196
|
+
if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
|
|
197
197
|
return seededFileUnchangedReminder(r.key);
|
|
198
198
|
}
|
|
199
|
-
if (prevNb && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
|
|
199
|
+
if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
|
|
200
200
|
return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
|
|
201
201
|
}
|
|
202
202
|
state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
|
|
@@ -265,7 +265,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
265
265
|
lastReadAt: Date.now(),
|
|
266
266
|
});
|
|
267
267
|
if (total === 0)
|
|
268
|
-
return `<system-reminder>Warning: the file exists but
|
|
268
|
+
return `<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>`;
|
|
269
269
|
const header = pageMarker ?? (truncated ? `[${path}: lines ${start}-${end} of ${total}${end < total ? " — use offset to see more" : ""}]\n` : "");
|
|
270
270
|
return {
|
|
271
271
|
content: `${nbFallbackPrefix}${header}${body}${READ_CYBER_REMINDER}`,
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
3
3
|
import { resolveKey, violationText } from "./safety.js";
|
|
4
|
-
import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern } from "./search.js";
|
|
4
|
+
import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
|
|
5
5
|
export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
6
6
|
return defineTool({
|
|
7
7
|
name: "Grep",
|
|
8
8
|
contract: { contractId: "core.grep@1", implementationRevision: "1" },
|
|
9
9
|
description: "Content search built on ripgrep. Prefer this over `grep`/`rg` via Bash — results integrate with the permission UI and file links.\n" +
|
|
10
|
+
"\n" +
|
|
10
11
|
'- Full regex syntax (e.g. "log.*Error", "function\\s+\\w+"). Ripgrep, not grep — escape literal braces (`interface\\{\\}`).\n' +
|
|
11
12
|
'- Filter with `glob` (e.g. "**/*.tsx") or `type` (e.g. "js", "py", "rust").\n' +
|
|
12
13
|
'- `output_mode`: "content" (matching lines), "files_with_matches" (paths only, default), or "count".\n' +
|
|
13
14
|
"- `multiline: true` for patterns that span lines.\n" +
|
|
14
15
|
"- Uses ripgrep when available (respects .gitignore, skips binary files and VCS directories), otherwise a JS fallback that skips node_modules/build/… and parses .gitignore. Hidden files/directories ARE searched.\n" +
|
|
15
|
-
"- Use Agent tool for open-ended searches requiring multiple rounds",
|
|
16
|
+
"- Use Agent tool (if available) for open-ended searches requiring multiple rounds",
|
|
16
17
|
parameters: Type.Object({
|
|
17
18
|
pattern: Type.String({ description: "The regular expression pattern to search for in file contents" }),
|
|
18
|
-
path: Type.Optional(Type.String({ description: "
|
|
19
|
+
path: Type.Optional(Type.String({ description: "File or directory to search in (rg PATH). Relative paths resolve against the root; defaults to the whole root." })),
|
|
19
20
|
glob: Type.Optional(Type.String({ description: 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")' })),
|
|
20
21
|
type: Type.Optional(Type.String({ description: 'File type to search (rg --type): "js", "py", "rust", "go", "java", etc. More efficient than glob for standard file types.' })),
|
|
21
22
|
output_mode: Type.Optional(Type.Union([Type.Literal("content"), Type.Literal("files_with_matches"), Type.Literal("count")], {
|
|
@@ -41,6 +42,23 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
41
42
|
effect: "read",
|
|
42
43
|
execute: async (args, ctx) => {
|
|
43
44
|
const a = args;
|
|
45
|
+
for (const [name, value] of [
|
|
46
|
+
["head_limit", a.head_limit],
|
|
47
|
+
["offset", a.offset],
|
|
48
|
+
]) {
|
|
49
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
|
50
|
+
return errorResult(`Error (Grep): ${name} must be a whole number of 0 or more, got ${value}.${name === "head_limit" ? " Pass 0 for unlimited." : ""}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (typeof a.glob === "string" && a.glob.length > 0) {
|
|
54
|
+
const badGlobs = invalidGlobTokens(a.glob);
|
|
55
|
+
if (badGlobs.length > 0) {
|
|
56
|
+
return errorResult(`Error (Grep): rejected the glob ${badGlobs.map((g) => JSON.stringify(g)).join(", ")} without searching — ` +
|
|
57
|
+
`an unterminated \`[\` class or \`{\` group cannot be compiled as a glob, and matching it literally would ` +
|
|
58
|
+
`silently narrow the search to a file with that exact name (an empty result would NOT mean "no matches"). ` +
|
|
59
|
+
`Close the bracket/brace, or drop \`glob\` and filter with \`path\`/\`type\` instead.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
44
62
|
let scoped = a.path;
|
|
45
63
|
if (a.path !== undefined) {
|
|
46
64
|
const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
|
|
@@ -77,9 +95,14 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
77
95
|
const m = /^(.*?):(\d+):/.exec(l);
|
|
78
96
|
return m ? m[1] : l;
|
|
79
97
|
};
|
|
98
|
+
const capMarker = /^…\[capped at (\d+) of (\d+)\]$/m.exec(text);
|
|
99
|
+
const cappedAt = capMarker ? Number(capMarker[1]) : undefined;
|
|
100
|
+
const capTotal = capMarker ? Number(capMarker[2]) : undefined;
|
|
101
|
+
const appliedOffset = typeof a.offset === "number" && a.offset > 0 ? { appliedOffset: a.offset } : {};
|
|
102
|
+
const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
|
|
80
103
|
let detailFields;
|
|
81
104
|
if (mode === "files_with_matches") {
|
|
82
|
-
detailFields = { filenames: rows, numFiles: rows.length };
|
|
105
|
+
detailFields = { filenames: rows, numFiles: rows.length, totalFiles: capTotal ?? rows.length, ...appliedLimit, ...appliedOffset };
|
|
83
106
|
}
|
|
84
107
|
else if (mode === "count") {
|
|
85
108
|
const filenames = [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
|
|
@@ -92,7 +115,14 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
92
115
|
else
|
|
93
116
|
malformed = true;
|
|
94
117
|
}
|
|
95
|
-
detailFields = {
|
|
118
|
+
detailFields = {
|
|
119
|
+
filenames,
|
|
120
|
+
numFiles: filenames.length,
|
|
121
|
+
content: rows.join("\n"),
|
|
122
|
+
...(malformed ? {} : { numMatches }),
|
|
123
|
+
...appliedLimit,
|
|
124
|
+
...appliedOffset,
|
|
125
|
+
};
|
|
96
126
|
}
|
|
97
127
|
else {
|
|
98
128
|
const filenames = [...new Set(rows.map(contentPathOf))];
|
|
@@ -103,6 +133,9 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
|
|
|
103
133
|
numFiles: filenames.length,
|
|
104
134
|
content: joined.length > GREP_CONTENT_PREVIEW_CHARS ? `${joined.slice(0, GREP_CONTENT_PREVIEW_CHARS)}\n…[truncated — full text in the tool output]` : joined,
|
|
105
135
|
numLines: rows.length,
|
|
136
|
+
totalLines: capTotal ?? rows.length,
|
|
137
|
+
...appliedLimit,
|
|
138
|
+
...appliedOffset,
|
|
106
139
|
};
|
|
107
140
|
}
|
|
108
141
|
return {
|
|
@@ -126,10 +159,12 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
126
159
|
"- On environments that do not report modification times, results fall back to alphabetical order\n" +
|
|
127
160
|
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)\n" +
|
|
128
161
|
"- Use this tool when you need to find files by name patterns; use `path` to scope to a sub-directory\n" +
|
|
129
|
-
"- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead",
|
|
162
|
+
"- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead (if available)",
|
|
130
163
|
parameters: Type.Object({
|
|
131
164
|
pattern: Type.String({ description: "Glob pattern (`*`, `**`, `?`)." }),
|
|
132
|
-
path: Type.Optional(Type.String({
|
|
165
|
+
path: Type.Optional(Type.String({
|
|
166
|
+
description: 'Scope the search to this sub-directory (relative to root). IMPORTANT: Omit this field to search the whole root. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
|
|
167
|
+
})),
|
|
133
168
|
max_results: Type.Optional(Type.Number({ description: "Cap results (default 500)." })),
|
|
134
169
|
}),
|
|
135
170
|
effect: "read",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
4
|
-
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
4
|
+
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
5
5
|
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
|
|
6
6
|
import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
|
|
7
7
|
async function gateToolWrite(hook, tool, path, key, content) {
|
|
@@ -54,12 +54,12 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
54
54
|
const path = fileArgPath(args);
|
|
55
55
|
if (path === undefined)
|
|
56
56
|
return errorResult(`Error (Edit): file_path is required.`);
|
|
57
|
-
const ipynb = ipynbRedirect("Edit", path);
|
|
58
|
-
if (ipynb)
|
|
59
|
-
return errorResult(ipynb);
|
|
60
57
|
const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
|
|
61
58
|
if (!r.ok)
|
|
62
59
|
return errorResult(violationText("Edit", r.violation));
|
|
60
|
+
if (!batch && a.old_string === a.new_string) {
|
|
61
|
+
return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
|
|
62
|
+
}
|
|
63
63
|
const singleOld = batch ? undefined : a.old_string;
|
|
64
64
|
const exists = await env.exists(r.key, ctx.signal);
|
|
65
65
|
if (!exists.ok)
|
|
@@ -132,6 +132,9 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
132
132
|
details: { type: "edit", filePath: path, originalFile: preDec.text, oldString: "", newString: newContent, replaceAll: false, edits: [{ oldString: "", newString: newContent, replaceAll: false }] },
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
const ipynb = ipynbRedirect("Edit", path);
|
|
136
|
+
if (ipynb)
|
|
137
|
+
return errorResult(ipynb);
|
|
135
138
|
const notRead = requireRead(state, r.key);
|
|
136
139
|
if (notRead)
|
|
137
140
|
return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
|
|
@@ -170,9 +173,18 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
|
|
|
170
173
|
oldS = resolved;
|
|
171
174
|
}
|
|
172
175
|
}
|
|
176
|
+
if (oldS !== "" && !working.includes(oldS)) {
|
|
177
|
+
const resolvedEsc = resolveEscapeMatch(working, oldS);
|
|
178
|
+
if (resolvedEsc !== undefined && resolvedEsc !== oldS) {
|
|
179
|
+
newS = adaptNewStringEscapes(oldS, resolvedEsc, newS);
|
|
180
|
+
oldS = resolvedEsc;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
173
183
|
const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
|
|
174
|
-
if (match)
|
|
175
|
-
|
|
184
|
+
if (match) {
|
|
185
|
+
const escNote = match.code === "ambiguous_edit" && escapeMatchWasAttempted(e.old_string) && !working.includes(oldS) ? ESCAPE_MATCH_MISS_NOTE : "";
|
|
186
|
+
return errorResult(batch ? `Error (Edit): ${where}${match.message}${escNote} (no changes written — the batch is atomic).` : `${violationText("Edit", match)}${escNote}`);
|
|
187
|
+
}
|
|
176
188
|
replacements += e.replace_all === true ? countOccurrences(working, oldS) : 1;
|
|
177
189
|
const effOld = e.replace_all === true ? oldS : deletionOldString(working, oldS, newS);
|
|
178
190
|
working = e.replace_all === true ? working.split(effOld).join(newS) : working.replace(effOld, () => newS);
|
package/dist/tools/fs/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface HandsToolkitOptions {
|
|
|
29
29
|
}) => void;
|
|
30
30
|
detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
|
|
31
31
|
execClamp?: ExecClampOption;
|
|
32
|
+
oneShot?: boolean;
|
|
32
33
|
autoBackgroundOnTimeout?: boolean;
|
|
33
34
|
readImageDownsampler?: ReadImageDownsamplerOption;
|
|
34
35
|
pdfModelCapabilities?: PdfModelCapabilities;
|
package/dist/tools/fs/index.js
CHANGED
|
@@ -45,11 +45,12 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
|
|
|
45
45
|
detachHub: opts.detachHub,
|
|
46
46
|
execClamp: opts.execClamp,
|
|
47
47
|
...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
|
|
48
|
+
...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
|
|
48
49
|
}));
|
|
49
50
|
if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
|
|
50
51
|
const sessionAxis = opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {};
|
|
51
52
|
tools.push(opts.taskRegistry
|
|
52
|
-
? createTaskOutputTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis })
|
|
53
|
+
? createTaskOutputTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis, ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}) })
|
|
53
54
|
: createEnvTaskOutputTool(env), opts.taskRegistry
|
|
54
55
|
? createTaskStopTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis })
|
|
55
56
|
: createEnvTaskStopTool(env));
|
|
@@ -53,6 +53,10 @@ export declare function countOccurrences(haystack: string, needle: string): numb
|
|
|
53
53
|
export declare function similarNameSuggestion(siblingNames: readonly string[], missingName: string): string | undefined;
|
|
54
54
|
export declare function normalizeQuotes(s: string): string;
|
|
55
55
|
export declare function resolveQuoteMatch(content: string, oldString: string): string | undefined;
|
|
56
|
+
export declare function resolveEscapeMatch(content: string, oldString: string): string | undefined;
|
|
57
|
+
export declare function adaptNewStringEscapes(oldString: string, matchedOld: string, newString: string): string;
|
|
58
|
+
export declare function escapeMatchWasAttempted(oldString: string): boolean;
|
|
59
|
+
export declare const ESCAPE_MATCH_MISS_NOTE = "\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)";
|
|
56
60
|
export declare function adaptNewStringQuotes(matchedOld: string, newString: string): string;
|
|
57
61
|
export declare function deletionOldString(content: string, oldString: string, newString: string): string;
|
|
58
62
|
export declare function checkEditMatch(content: string, oldString: string, replaceAll: boolean, truncated: boolean): FsViolation | undefined;
|