tinker-agent 1.5.1 → 1.7.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 +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- 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/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -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 +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
package/src/tools/bash-task.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
1
|
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import type {
|
|
@@ -8,7 +7,19 @@ import type {
|
|
|
8
7
|
import type { ToolCallIdentity } from "../agent/types";
|
|
9
8
|
import { createUuidV7 } from "../ids/uuid-v7";
|
|
10
9
|
import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
|
|
10
|
+
import {
|
|
11
|
+
type ProcessExitResult,
|
|
12
|
+
type ShellProcessHandle,
|
|
13
|
+
type ShellProcessMode,
|
|
14
|
+
spawnShellProcess,
|
|
15
|
+
} from "./shell-process";
|
|
11
16
|
import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
|
|
17
|
+
import {
|
|
18
|
+
createTerminalScreen,
|
|
19
|
+
TERMINAL_SCREEN_COLUMNS,
|
|
20
|
+
TERMINAL_SCREEN_ROWS,
|
|
21
|
+
type TerminalScreen,
|
|
22
|
+
} from "./terminal-screen";
|
|
12
23
|
|
|
13
24
|
export type ShellTaskStatus =
|
|
14
25
|
| "running"
|
|
@@ -38,11 +49,15 @@ export type ShellTaskSnapshot = {
|
|
|
38
49
|
outputBytes: number;
|
|
39
50
|
outputLines: number;
|
|
40
51
|
cwd: string;
|
|
52
|
+
tty: boolean;
|
|
41
53
|
};
|
|
42
54
|
|
|
43
55
|
export type ShellTaskInspection = {
|
|
44
56
|
task: ShellTaskSnapshot;
|
|
45
57
|
output: TaskOutputSnapshot;
|
|
58
|
+
screenRows?: number;
|
|
59
|
+
screenColumns?: number;
|
|
60
|
+
screen?: string;
|
|
46
61
|
};
|
|
47
62
|
|
|
48
63
|
export type ShellTaskHandle = {
|
|
@@ -81,9 +96,12 @@ type ManagedShellTask = {
|
|
|
81
96
|
outputFilePath: string;
|
|
82
97
|
cwdFilePath: string;
|
|
83
98
|
cwd: string;
|
|
84
|
-
|
|
99
|
+
mode: ShellProcessMode;
|
|
100
|
+
process: ShellProcessHandle;
|
|
85
101
|
processGroupId: number;
|
|
86
102
|
output: TaskOutput;
|
|
103
|
+
terminalScreen?: TerminalScreen;
|
|
104
|
+
finalScreen?: string;
|
|
87
105
|
completion: Promise<ShellTaskSnapshot>;
|
|
88
106
|
stopPromise?: Promise<StopTaskResult>;
|
|
89
107
|
terminalEventEmitted: boolean;
|
|
@@ -115,6 +133,7 @@ export class ShellTaskManager {
|
|
|
115
133
|
command: string;
|
|
116
134
|
description: string;
|
|
117
135
|
origin: ShellTaskOrigin;
|
|
136
|
+
tty: boolean;
|
|
118
137
|
}): Promise<ShellTaskHandle> {
|
|
119
138
|
if (!this.acceptingTasks) {
|
|
120
139
|
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
@@ -142,22 +161,26 @@ export class ShellTaskManager {
|
|
|
142
161
|
throw new Error("Cannot start a Bash task after task manager shutdown.");
|
|
143
162
|
}
|
|
144
163
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
const terminalScreen = input.tty ? createTerminalScreen() : undefined;
|
|
165
|
+
let shellProcess: ShellProcessHandle;
|
|
166
|
+
try {
|
|
167
|
+
shellProcess = await spawnShellProcess({
|
|
168
|
+
mode: input.tty ? "pty" : "pipe",
|
|
169
|
+
command: input.command,
|
|
170
|
+
cwd: this.options.cwdState.cwd,
|
|
171
|
+
cwdFilePath,
|
|
172
|
+
onOutput(bytes) {
|
|
173
|
+
output.write(Buffer.from(bytes));
|
|
174
|
+
if (terminalScreen !== undefined) {
|
|
175
|
+
void terminalScreen.write(bytes).catch(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
terminalScreen?.dispose();
|
|
158
181
|
await output.end();
|
|
159
182
|
await unlinkIfExists(cwdFilePath);
|
|
160
|
-
throw
|
|
183
|
+
throw error;
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
const task: ManagedShellTask = {
|
|
@@ -170,15 +193,15 @@ export class ShellTaskManager {
|
|
|
170
193
|
outputFilePath,
|
|
171
194
|
cwdFilePath,
|
|
172
195
|
cwd: this.options.cwdState.cwd,
|
|
173
|
-
|
|
174
|
-
|
|
196
|
+
mode: shellProcess.mode,
|
|
197
|
+
process: shellProcess,
|
|
198
|
+
processGroupId: shellProcess.pid,
|
|
175
199
|
output,
|
|
200
|
+
terminalScreen,
|
|
176
201
|
completion: Promise.resolve(undefined as never),
|
|
177
202
|
terminalEventEmitted: false,
|
|
178
203
|
};
|
|
179
204
|
|
|
180
|
-
pipeTaskOutput(task.process.stdout, task.output);
|
|
181
|
-
pipeTaskOutput(task.process.stderr, task.output);
|
|
182
205
|
task.completion = this.monitorTaskSafely(task);
|
|
183
206
|
this.tasks.set(id, task);
|
|
184
207
|
|
|
@@ -231,10 +254,45 @@ export class ShellTaskManager {
|
|
|
231
254
|
}
|
|
232
255
|
|
|
233
256
|
this.synchronizeTerminalState(task);
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
257
|
+
return this.inspection(task);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async inspectTaskOutput(taskId: string): Promise<ShellTaskInspection | undefined> {
|
|
261
|
+
const task = this.tasks.get(taskId);
|
|
262
|
+
if (task === undefined) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.synchronizeTerminalState(task);
|
|
267
|
+
if (
|
|
268
|
+
task.mode === "pty" &&
|
|
269
|
+
isTerminalStatus(task.status) &&
|
|
270
|
+
task.finalScreen === undefined
|
|
271
|
+
) {
|
|
272
|
+
await task.completion;
|
|
273
|
+
} else {
|
|
274
|
+
await task.terminalScreen?.flush();
|
|
275
|
+
}
|
|
276
|
+
return this.inspection(task);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
taskCompletion(taskId: string): Promise<ShellTaskSnapshot> {
|
|
280
|
+
return this.requireTask(taskId).completion;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async writeTaskInput(taskId: string, chars: string): Promise<number> {
|
|
284
|
+
const task = this.requireTask(taskId);
|
|
285
|
+
this.synchronizeTerminalState(task);
|
|
286
|
+
if (task.mode !== "pty" || task.process.write === undefined) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Task ${taskId} does not accept terminal input; start it with Bash tty=true.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (task.status !== "running") {
|
|
292
|
+
throw new Error(`Task ${taskId} is not running (status=${task.status}).`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return task.process.write(chars);
|
|
238
296
|
}
|
|
239
297
|
|
|
240
298
|
async stopTask(taskId: string, reason: StopTaskReason): Promise<StopTaskResult> {
|
|
@@ -372,20 +430,43 @@ export class ShellTaskManager {
|
|
|
372
430
|
outputError instanceof Error ? outputError.message : String(outputError)
|
|
373
431
|
}`;
|
|
374
432
|
}
|
|
433
|
+
if (task.terminalScreen !== undefined) {
|
|
434
|
+
try {
|
|
435
|
+
await task.terminalScreen.flush();
|
|
436
|
+
task.finalScreen = task.terminalScreen.text();
|
|
437
|
+
} catch {
|
|
438
|
+
// The original monitor error remains the primary task failure.
|
|
439
|
+
}
|
|
440
|
+
task.terminalScreen.dispose();
|
|
441
|
+
}
|
|
442
|
+
task.process.close();
|
|
375
443
|
await unlinkIfExists(task.cwdFilePath);
|
|
376
444
|
|
|
377
|
-
|
|
445
|
+
const snapshot = this.snapshot(task);
|
|
446
|
+
if (task.backgroundedAt !== undefined && !task.terminalEventEmitted) {
|
|
447
|
+
task.terminalEventEmitted = true;
|
|
448
|
+
await this.options.runtimeSession.append({
|
|
449
|
+
type: "bash.task.finished",
|
|
450
|
+
...task.origin,
|
|
451
|
+
data: { task: snapshot },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return snapshot;
|
|
378
455
|
}
|
|
379
456
|
}
|
|
380
457
|
|
|
381
458
|
private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
|
|
382
|
-
const
|
|
383
|
-
const close = waitForProcessClose(task.process);
|
|
384
|
-
const result = await exit;
|
|
459
|
+
const result = await task.process.wait();
|
|
385
460
|
|
|
386
461
|
this.applyTermination(task, result);
|
|
387
|
-
await
|
|
462
|
+
await task.process.waitForOutputClose();
|
|
388
463
|
await task.output.end();
|
|
464
|
+
if (task.terminalScreen !== undefined) {
|
|
465
|
+
await task.terminalScreen.flush();
|
|
466
|
+
task.finalScreen = task.terminalScreen.text();
|
|
467
|
+
task.terminalScreen.dispose();
|
|
468
|
+
}
|
|
469
|
+
task.process.close();
|
|
389
470
|
await this.updateCwdFromFile(task);
|
|
390
471
|
await unlinkIfExists(task.cwdFilePath);
|
|
391
472
|
|
|
@@ -464,6 +545,25 @@ export class ShellTaskManager {
|
|
|
464
545
|
outputBytes: output.outputBytes,
|
|
465
546
|
outputLines: output.outputLines,
|
|
466
547
|
cwd: task.cwd,
|
|
548
|
+
tty: task.mode === "pty",
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private inspection(task: ManagedShellTask): ShellTaskInspection {
|
|
553
|
+
const screen =
|
|
554
|
+
task.mode === "pty"
|
|
555
|
+
? (task.finalScreen ?? task.terminalScreen?.text() ?? "")
|
|
556
|
+
: undefined;
|
|
557
|
+
return {
|
|
558
|
+
task: this.snapshot(task),
|
|
559
|
+
output: task.output.snapshot(),
|
|
560
|
+
...(screen === undefined
|
|
561
|
+
? {}
|
|
562
|
+
: {
|
|
563
|
+
screenRows: TERMINAL_SCREEN_ROWS,
|
|
564
|
+
screenColumns: TERMINAL_SCREEN_COLUMNS,
|
|
565
|
+
screen,
|
|
566
|
+
}),
|
|
467
567
|
};
|
|
468
568
|
}
|
|
469
569
|
|
|
@@ -488,54 +588,6 @@ export class ShellTaskManager {
|
|
|
488
588
|
}
|
|
489
589
|
}
|
|
490
590
|
|
|
491
|
-
type ProcessExitResult = {
|
|
492
|
-
code: number | null;
|
|
493
|
-
signal: NodeJS.Signals | null;
|
|
494
|
-
error?: string;
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
function waitForProcessExit(
|
|
498
|
-
process: ChildProcessWithoutNullStreams,
|
|
499
|
-
): Promise<ProcessExitResult> {
|
|
500
|
-
return new Promise((resolve) => {
|
|
501
|
-
let settled = false;
|
|
502
|
-
const finish = (result: ProcessExitResult) => {
|
|
503
|
-
if (!settled) {
|
|
504
|
-
settled = true;
|
|
505
|
-
resolve(result);
|
|
506
|
-
}
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
process.once("error", (error) => {
|
|
510
|
-
finish({ code: null, signal: null, error: error.message });
|
|
511
|
-
});
|
|
512
|
-
process.once("exit", (code, signal) => {
|
|
513
|
-
finish({ code, signal });
|
|
514
|
-
});
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function waitForProcessError(process: ChildProcessWithoutNullStreams): Promise<Error> {
|
|
519
|
-
return new Promise((resolve) => {
|
|
520
|
-
process.once("error", resolve);
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function waitForProcessClose(process: ChildProcessWithoutNullStreams): Promise<void> {
|
|
525
|
-
return new Promise((resolve) => {
|
|
526
|
-
let settled = false;
|
|
527
|
-
const finish = () => {
|
|
528
|
-
if (!settled) {
|
|
529
|
-
settled = true;
|
|
530
|
-
resolve();
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
|
|
534
|
-
process.once("error", finish);
|
|
535
|
-
process.once("close", finish);
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
539
591
|
function signalProcessGroup(
|
|
540
592
|
task: ManagedShellTask,
|
|
541
593
|
signal: "SIGTERM" | "SIGKILL",
|
|
@@ -573,12 +625,6 @@ function isTerminalStatus(status: ShellTaskStatus): boolean {
|
|
|
573
625
|
return status === "completed" || status === "failed" || status === "killed";
|
|
574
626
|
}
|
|
575
627
|
|
|
576
|
-
function pipeTaskOutput(stream: NodeJS.ReadableStream, output: TaskOutput): void {
|
|
577
|
-
stream.on("data", (chunk: Buffer | string) => {
|
|
578
|
-
output.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
|
|
582
628
|
async function ensureEmptyFile(filePath: string): Promise<void> {
|
|
583
629
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
584
630
|
const file = await open(filePath, "w");
|
|
@@ -608,10 +654,3 @@ function errorCode(error: unknown): unknown {
|
|
|
608
654
|
? error.code
|
|
609
655
|
: undefined;
|
|
610
656
|
}
|
|
611
|
-
|
|
612
|
-
const bashWrapperScript = `
|
|
613
|
-
eval "$TINKER_BASH_COMMAND"
|
|
614
|
-
exit_code=$?
|
|
615
|
-
pwd -P > "$TINKER_BASH_CWD_FILE"
|
|
616
|
-
exit "$exit_code"
|
|
617
|
-
`;
|
package/src/tools/bash.ts
CHANGED
|
@@ -12,12 +12,14 @@ 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;
|
|
18
19
|
timeout?: number;
|
|
19
20
|
description?: string;
|
|
20
21
|
run_in_background?: boolean;
|
|
22
|
+
tty?: boolean;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
export type BashToolOptions = {
|
|
@@ -65,6 +67,11 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
65
67
|
type: "boolean",
|
|
66
68
|
description: "Run the command in the background and return immediately.",
|
|
67
69
|
},
|
|
70
|
+
tty: {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
description:
|
|
73
|
+
"Run the command in a pseudo-terminal so it can receive interactive input.",
|
|
74
|
+
},
|
|
68
75
|
},
|
|
69
76
|
required: ["command"],
|
|
70
77
|
},
|
|
@@ -86,26 +93,62 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
86
93
|
outputLines: 0,
|
|
87
94
|
preview: "",
|
|
88
95
|
truncated: false,
|
|
96
|
+
tty: false,
|
|
89
97
|
error: parsed.error,
|
|
90
98
|
};
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
const input = parsed.value;
|
|
102
|
+
const risk = classifyBashRisk(input.command, {
|
|
103
|
+
workspaceRoot: options.workspaceRoot,
|
|
104
|
+
});
|
|
105
|
+
if (risk.dangerous && context.confirmBashCommand !== undefined) {
|
|
106
|
+
const decision = await context.confirmBashCommand({
|
|
107
|
+
command: input.command,
|
|
108
|
+
reason: risk.reason,
|
|
109
|
+
});
|
|
110
|
+
throwIfTurnCancelled(context.signal);
|
|
111
|
+
if (decision === "deny") {
|
|
112
|
+
const suffix =
|
|
113
|
+
context.bashGuardSurface === "one-shot"
|
|
114
|
+
? "Non-interactive mode cannot confirm; rerun with --yolo."
|
|
115
|
+
: "The user declined this command.";
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
command: input.command,
|
|
119
|
+
taskId: "",
|
|
120
|
+
sessionId: call.sessionId,
|
|
121
|
+
status: "failed",
|
|
122
|
+
cwd: options.cwdState.cwd,
|
|
123
|
+
outputFilePath: "",
|
|
124
|
+
outputBytes: 0,
|
|
125
|
+
outputLines: 0,
|
|
126
|
+
preview: "",
|
|
127
|
+
truncated: false,
|
|
128
|
+
tty: input.tty === true,
|
|
129
|
+
error: `Command denied: ${risk.reason}. ${suffix}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
94
133
|
const foregroundTimeoutMs = input.timeout ?? defaultTimeoutMs;
|
|
95
134
|
throwIfTurnCancelled(context.signal);
|
|
96
135
|
const task = await options.taskManager.start({
|
|
97
136
|
command: input.command,
|
|
98
137
|
description: input.description ?? input.command,
|
|
99
138
|
origin: call,
|
|
139
|
+
tty: input.tty === true,
|
|
100
140
|
});
|
|
101
141
|
|
|
102
142
|
if (input.run_in_background === true) {
|
|
103
143
|
// Starting and publishing an explicit background task is one commit
|
|
104
144
|
// boundary. Cancellation is observed after its result is recorded.
|
|
105
145
|
await options.taskManager.markBackgrounded(task.taskId, "requested");
|
|
106
|
-
const inspection =
|
|
146
|
+
const inspection = await requireTaskOutputInspection(
|
|
147
|
+
options.taskManager,
|
|
148
|
+
task.taskId,
|
|
149
|
+
);
|
|
107
150
|
if (inspection.task.status !== "running") {
|
|
108
|
-
return buildCompletedResult(inspection
|
|
151
|
+
return buildCompletedResult(inspection);
|
|
109
152
|
}
|
|
110
153
|
|
|
111
154
|
return buildRunningResult({
|
|
@@ -124,9 +167,12 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
124
167
|
// Timeout wins ownership. Marking the task backgrounded and returning
|
|
125
168
|
// its task ID is an uninterrupted commit boundary.
|
|
126
169
|
await options.taskManager.markBackgrounded(task.taskId, "foreground_timeout");
|
|
127
|
-
const inspection =
|
|
170
|
+
const inspection = await requireTaskOutputInspection(
|
|
171
|
+
options.taskManager,
|
|
172
|
+
task.taskId,
|
|
173
|
+
);
|
|
128
174
|
if (inspection.task.status !== "running") {
|
|
129
|
-
return buildCompletedResult(inspection
|
|
175
|
+
return buildCompletedResult(inspection);
|
|
130
176
|
}
|
|
131
177
|
|
|
132
178
|
return buildRunningResult({
|
|
@@ -138,11 +184,14 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
138
184
|
});
|
|
139
185
|
}
|
|
140
186
|
|
|
141
|
-
const inspection =
|
|
142
|
-
|
|
187
|
+
const inspection = await requireTaskOutputInspection(
|
|
188
|
+
options.taskManager,
|
|
189
|
+
task.taskId,
|
|
190
|
+
);
|
|
191
|
+
const raw = await buildCompletedResult(inspection);
|
|
143
192
|
updateCwdStateAfterForegroundCommand({
|
|
144
193
|
raw,
|
|
145
|
-
task:
|
|
194
|
+
task: inspection.task,
|
|
146
195
|
cwdState: options.cwdState,
|
|
147
196
|
workspaceRoot: options.workspaceRoot,
|
|
148
197
|
});
|
|
@@ -179,6 +228,10 @@ export function parseBashArgs(
|
|
|
179
228
|
return { ok: false, error: "Bash.run_in_background must be a boolean." };
|
|
180
229
|
}
|
|
181
230
|
|
|
231
|
+
if (args.tty !== undefined && typeof args.tty !== "boolean") {
|
|
232
|
+
return { ok: false, error: "Bash.tty must be a boolean." };
|
|
233
|
+
}
|
|
234
|
+
|
|
182
235
|
return {
|
|
183
236
|
ok: true,
|
|
184
237
|
value: {
|
|
@@ -189,6 +242,7 @@ export function parseBashArgs(
|
|
|
189
242
|
? undefined
|
|
190
243
|
: args.description,
|
|
191
244
|
run_in_background: args.run_in_background,
|
|
245
|
+
tty: args.tty,
|
|
192
246
|
},
|
|
193
247
|
};
|
|
194
248
|
}
|
|
@@ -294,18 +348,22 @@ function buildRunningResult(input: {
|
|
|
294
348
|
timeoutMs: input.timeoutMs,
|
|
295
349
|
backgrounded: input.backgrounded,
|
|
296
350
|
backgroundedDueToTimeout: input.backgroundedDueToTimeout,
|
|
351
|
+
tty: task.tty,
|
|
352
|
+
screenRows: input.inspection.screenRows,
|
|
353
|
+
screenColumns: input.inspection.screenColumns,
|
|
354
|
+
screen: input.inspection.screen,
|
|
297
355
|
};
|
|
298
356
|
}
|
|
299
357
|
|
|
300
358
|
async function buildCompletedResult(
|
|
301
|
-
|
|
302
|
-
fallbackOutput: TaskOutputSnapshot,
|
|
359
|
+
inspection: ShellTaskInspection,
|
|
303
360
|
): Promise<BashRawResult> {
|
|
361
|
+
const { task } = inspection;
|
|
304
362
|
if (task.status === "running" || task.status === "stopping") {
|
|
305
363
|
throw new Error(`Bash task ${task.taskId} completed with status=${task.status}.`);
|
|
306
364
|
}
|
|
307
365
|
|
|
308
|
-
const snapshot = await snapshotCompletedOutput(task,
|
|
366
|
+
const snapshot = await snapshotCompletedOutput(task, inspection.output);
|
|
309
367
|
const interpretation = interpretCommandResult({
|
|
310
368
|
command: task.command,
|
|
311
369
|
exitCode: task.exitCode,
|
|
@@ -328,6 +386,10 @@ async function buildCompletedResult(
|
|
|
328
386
|
truncated: snapshot.truncated,
|
|
329
387
|
omittedLines: snapshot.omittedLines,
|
|
330
388
|
returnCodeInterpretation: interpretation.interpretation,
|
|
389
|
+
tty: task.tty,
|
|
390
|
+
screenRows: inspection.screenRows,
|
|
391
|
+
screenColumns: inspection.screenColumns,
|
|
392
|
+
screen: inspection.screen,
|
|
331
393
|
error: task.error,
|
|
332
394
|
};
|
|
333
395
|
}
|
|
@@ -359,11 +421,11 @@ function updateCwdStateAfterForegroundCommand(input: {
|
|
|
359
421
|
}
|
|
360
422
|
}
|
|
361
423
|
|
|
362
|
-
function
|
|
424
|
+
async function requireTaskOutputInspection(
|
|
363
425
|
taskManager: ShellTaskManager,
|
|
364
426
|
taskId: string,
|
|
365
|
-
): ShellTaskInspection {
|
|
366
|
-
const inspection = taskManager.
|
|
427
|
+
): Promise<ShellTaskInspection> {
|
|
428
|
+
const inspection = await taskManager.inspectTaskOutput(taskId);
|
|
367
429
|
if (inspection === undefined) {
|
|
368
430
|
throw new Error(`Bash task disappeared from task manager: ${taskId}`);
|
|
369
431
|
}
|
|
@@ -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
|
+
}
|