tinker-agent 1.5.0 → 1.6.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/CHANGELOG.md +48 -1
- package/README.md +13 -5
- package/package.json +7 -5
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/tui-runner.tsx +21 -2
- package/src/events/observation-text-log.ts +21 -0
- package/src/events/stdout-event-printer.ts +11 -0
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +190 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +11 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash.ts +31 -0
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +47 -3
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +301 -134
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +9 -12
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +19 -11
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +75 -3
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +277 -5
- package/src/tui/tui-session-controller.ts +32 -8
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export type BashRisk =
|
|
4
|
+
| { readonly dangerous: false }
|
|
5
|
+
| { readonly dangerous: true; readonly reason: string };
|
|
6
|
+
|
|
7
|
+
export type BashRiskContext = {
|
|
8
|
+
readonly workspaceRoot?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SAFE: BashRisk = Object.freeze({ dangerous: false });
|
|
12
|
+
|
|
13
|
+
export function classifyBashRisk(
|
|
14
|
+
command: string,
|
|
15
|
+
context: BashRiskContext = {},
|
|
16
|
+
): BashRisk {
|
|
17
|
+
const normalized = command.trim();
|
|
18
|
+
if (normalized === "") {
|
|
19
|
+
return SAFE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(normalized)) {
|
|
23
|
+
return dangerous("fork bomb");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const segment of shellSegments(normalized)) {
|
|
27
|
+
const words = shellWords(segment);
|
|
28
|
+
const commandIndex = commandWordIndex(words);
|
|
29
|
+
if (commandIndex === -1) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const name = basename(words[commandIndex] ?? "");
|
|
33
|
+
const args = words.slice(commandIndex + 1);
|
|
34
|
+
|
|
35
|
+
if (["shutdown", "reboot", "halt", "poweroff"].includes(name)) {
|
|
36
|
+
return dangerous(`system power command ${name}`);
|
|
37
|
+
}
|
|
38
|
+
if (name === "wipefs" || name.startsWith("mkfs.")) {
|
|
39
|
+
return dangerous(`block-device command ${name}`);
|
|
40
|
+
}
|
|
41
|
+
if (name === "dd" && args.some((word) => /^of=\/dev\/[^/]/.test(word))) {
|
|
42
|
+
return dangerous("dd writes directly to a device");
|
|
43
|
+
}
|
|
44
|
+
if ((name === "chmod" || name === "chown") && hasRecursiveFlag(args)) {
|
|
45
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
46
|
+
if (operands.at(-1) === "/") {
|
|
47
|
+
return dangerous(`${name} recursively targets the filesystem root`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (name === "rm" && hasRecursiveFlag(args) && hasForceFlag(args)) {
|
|
51
|
+
const operands = args.filter((word) => !word.startsWith("-"));
|
|
52
|
+
if (
|
|
53
|
+
operands.some((target) => isDestructiveRmTarget(target, context.workspaceRoot))
|
|
54
|
+
) {
|
|
55
|
+
return dangerous("recursive forced removal targets a protected root");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return SAFE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dangerous(reason: string): BashRisk {
|
|
64
|
+
return Object.freeze({ dangerous: true, reason });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shellSegments(command: string): string[] {
|
|
68
|
+
return command.split(/(?:&&|\|\||[;|\n])/);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function shellWords(segment: string): string[] {
|
|
72
|
+
return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g)?.map(unquote) ?? [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unquote(word: string): string {
|
|
76
|
+
if (
|
|
77
|
+
(word.startsWith('"') && word.endsWith('"')) ||
|
|
78
|
+
(word.startsWith("'") && word.endsWith("'"))
|
|
79
|
+
) {
|
|
80
|
+
return word.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
return word;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function commandWordIndex(words: readonly string[]): number {
|
|
86
|
+
let index = 0;
|
|
87
|
+
while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) {
|
|
88
|
+
index += 1;
|
|
89
|
+
}
|
|
90
|
+
if (basename(words[index] ?? "") === "sudo") {
|
|
91
|
+
index += 1;
|
|
92
|
+
while ((words[index] ?? "").startsWith("-")) {
|
|
93
|
+
index += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return index < words.length ? index : -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function basename(word: string): string {
|
|
100
|
+
return word.slice(word.lastIndexOf("/") + 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasRecursiveFlag(args: readonly string[]): boolean {
|
|
104
|
+
return args.some((word) => /^-[^-]*[rR]/.test(word) || word === "--recursive");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasForceFlag(args: readonly string[]): boolean {
|
|
108
|
+
return args.some((word) => /^-[^-]*f/.test(word) || word === "--force");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDestructiveRmTarget(
|
|
112
|
+
target: string,
|
|
113
|
+
workspaceRoot: string | undefined,
|
|
114
|
+
): boolean {
|
|
115
|
+
if (target === "/" || target === "/*" || target === "~" || target === "~/*") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
target === "$HOME" ||
|
|
120
|
+
target === "${HOME}" ||
|
|
121
|
+
target === "$HOME/*" ||
|
|
122
|
+
target === "${HOME}/*"
|
|
123
|
+
) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (workspaceRoot === undefined || !path.isAbsolute(target)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const normalizedTarget = path.resolve(target.replace(/\/\*$/, ""));
|
|
130
|
+
return normalizedTarget === path.resolve(workspaceRoot);
|
|
131
|
+
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { defineToolExecutor } from "./types";
|
|
|
12
12
|
import type { TaskOutputSnapshot } from "./task-output";
|
|
13
13
|
import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
|
|
14
14
|
import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
|
|
15
|
+
import { classifyBashRisk } from "./bash-guard";
|
|
15
16
|
|
|
16
17
|
type BashArgs = {
|
|
17
18
|
command: string;
|
|
@@ -91,6 +92,36 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const input = parsed.value;
|
|
95
|
+
const risk = classifyBashRisk(input.command, {
|
|
96
|
+
workspaceRoot: options.workspaceRoot,
|
|
97
|
+
});
|
|
98
|
+
if (risk.dangerous && context.confirmBashCommand !== undefined) {
|
|
99
|
+
const decision = await context.confirmBashCommand({
|
|
100
|
+
command: input.command,
|
|
101
|
+
reason: risk.reason,
|
|
102
|
+
});
|
|
103
|
+
throwIfTurnCancelled(context.signal);
|
|
104
|
+
if (decision === "deny") {
|
|
105
|
+
const suffix =
|
|
106
|
+
context.bashGuardSurface === "one-shot"
|
|
107
|
+
? "Non-interactive mode cannot confirm; rerun with --yolo."
|
|
108
|
+
: "The user declined this command.";
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
command: input.command,
|
|
112
|
+
taskId: "",
|
|
113
|
+
sessionId: call.sessionId,
|
|
114
|
+
status: "failed",
|
|
115
|
+
cwd: options.cwdState.cwd,
|
|
116
|
+
outputFilePath: "",
|
|
117
|
+
outputBytes: 0,
|
|
118
|
+
outputLines: 0,
|
|
119
|
+
preview: "",
|
|
120
|
+
truncated: false,
|
|
121
|
+
error: `Command denied: ${risk.reason}. ${suffix}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
94
125
|
const foregroundTimeoutMs = input.timeout ?? defaultTimeoutMs;
|
|
95
126
|
throwIfTurnCancelled(context.signal);
|
|
96
127
|
const task = await options.taskManager.start({
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { lstat, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
3
|
+
import { resolveWorkspacePath } from "./path-safety";
|
|
4
|
+
import type { TurnUndoManager } from "./turn-undo-manager";
|
|
5
|
+
import { defineToolExecutor } from "./types";
|
|
6
|
+
import type {
|
|
7
|
+
DeleteFileRawResult,
|
|
8
|
+
FileSnapshotStore,
|
|
9
|
+
ToolExecutionContext,
|
|
10
|
+
ToolExecutor,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
type DeleteArgs = {
|
|
14
|
+
file_path: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DeleteToolOptions = {
|
|
18
|
+
workspaceRoot: string;
|
|
19
|
+
snapshots: FileSnapshotStore;
|
|
20
|
+
undoManager?: TurnUndoManager;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function createDeleteToolExecutor(options: DeleteToolOptions): ToolExecutor {
|
|
24
|
+
return defineToolExecutor("delete", {
|
|
25
|
+
definition: {
|
|
26
|
+
name: "Delete",
|
|
27
|
+
description:
|
|
28
|
+
"Delete one existing regular file. Directories and symbolic links are not supported.",
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
file_path: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Workspace-relative path or absolute path.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["file_path"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
async execute(
|
|
42
|
+
args,
|
|
43
|
+
call,
|
|
44
|
+
context: ToolExecutionContext,
|
|
45
|
+
): Promise<DeleteFileRawResult> {
|
|
46
|
+
throwIfTurnCancelled(context.signal);
|
|
47
|
+
const parsed = parseDeleteArgs(args);
|
|
48
|
+
|
|
49
|
+
if (!parsed.ok) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
filePath: "",
|
|
53
|
+
error: parsed.error,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const input = parsed.value;
|
|
58
|
+
let absolutePath: string;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
absolutePath = resolveWorkspacePath(options.workspaceRoot, input.file_path);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
filePath: input.file_path,
|
|
66
|
+
error: errorMessage(error),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let info: Awaited<ReturnType<typeof lstat>>;
|
|
71
|
+
try {
|
|
72
|
+
info = await lstat(absolutePath);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
filePath: input.file_path,
|
|
77
|
+
absolutePath,
|
|
78
|
+
error: deleteErrorMessage(error),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (info.isSymbolicLink()) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
filePath: input.file_path,
|
|
86
|
+
absolutePath,
|
|
87
|
+
error: "Symbolic links are not supported.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!info.isFile()) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
filePath: input.file_path,
|
|
95
|
+
absolutePath,
|
|
96
|
+
error: "Path is not a regular file.",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const undoCapture = await options.undoManager?.captureBeforeMutation({
|
|
101
|
+
turnId: call.turnId,
|
|
102
|
+
turnNumber: call.turnNumber,
|
|
103
|
+
absolutePath,
|
|
104
|
+
displayPath: input.file_path,
|
|
105
|
+
knownByteLength: info.size,
|
|
106
|
+
loadBefore: async () => ({
|
|
107
|
+
state: "present",
|
|
108
|
+
bytes: await readFile(absolutePath),
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
throwIfTurnCancelled(context.signal);
|
|
114
|
+
await rm(absolutePath);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (undoCapture !== undefined) {
|
|
117
|
+
await options.undoManager?.recordMutationFailure(undoCapture);
|
|
118
|
+
}
|
|
119
|
+
if (context.signal.aborted) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
filePath: input.file_path,
|
|
125
|
+
absolutePath,
|
|
126
|
+
error: deleteErrorMessage(error),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (undoCapture !== undefined) {
|
|
131
|
+
options.undoManager?.recordMutationResult(undoCapture, { state: "absent" });
|
|
132
|
+
}
|
|
133
|
+
options.snapshots.delete(absolutePath);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
filePath: input.file_path,
|
|
138
|
+
absolutePath,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseDeleteArgs(
|
|
145
|
+
args: unknown,
|
|
146
|
+
): { ok: true; value: DeleteArgs } | { ok: false; error: string } {
|
|
147
|
+
if (!isRecord(args)) {
|
|
148
|
+
return { ok: false, error: "Delete arguments must be an object." };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (typeof args.file_path !== "string") {
|
|
152
|
+
return { ok: false, error: "Delete.file_path must be a string." };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
value: {
|
|
158
|
+
file_path: args.file_path,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
164
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function deleteErrorMessage(error: unknown): string {
|
|
168
|
+
return isNotFound(error) ? "File does not exist." : errorMessage(error);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isNotFound(error: unknown): boolean {
|
|
172
|
+
return (
|
|
173
|
+
typeof error === "object" &&
|
|
174
|
+
error !== null &&
|
|
175
|
+
"code" in error &&
|
|
176
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function errorMessage(error: unknown): string {
|
|
181
|
+
return error instanceof Error ? error.message : String(error);
|
|
182
|
+
}
|
package/src/tools/edit.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import type { ToolCall } from "../agent/types";
|
|
3
4
|
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
4
5
|
import { computeFilePatch } from "./file-diff";
|
|
5
6
|
import { ensureParentDirectory } from "./ensure-parent-directory";
|
|
6
7
|
import { sha256Bytes, sha256Text } from "./hash";
|
|
7
8
|
import { resolveWorkspacePath } from "./path-safety";
|
|
9
|
+
import type { TurnUndoManager } from "./turn-undo-manager";
|
|
8
10
|
import { defineToolExecutor } from "./types";
|
|
9
11
|
import type {
|
|
10
12
|
EditFileRawResult,
|
|
@@ -23,6 +25,7 @@ type EditArgs = {
|
|
|
23
25
|
export type EditToolOptions = {
|
|
24
26
|
workspaceRoot: string;
|
|
25
27
|
snapshots: FileSnapshotStore;
|
|
28
|
+
undoManager?: TurnUndoManager;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
@@ -58,7 +61,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
58
61
|
},
|
|
59
62
|
async execute(
|
|
60
63
|
args,
|
|
61
|
-
|
|
64
|
+
call,
|
|
62
65
|
context: ToolExecutionContext,
|
|
63
66
|
): Promise<EditFileRawResult> {
|
|
64
67
|
throwIfTurnCancelled(context.signal);
|
|
@@ -102,6 +105,8 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
102
105
|
target,
|
|
103
106
|
newContent: input.new_string,
|
|
104
107
|
snapshots: options.snapshots,
|
|
108
|
+
undoManager: options.undoManager,
|
|
109
|
+
call,
|
|
105
110
|
signal: context.signal,
|
|
106
111
|
});
|
|
107
112
|
}
|
|
@@ -173,6 +178,9 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
173
178
|
expectedSha256: target.sha256,
|
|
174
179
|
readRequiredOnChange: true,
|
|
175
180
|
snapshots: options.snapshots,
|
|
181
|
+
undoManager: options.undoManager,
|
|
182
|
+
call,
|
|
183
|
+
initialBefore: target,
|
|
176
184
|
signal: context.signal,
|
|
177
185
|
});
|
|
178
186
|
},
|
|
@@ -219,6 +227,8 @@ async function writeEmptyTarget(input: {
|
|
|
219
227
|
target: TargetFileState;
|
|
220
228
|
newContent: string;
|
|
221
229
|
snapshots: FileSnapshotStore;
|
|
230
|
+
undoManager?: TurnUndoManager;
|
|
231
|
+
call: ToolCall;
|
|
222
232
|
signal: AbortSignal;
|
|
223
233
|
}): Promise<EditFileRawResult> {
|
|
224
234
|
if (input.target.exists && input.target.content.length > 0) {
|
|
@@ -242,6 +252,9 @@ async function writeEmptyTarget(input: {
|
|
|
242
252
|
expectedSha256: input.target.exists ? input.target.sha256 : undefined,
|
|
243
253
|
readRequiredOnChange: false,
|
|
244
254
|
snapshots: input.snapshots,
|
|
255
|
+
undoManager: input.undoManager,
|
|
256
|
+
call: input.call,
|
|
257
|
+
initialBefore: input.target,
|
|
245
258
|
signal: input.signal,
|
|
246
259
|
});
|
|
247
260
|
}
|
|
@@ -258,10 +271,14 @@ async function writeEditedContent(input: {
|
|
|
258
271
|
expectedSha256?: string;
|
|
259
272
|
readRequiredOnChange: boolean;
|
|
260
273
|
snapshots: FileSnapshotStore;
|
|
274
|
+
undoManager?: TurnUndoManager;
|
|
275
|
+
call: ToolCall;
|
|
276
|
+
initialBefore: TargetFileState;
|
|
261
277
|
signal: AbortSignal;
|
|
262
278
|
}): Promise<EditFileRawResult> {
|
|
263
279
|
throwIfTurnCancelled(input.signal);
|
|
264
280
|
|
|
281
|
+
let verifiedBefore = input.initialBefore;
|
|
265
282
|
if (input.expectedSha256 !== undefined) {
|
|
266
283
|
const currentState = await targetFileState(input.absolutePath);
|
|
267
284
|
throwIfTurnCancelled(input.signal);
|
|
@@ -296,12 +313,27 @@ async function writeEditedContent(input: {
|
|
|
296
313
|
: "File changed while Edit was being prepared. Retry Edit with the current file state.",
|
|
297
314
|
};
|
|
298
315
|
}
|
|
316
|
+
verifiedBefore = currentState;
|
|
299
317
|
}
|
|
300
318
|
|
|
319
|
+
const undoCapture = await input.undoManager?.captureBeforeMutation({
|
|
320
|
+
turnId: input.call.turnId,
|
|
321
|
+
turnNumber: input.call.turnNumber,
|
|
322
|
+
absolutePath: input.absolutePath,
|
|
323
|
+
displayPath: input.filePath,
|
|
324
|
+
loadBefore: async () =>
|
|
325
|
+
verifiedBefore.exists
|
|
326
|
+
? { state: "present", bytes: verifiedBefore.bytes }
|
|
327
|
+
: { state: "absent" },
|
|
328
|
+
});
|
|
329
|
+
|
|
301
330
|
if (input.created) {
|
|
302
331
|
try {
|
|
303
332
|
await ensureParentDirectory(input.absolutePath);
|
|
304
333
|
} catch (error) {
|
|
334
|
+
if (undoCapture !== undefined) {
|
|
335
|
+
await input.undoManager?.recordMutationFailure(undoCapture);
|
|
336
|
+
}
|
|
305
337
|
return {
|
|
306
338
|
ok: false,
|
|
307
339
|
filePath: input.filePath,
|
|
@@ -312,14 +344,39 @@ async function writeEditedContent(input: {
|
|
|
312
344
|
throwIfTurnCancelled(input.signal);
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
347
|
+
let newSha256: string;
|
|
348
|
+
try {
|
|
349
|
+
throwIfTurnCancelled(input.signal);
|
|
350
|
+
await writeFile(input.absolutePath, input.newContent, "utf8");
|
|
351
|
+
const expectedSha256 = sha256Text(input.newContent);
|
|
352
|
+
const written = await targetFileState(input.absolutePath);
|
|
353
|
+
if (!written.ok) {
|
|
354
|
+
throw new Error(`Failed to verify edited file: ${written.error}`);
|
|
355
|
+
}
|
|
356
|
+
if (!written.exists) {
|
|
357
|
+
throw new Error("Failed to verify edited file: File does not exist.");
|
|
358
|
+
}
|
|
359
|
+
if (written.sha256 !== expectedSha256) {
|
|
360
|
+
throw new Error("File changed while Edit was being verified.");
|
|
361
|
+
}
|
|
362
|
+
newSha256 = written.sha256;
|
|
363
|
+
if (undoCapture !== undefined) {
|
|
364
|
+
input.undoManager?.recordMutationResult(undoCapture, {
|
|
365
|
+
state: "present",
|
|
366
|
+
sha256: newSha256,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
input.snapshots.set(input.absolutePath, {
|
|
370
|
+
sha256: newSha256,
|
|
371
|
+
mtimeMs: written.mtimeMs,
|
|
372
|
+
source: "edit",
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (undoCapture !== undefined) {
|
|
376
|
+
await input.undoManager?.recordMutationFailure(undoCapture);
|
|
377
|
+
}
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
323
380
|
|
|
324
381
|
const patch = computeFilePatch({
|
|
325
382
|
filePath: input.filePath,
|
|
@@ -348,6 +405,7 @@ type TargetFileState =
|
|
|
348
405
|
ok: true;
|
|
349
406
|
exists: true;
|
|
350
407
|
content: string;
|
|
408
|
+
bytes: Buffer;
|
|
351
409
|
sha256: string;
|
|
352
410
|
mtimeMs: number;
|
|
353
411
|
};
|
|
@@ -371,6 +429,7 @@ async function targetFileState(
|
|
|
371
429
|
ok: true,
|
|
372
430
|
exists: true,
|
|
373
431
|
content: bytes.toString("utf8"),
|
|
432
|
+
bytes,
|
|
374
433
|
sha256: sha256Bytes(bytes),
|
|
375
434
|
mtimeMs: currentInfo.mtimeMs,
|
|
376
435
|
};
|
package/src/tools/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createBashToolExecutor } from "./bash";
|
|
2
2
|
import { ShellTaskManager } from "./bash-task";
|
|
3
3
|
import { createCwdState } from "./cwd-state";
|
|
4
|
+
import { createDeleteToolExecutor } from "./delete";
|
|
4
5
|
import { createEditToolExecutor } from "./edit";
|
|
5
6
|
import { createGlobToolExecutor } from "./glob";
|
|
6
7
|
import { createGrepToolExecutor } from "./grep";
|
|
@@ -13,6 +14,7 @@ import { createWebFetchToolExecutor } from "./web-fetch";
|
|
|
13
14
|
import type { Refiner } from "./web-fetch/refiner";
|
|
14
15
|
import { createWebSearchToolExecutor } from "./web-search";
|
|
15
16
|
import { createWriteToolExecutor } from "./write";
|
|
17
|
+
import { TurnUndoManager } from "./turn-undo-manager";
|
|
16
18
|
import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
17
19
|
import type {
|
|
18
20
|
RuntimeSessionContext,
|
|
@@ -62,7 +64,17 @@ export class ToolRegistry {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export class ToolRuntime {
|
|
65
|
-
constructor(
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly registry: ToolRegistry,
|
|
69
|
+
private readonly bashGuard?: {
|
|
70
|
+
readonly surface: "tui" | "one-shot";
|
|
71
|
+
confirm(
|
|
72
|
+
call: ToolCall,
|
|
73
|
+
request: { command: string; reason: string },
|
|
74
|
+
signal: AbortSignal,
|
|
75
|
+
): Promise<"allow" | "deny">;
|
|
76
|
+
},
|
|
77
|
+
) {}
|
|
66
78
|
|
|
67
79
|
async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
|
|
68
80
|
throwIfTurnCancelled(context.signal);
|
|
@@ -88,7 +100,16 @@ export class ToolRuntime {
|
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
try {
|
|
91
|
-
return await tool.execute(call.args, call,
|
|
103
|
+
return await tool.execute(call.args, call, {
|
|
104
|
+
...context,
|
|
105
|
+
...(this.bashGuard === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: {
|
|
108
|
+
bashGuardSurface: this.bashGuard.surface,
|
|
109
|
+
confirmBashCommand: (request: { command: string; reason: string }) =>
|
|
110
|
+
this.bashGuard!.confirm(call, request, context.signal),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
92
113
|
} catch (error) {
|
|
93
114
|
if (context.signal.aborted) {
|
|
94
115
|
throw cancellationError(context.signal, error);
|
|
@@ -113,6 +134,7 @@ export type DefaultTooling = {
|
|
|
113
134
|
snapshots: FileSnapshotStore;
|
|
114
135
|
bashState: BashToolingState;
|
|
115
136
|
taskManager: ShellTaskManager;
|
|
137
|
+
turnUndoManager?: TurnUndoManager;
|
|
116
138
|
dispose(reason?: SessionDisposeReason["type"]): Promise<void>;
|
|
117
139
|
};
|
|
118
140
|
|
|
@@ -134,8 +156,20 @@ export function createDefaultTooling(options: {
|
|
|
134
156
|
skillCoordinator?: SkillActivationCoordinator;
|
|
135
157
|
toolingConfig?: PublicToolingConfig;
|
|
136
158
|
memorySearch?: ToolExecutor;
|
|
159
|
+
enableTurnUndo?: boolean;
|
|
160
|
+
bashGuard?: {
|
|
161
|
+
readonly surface: "tui" | "one-shot";
|
|
162
|
+
confirm(
|
|
163
|
+
call: ToolCall,
|
|
164
|
+
request: { command: string; reason: string },
|
|
165
|
+
signal: AbortSignal,
|
|
166
|
+
): Promise<"allow" | "deny">;
|
|
167
|
+
};
|
|
137
168
|
}): DefaultTooling {
|
|
138
169
|
const snapshots: FileSnapshotStore = new Map();
|
|
170
|
+
const turnUndoManager = options.enableTurnUndo
|
|
171
|
+
? new TurnUndoManager({ snapshots })
|
|
172
|
+
: undefined;
|
|
139
173
|
const registry = new ToolRegistry();
|
|
140
174
|
const runtimeSession = options.runtimeSession;
|
|
141
175
|
const toolingConfig = options.toolingConfig ?? DEFAULT_PUBLIC_TOOLING_CONFIG;
|
|
@@ -194,12 +228,21 @@ export function createDefaultTooling(options: {
|
|
|
194
228
|
createWriteToolExecutor({
|
|
195
229
|
workspaceRoot: options.workspaceRoot,
|
|
196
230
|
snapshots,
|
|
231
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
197
232
|
}),
|
|
198
233
|
);
|
|
199
234
|
registry.register(
|
|
200
235
|
createEditToolExecutor({
|
|
201
236
|
workspaceRoot: options.workspaceRoot,
|
|
202
237
|
snapshots,
|
|
238
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
registry.register(
|
|
242
|
+
createDeleteToolExecutor({
|
|
243
|
+
workspaceRoot: options.workspaceRoot,
|
|
244
|
+
snapshots,
|
|
245
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
203
246
|
}),
|
|
204
247
|
);
|
|
205
248
|
registry.register(
|
|
@@ -232,9 +275,10 @@ export function createDefaultTooling(options: {
|
|
|
232
275
|
|
|
233
276
|
return {
|
|
234
277
|
registry,
|
|
235
|
-
runtime: new ToolRuntime(registry),
|
|
278
|
+
runtime: new ToolRuntime(registry, options.bashGuard),
|
|
236
279
|
snapshots,
|
|
237
280
|
taskManager,
|
|
281
|
+
...(turnUndoManager === undefined ? {} : { turnUndoManager }),
|
|
238
282
|
bashState: {
|
|
239
283
|
get cwd() {
|
|
240
284
|
return cwdState.cwd;
|