@shanepadgett/tau-agent 0.44.0 → 0.44.1
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/extensions/script-runner/README.md +3 -1
- package/extensions/script-runner/index.ts +41 -96
- package/extensions/tool-approval/README.md +3 -3
- package/extensions/tool-approval/index.ts +78 -19
- package/extensions/tool-approval/panel.ts +16 -2
- package/package.json +2 -2
- package/shared/events.ts +5 -0
- package/shared/script-source.ts +94 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Gives the agent a first-class `script_runner` tool for running Python 3, Node.js, and Deno scripts instead of falling back to bash. The agent picks whichever runtime is more efficient for the task.
|
|
4
4
|
|
|
5
|
-
When a run fails, the tool keeps the script and returns a `scriptId`. The agent retries with targeted `{oldText, newText}` edits against what it just wrote instead of resending the whole script, saving output tokens and keeping duplicate scripts out of context. Only the source the agent already sent is referenced; no file path is exposed.
|
|
5
|
+
When a run fails, the tool keeps the script and returns a `scriptId`. The agent retries with targeted `{oldText, newText}` edits against what it just wrote instead of resending the whole script, saving output tokens and keeping duplicate scripts out of context. Only the source the agent already sent is referenced; no script source file path is exposed.
|
|
6
|
+
|
|
7
|
+
Long output shows the tail and a path to the complete output in a temporary file for the active session.
|
|
6
8
|
|
|
7
9
|
Runtimes are detected from the environment: Python 3 via `python3`, Node via the current process when Node is 22.6 or newer (`node --experimental-strip-types`), Deno via `deno` (`deno run -A`). The `node` language is the local Node.js runtime with full Node APIs; scripts may be TypeScript with erasable syntax or plain JavaScript. The `deno` language is the local Deno runtime with full permissions and native TypeScript/JavaScript. The tool registers only the runtimes actually available and is hidden from the prompt entirely when none are present.
|
|
@@ -4,17 +4,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
-
import {
|
|
8
|
-
DEFAULT_MAX_BYTES,
|
|
9
|
-
DEFAULT_MAX_LINES,
|
|
10
|
-
defineTool,
|
|
11
|
-
type ExecResult,
|
|
12
|
-
type ExtensionAPI,
|
|
13
|
-
type Theme,
|
|
14
|
-
truncateTail,
|
|
15
|
-
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { defineTool, type ExecResult, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
16
8
|
import { Text } from "@earendil-works/pi-tui";
|
|
17
9
|
import { Type } from "typebox";
|
|
10
|
+
import { BoundedTextResultBuilder } from "../../shared/bounded-text-result.ts";
|
|
11
|
+
import { onTauEventImmediately } from "../../shared/events.ts";
|
|
12
|
+
import { createScriptSourceStore } from "../../shared/script-source.ts";
|
|
13
|
+
import { createTemporaryOutputStore } from "../../shared/temporary-output-store.ts";
|
|
18
14
|
import { renderToolOutputPreview } from "../../shared/text.ts";
|
|
19
15
|
|
|
20
16
|
type Language = "python3" | "node" | "deno";
|
|
@@ -25,13 +21,7 @@ interface Runtimes {
|
|
|
25
21
|
deno: string | undefined;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
interface StoredScript {
|
|
29
|
-
language: Language;
|
|
30
|
-
source: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
24
|
const TIMEOUT_MS = 120_000;
|
|
34
|
-
const MAX_STORED = 8;
|
|
35
25
|
|
|
36
26
|
function detectRuntimes(): Runtimes {
|
|
37
27
|
let python3: string | undefined;
|
|
@@ -81,18 +71,6 @@ function scrubPath(text: string, file: string, dir: string): string {
|
|
|
81
71
|
return text.replaceAll(file, "<script>").replaceAll(dir, "<tmpdir>");
|
|
82
72
|
}
|
|
83
73
|
|
|
84
|
-
function applyEdits(source: string, edits: ReadonlyArray<{ oldText: string; newText: string }>): string {
|
|
85
|
-
let next = source;
|
|
86
|
-
for (const edit of edits) {
|
|
87
|
-
const idx = next.indexOf(edit.oldText);
|
|
88
|
-
if (idx === -1) {
|
|
89
|
-
throw new Error("edits oldText not found. Copy exact text from the script you wrote.");
|
|
90
|
-
}
|
|
91
|
-
next = next.slice(0, idx) + edit.newText + next.slice(idx + edit.oldText.length);
|
|
92
|
-
}
|
|
93
|
-
return next;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
74
|
function renderEditsPreview(edits: ReadonlyArray<{ oldText: string; newText: string }>, theme: Theme): string {
|
|
97
75
|
return edits
|
|
98
76
|
.map((edit) => {
|
|
@@ -109,56 +87,6 @@ function renderEditsPreview(edits: ReadonlyArray<{ oldText: string; newText: str
|
|
|
109
87
|
.join("\n");
|
|
110
88
|
}
|
|
111
89
|
|
|
112
|
-
function resolveScriptSource(
|
|
113
|
-
scripts: Map<string, StoredScript>,
|
|
114
|
-
language: Language,
|
|
115
|
-
params: {
|
|
116
|
-
script?: string;
|
|
117
|
-
scriptId?: string;
|
|
118
|
-
edits?: ReadonlyArray<{ oldText: string; newText: string }>;
|
|
119
|
-
},
|
|
120
|
-
): { scriptId: string; source: string } {
|
|
121
|
-
const edits = params.edits;
|
|
122
|
-
if (edits && edits.length > 0) {
|
|
123
|
-
const scriptId = params.scriptId;
|
|
124
|
-
if (!scriptId) throw new Error("edits require scriptId from the failed run.");
|
|
125
|
-
const stored = scripts.get(scriptId);
|
|
126
|
-
if (!stored) throw new Error(`No stored script for scriptId ${scriptId}. Evicted; resend full script.`);
|
|
127
|
-
if (stored.language !== language) {
|
|
128
|
-
throw new Error(`Language mismatch: scriptId ${scriptId} is ${stored.language}, not ${language}.`);
|
|
129
|
-
}
|
|
130
|
-
return { scriptId, source: applyEdits(stored.source, edits) };
|
|
131
|
-
}
|
|
132
|
-
if (typeof params.script !== "string" || params.script.length === 0) {
|
|
133
|
-
throw new Error("Provide script, or edits + scriptId.");
|
|
134
|
-
}
|
|
135
|
-
return { scriptId: params.scriptId ?? newScriptId(), source: params.script };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function formatTruncatedTail(text: string): { body: string; note: string } {
|
|
139
|
-
const trunc = truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
140
|
-
const note = trunc.truncated
|
|
141
|
-
? `\n\n[output truncated: kept tail ${trunc.outputLines} / ${trunc.totalLines} lines]`
|
|
142
|
-
: "";
|
|
143
|
-
return { body: trunc.content, note };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function successScriptResult(stdout: string): { content: [{ type: "text"; text: string }]; details: undefined } {
|
|
147
|
-
const { body, note } = formatTruncatedTail(stdout.trim());
|
|
148
|
-
const out = body.trim();
|
|
149
|
-
return {
|
|
150
|
-
content: [{ type: "text", text: out ? `${out}${note}` : `(no output)${note}` }],
|
|
151
|
-
details: undefined,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function throwScriptFailure(scriptId: string, result: { stdout: string; stderr: string }): never {
|
|
156
|
-
const diag = result.stderr.trim() || result.stdout.trim();
|
|
157
|
-
const { body, note } = formatTruncatedTail(diag);
|
|
158
|
-
const detail = body ? `${body}${note}\n\n` : "";
|
|
159
|
-
throw new Error(`${detail}scriptId: ${scriptId}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
90
|
export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
163
91
|
const runtimes = detectRuntimes();
|
|
164
92
|
const detected = (["python3", "node", "deno"] as const).filter(
|
|
@@ -168,16 +96,15 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
168
96
|
|
|
169
97
|
const langPhrase = formatLangList(detected);
|
|
170
98
|
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
99
|
+
const scriptStore = createScriptSourceStore();
|
|
100
|
+
const temporaryOutput = createTemporaryOutputStore();
|
|
101
|
+
onTauEventImmediately(pi, "script-runner.source-store", "tau:script-runner.source-store", ({ accept }) =>
|
|
102
|
+
accept(scriptStore),
|
|
103
|
+
);
|
|
104
|
+
pi.on("session_start", async () => {
|
|
105
|
+
await temporaryOutput.shutdown();
|
|
106
|
+
await temporaryOutput.start();
|
|
107
|
+
});
|
|
181
108
|
|
|
182
109
|
function resolveCommand(language: Language): string {
|
|
183
110
|
const cmd = runtimes[language];
|
|
@@ -265,24 +192,41 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
265
192
|
"script_runner never exposes the script path; you already have the source. Never try to read it back.",
|
|
266
193
|
],
|
|
267
194
|
parameters: paramsSchema,
|
|
268
|
-
async execute(
|
|
195
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
269
196
|
const language = params.language;
|
|
270
197
|
if (signal?.aborted) {
|
|
271
198
|
return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
|
|
272
199
|
}
|
|
200
|
+
scriptStore.verifyAndConsume(toolCallId, params);
|
|
273
201
|
const command = resolveCommand(language);
|
|
274
|
-
const
|
|
275
|
-
|
|
202
|
+
const resolved = scriptStore.resolve(params);
|
|
203
|
+
const scriptId = resolved.scriptId ?? newScriptId();
|
|
204
|
+
const source = resolved.source;
|
|
205
|
+
scriptStore.remember(scriptId, language, source);
|
|
276
206
|
await onUpdate?.({
|
|
277
207
|
content: [{ type: "text", text: `Running ${languageLabel(language)}...` }],
|
|
278
208
|
details: undefined,
|
|
279
209
|
});
|
|
280
210
|
const result = await runScript(language, command, source, ctx.cwd, signal);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
211
|
+
const succeeded = result.code === 0 && !result.killed;
|
|
212
|
+
const output = new BoundedTextResultBuilder(temporaryOutput, "tail");
|
|
213
|
+
let content: string;
|
|
214
|
+
try {
|
|
215
|
+
await output.append(succeeded ? result.stdout.trim() : result.stderr.trim() || result.stdout.trim());
|
|
216
|
+
if (signal?.aborted) {
|
|
217
|
+
await output.abort();
|
|
218
|
+
return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
|
|
219
|
+
}
|
|
220
|
+
content = (await output.finish()).content;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await output.abort();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
if (succeeded) {
|
|
226
|
+
scriptStore.forget(scriptId);
|
|
227
|
+
return { content: [{ type: "text", text: content.trim() || "(no output)" }], details: undefined };
|
|
284
228
|
}
|
|
285
|
-
|
|
229
|
+
throw new Error(`${content ? `${content}\n\n` : ""}scriptId: ${scriptId}`);
|
|
286
230
|
},
|
|
287
231
|
renderCall(args, theme, context) {
|
|
288
232
|
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
@@ -312,7 +256,8 @@ export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
|
312
256
|
|
|
313
257
|
pi.registerTool(tool);
|
|
314
258
|
|
|
315
|
-
pi.on("session_shutdown", () => {
|
|
316
|
-
|
|
259
|
+
pi.on("session_shutdown", async () => {
|
|
260
|
+
scriptStore.clear();
|
|
261
|
+
await temporaryOutput.shutdown();
|
|
317
262
|
});
|
|
318
263
|
}
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Reviews agent `bash` and `script_runner` requests before they run.
|
|
4
4
|
|
|
5
|
-
Common read-only bash commands skip review and run immediately. Other bash and every `script_runner` request go to a separate reviewer. When the agent requests several tools at once, Tau reviews up to three requests concurrently, with one model review focused on each request. Tau uses the reviewer model for the current provider, then the current chat model if that reviewer is unavailable or fails. If a reviewer model is unavailable or fails, Tau notifies and tries the next one. The reviewer returns a validated decision and one concise paragraph that explains the request.
|
|
5
|
+
Common read-only bash commands skip review and run immediately. Other bash and every `script_runner` request go to a separate reviewer. For a `script_runner` retry with `scriptId` and edits, Tau reconstructs the full resulting script before review and runs that exact source after approval. A retry with missing or invalid stored source is blocked. When the agent requests several tools at once, Tau reviews up to three requests concurrently, with one model review focused on each request. Tau uses the reviewer model for the current provider, then the current chat model if that reviewer is unavailable or fails. If a reviewer model is unavailable or fails, Tau notifies and tries the next one. The reviewer returns a validated decision and one concise paragraph that explains the request.
|
|
6
6
|
|
|
7
7
|
With `autoApprove` enabled, reviewer-approved requests run without another confirmation. Tau shows a user-only marker with the reviewer model after those auto-approvals. Common read-only bash that skips review does not get a marker. Routine local development work should be approved, including requests that modify project files or run scripts. The reviewer asks for human approval only when it finds a concrete destructive, system, production, privileged, or security-sensitive effect.
|
|
8
8
|
|
|
9
|
-
When approval is required, Tau shows one paragraph that explains the effect and risk without repeating the request. If several requests need approval, their confirmation windows open one at a time. If the reviewer fails or returns a malformed decision, Tau asks for direct human approval instead of running it automatically. Tau also sends an attention notification when the approval window opens.
|
|
9
|
+
When approval is required, Tau shows one paragraph that explains the effect and risk without repeating the request. For `script_runner`, human confirmation also shows the complete script that will run. If several requests need approval, their confirmation windows open one at a time. If the reviewer fails or returns a malformed decision, Tau asks for direct human approval instead of running it automatically. Tau also sends an attention notification when the approval window opens.
|
|
10
10
|
|
|
11
|
-
In the terminal approval panel, move between Approve and Reject, press `n` to add a note to the highlighted choice, then press Enter to choose. Enter saves an edited note before choosing; Escape cancels note editing or blocks the request from the choice list. A rejection note tells the agent why the request was blocked. An approval note reaches the agent with the tool result; it does not change the request being approved. To ask for a different request, reject it with a note. Long notes are truncated. RPC clients use the standard confirmation dialog without notes.
|
|
11
|
+
In the terminal approval panel, move between Approve and Reject, press `j` or `k` to scroll a script, press `n` to add a note to the highlighted choice, then press Enter to choose. Enter saves an edited note before choosing; Escape cancels note editing or blocks the request from the choice list. A rejection note tells the agent why the request was blocked. An approval note reaches the agent with the tool result; it does not change the request being approved. To ask for a different request, reject it with a note. Long notes are truncated. RPC clients use the standard confirmation dialog without notes.
|
|
12
12
|
|
|
13
13
|
Configure under `extensions.toolApproval`:
|
|
14
14
|
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
import { Marker } from "@shanepadgett/tau-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import { emitAgentBlocked } from "../../shared/agent-blocked.ts";
|
|
11
|
+
import { emitTauEvent } from "../../shared/events.ts";
|
|
11
12
|
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
13
|
+
import type { ScriptSourceStore } from "../../shared/script-source.ts";
|
|
12
14
|
import { errorText, truncAt } from "../../shared/text.ts";
|
|
13
15
|
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
14
16
|
import { isAllowlistedBash } from "./allowlist.ts";
|
|
@@ -112,10 +114,16 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
112
114
|
async function requestToolApproval(
|
|
113
115
|
ctx: ExtensionContext,
|
|
114
116
|
toolCallId: string,
|
|
115
|
-
|
|
117
|
+
request: ToolApprovalRequest,
|
|
116
118
|
title: string,
|
|
117
119
|
body: string,
|
|
118
120
|
): Promise<{ block: true; reason: string } | undefined> {
|
|
121
|
+
const toolName = request.toolName;
|
|
122
|
+
const source =
|
|
123
|
+
toolName === "script_runner" && typeof request.input.script === "string" ? request.input.script : undefined;
|
|
124
|
+
if (toolName === "script_runner" && source === undefined) {
|
|
125
|
+
return block("script_runner source is unavailable for manual approval");
|
|
126
|
+
}
|
|
119
127
|
if (!ctx.hasUI) return block(`${toolLabel(toolName)} needs confirmation, but interactive UI is unavailable`);
|
|
120
128
|
try {
|
|
121
129
|
emitAgentBlocked(pi, {
|
|
@@ -124,11 +132,14 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
124
132
|
source: "tool-approval.review",
|
|
125
133
|
});
|
|
126
134
|
if (ctx.mode !== "tui") {
|
|
127
|
-
const confirmed = await ctx.ui.confirm(
|
|
135
|
+
const confirmed = await ctx.ui.confirm(
|
|
136
|
+
title,
|
|
137
|
+
source === undefined ? body : `${body}\n\nFull script:\n${source}`,
|
|
138
|
+
);
|
|
128
139
|
return confirmed ? undefined : block(`${toolLabel(toolName)} rejected by user`);
|
|
129
140
|
}
|
|
130
141
|
const answer = await ctx.ui.custom<ApprovalAnswer | undefined>(
|
|
131
|
-
(tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, done),
|
|
142
|
+
(tui, theme, keys, done) => new ToolApprovalPanel(tui, theme, keys, title, body, source, done),
|
|
132
143
|
);
|
|
133
144
|
if (!answer) return block(`${toolLabel(toolName)} approval cancelled by user`);
|
|
134
145
|
const note = truncAt(answer.note, 800);
|
|
@@ -147,6 +158,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
147
158
|
pi.on("session_start", async (_event, ctx) => {
|
|
148
159
|
batchReviews = undefined;
|
|
149
160
|
pendingNotes.clear();
|
|
161
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
150
162
|
await refreshSettings(ctx);
|
|
151
163
|
});
|
|
152
164
|
|
|
@@ -161,6 +173,17 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
161
173
|
return block(`tool approval settings failed to load: ${truncAt(message, 600)}`);
|
|
162
174
|
}
|
|
163
175
|
if (!settings.enabled) return undefined;
|
|
176
|
+
const scriptStore = scriptSourceStoreFrom(pi);
|
|
177
|
+
if (request.toolName === "script_runner") {
|
|
178
|
+
try {
|
|
179
|
+
if (!scriptStore) return block("script_runner source store is unavailable for review");
|
|
180
|
+
const { source } = scriptStore.resolve(request.input);
|
|
181
|
+
request.input.script = source;
|
|
182
|
+
delete request.input.edits;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return block(`script_runner source could not be reviewed: ${errorText(error)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
164
187
|
|
|
165
188
|
if (request.toolName === "bash") {
|
|
166
189
|
const command = request.input.command;
|
|
@@ -174,7 +197,7 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
174
197
|
|
|
175
198
|
ctx.ui.setStatus(STATUS_KEY, `reviewing ${toolLabel(request.toolName)}`);
|
|
176
199
|
try {
|
|
177
|
-
batchReviews ??= await reviewAssistantRequests(ctx, event, request);
|
|
200
|
+
batchReviews ??= await reviewAssistantRequests(ctx, event, request, scriptStore);
|
|
178
201
|
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
179
202
|
const cached = batchReviews.get(event.toolCallId);
|
|
180
203
|
batchReviews.delete(event.toolCallId);
|
|
@@ -188,41 +211,51 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
188
211
|
}
|
|
189
212
|
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
190
213
|
const { review, provider, model } = result;
|
|
214
|
+
let rejected: { block: true; reason: string } | undefined;
|
|
191
215
|
if (review.decision === "requires_user_approval") {
|
|
192
|
-
|
|
216
|
+
rejected = await requestToolApproval(
|
|
193
217
|
ctx,
|
|
194
218
|
event.toolCallId,
|
|
195
|
-
request
|
|
219
|
+
request,
|
|
196
220
|
`Approve high-impact ${toolLabel(request.toolName)}?`,
|
|
197
221
|
formatApproval(review.summary, review.reason),
|
|
198
222
|
);
|
|
199
|
-
}
|
|
200
|
-
if (settings.autoApprove) {
|
|
223
|
+
} else if (settings.autoApprove) {
|
|
201
224
|
pi.appendEntry<AutoApprovedMarker>(AUTO_APPROVED_TYPE, {
|
|
202
225
|
toolName: request.toolName,
|
|
203
226
|
provider,
|
|
204
227
|
model,
|
|
205
228
|
});
|
|
206
|
-
|
|
229
|
+
} else {
|
|
230
|
+
rejected = await requestToolApproval(
|
|
231
|
+
ctx,
|
|
232
|
+
event.toolCallId,
|
|
233
|
+
request,
|
|
234
|
+
`Run reviewed ${toolLabel(request.toolName)}?`,
|
|
235
|
+
formatApproval(review.summary, "Automatic approval is disabled."),
|
|
236
|
+
);
|
|
207
237
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
event.toolCallId,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
formatApproval(review.summary, "Automatic approval is disabled."),
|
|
214
|
-
);
|
|
238
|
+
if (!rejected && request.toolName === "script_runner") {
|
|
239
|
+
if (!scriptStore) return block("script_runner source store is unavailable for approval");
|
|
240
|
+
scriptStore.approve(event.toolCallId, request.input);
|
|
241
|
+
}
|
|
242
|
+
return rejected;
|
|
215
243
|
} catch (error) {
|
|
216
244
|
if (ctx.signal?.aborted) return block("Tool review cancelled");
|
|
217
245
|
const message = singleLine(errorText(error));
|
|
218
246
|
ctx.ui.notify(`Tool review failed; manual approval required: ${truncAt(message, 600)}`, "warning");
|
|
219
|
-
|
|
247
|
+
const rejected = await requestToolApproval(
|
|
220
248
|
ctx,
|
|
221
249
|
event.toolCallId,
|
|
222
|
-
request
|
|
250
|
+
request,
|
|
223
251
|
`Automatic ${toolLabel(request.toolName)} review failed. Continue?`,
|
|
224
|
-
`The automatic review failed, so Tau could not summarize this ${toolLabel(request.toolName)}. Approve it only if you understand the request shown above.`,
|
|
252
|
+
`The automatic review failed, so Tau could not summarize this ${toolLabel(request.toolName)}. Approve it only if you understand ${request.toolName === "script_runner" ? "the full script below" : "the request shown above"}.`,
|
|
225
253
|
);
|
|
254
|
+
if (!rejected && request.toolName === "script_runner") {
|
|
255
|
+
if (!scriptStore) return block("script_runner source store is unavailable for approval");
|
|
256
|
+
scriptStore.approve(event.toolCallId, request.input);
|
|
257
|
+
}
|
|
258
|
+
return rejected;
|
|
226
259
|
} finally {
|
|
227
260
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
228
261
|
}
|
|
@@ -250,15 +283,29 @@ export default function toolApprovalExtension(pi: ExtensionAPI): void {
|
|
|
250
283
|
pi.on("agent_end", () => {
|
|
251
284
|
batchReviews = undefined;
|
|
252
285
|
pendingNotes.clear();
|
|
286
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
253
287
|
});
|
|
254
288
|
|
|
255
289
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
256
290
|
batchReviews = undefined;
|
|
257
291
|
pendingNotes.clear();
|
|
292
|
+
scriptSourceStoreFrom(pi)?.clearApprovals();
|
|
258
293
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
259
294
|
});
|
|
260
295
|
}
|
|
261
296
|
|
|
297
|
+
function scriptSourceStoreFrom(pi: ExtensionAPI): ScriptSourceStore | undefined {
|
|
298
|
+
let store: ScriptSourceStore | undefined;
|
|
299
|
+
let count = 0;
|
|
300
|
+
emitTauEvent(pi, "tau:script-runner.source-store", {
|
|
301
|
+
accept(candidate) {
|
|
302
|
+
store = candidate;
|
|
303
|
+
count++;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
return count === 1 ? store : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
262
309
|
function approvalRequest(event: ToolCallEvent): ToolApprovalRequest | undefined {
|
|
263
310
|
if (isToolCallEventType("bash", event)) return { toolName: "bash", input: event.input };
|
|
264
311
|
if (isToolCallEventType<"script_runner", Record<string, unknown>>("script_runner", event)) {
|
|
@@ -284,6 +331,7 @@ async function reviewAssistantRequests(
|
|
|
284
331
|
ctx: ExtensionContext,
|
|
285
332
|
event: ToolCallEvent,
|
|
286
333
|
currentRequest: ToolApprovalRequest,
|
|
334
|
+
scriptStore: ScriptSourceStore | undefined,
|
|
287
335
|
): Promise<Map<string, CachedReview>> {
|
|
288
336
|
const reviews = new Map<string, CachedReview>();
|
|
289
337
|
const assistant = ctx.sessionManager
|
|
@@ -308,6 +356,17 @@ async function reviewAssistantRequests(
|
|
|
308
356
|
const command = request.input.command;
|
|
309
357
|
if (typeof command !== "string" || !command.trim() || isAllowlistedBash(command)) return [];
|
|
310
358
|
}
|
|
359
|
+
if (request.toolName === "script_runner" && part.id !== event.toolCallId) {
|
|
360
|
+
if (!scriptStore) return [];
|
|
361
|
+
try {
|
|
362
|
+
const { source } = scriptStore.resolve(request.input);
|
|
363
|
+
request.input = { ...request.input, script: source };
|
|
364
|
+
delete request.input.edits;
|
|
365
|
+
} catch {
|
|
366
|
+
// The sibling's own validated tool call will reject invalid or missing source.
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
311
370
|
return [{ toolCallId: part.id, request, requestJson: JSON.stringify(request) }];
|
|
312
371
|
});
|
|
313
372
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
pushSavedNote,
|
|
16
16
|
rawHint,
|
|
17
17
|
renderNoteEditor,
|
|
18
|
+
ScrollableMarkdown,
|
|
18
19
|
ToolPanel,
|
|
19
20
|
type ToolPanelConfig,
|
|
20
21
|
wrapWithPrefix,
|
|
@@ -34,6 +35,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
34
35
|
private readonly noteEditor: Editor;
|
|
35
36
|
private readonly panelConfig: ToolPanelConfig;
|
|
36
37
|
private readonly panel: ToolPanel;
|
|
38
|
+
private readonly sourceView: ScrollableMarkdown | undefined;
|
|
37
39
|
private readonly notes: Record<ApprovalChoice, string> = { approve: "", reject: "" };
|
|
38
40
|
private choice: ApprovalChoice = "approve";
|
|
39
41
|
private editing = false;
|
|
@@ -45,6 +47,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
45
47
|
keys: KeybindingsManager,
|
|
46
48
|
title: string,
|
|
47
49
|
body: string,
|
|
50
|
+
scriptSource: string | undefined,
|
|
48
51
|
done: (answer: ApprovalAnswer | undefined) => void,
|
|
49
52
|
) {
|
|
50
53
|
this.tui = tui;
|
|
@@ -56,11 +59,16 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
56
59
|
this.notes[this.choice] = value.trim();
|
|
57
60
|
this.closeNote();
|
|
58
61
|
};
|
|
62
|
+
if (scriptSource !== undefined) {
|
|
63
|
+
const fenceLength = (scriptSource.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length + 1), 3);
|
|
64
|
+
const fence = "`".repeat(fenceLength);
|
|
65
|
+
this.sourceView = new ScrollableMarkdown(tui, `${fence}\n${scriptSource}\n${fence}`, 14);
|
|
66
|
+
}
|
|
59
67
|
this.panelConfig = {
|
|
60
68
|
title,
|
|
61
69
|
secondary: "Approve runs this request as shown. To change it, reject with a note.",
|
|
62
70
|
header: [body],
|
|
63
|
-
body: { render: (width) => this.renderChoices(width), invalidate: () =>
|
|
71
|
+
body: { render: (width) => this.renderChoices(width), invalidate: () => this.sourceView?.invalidate() },
|
|
64
72
|
footer: { kind: "hints", hints: this.hints() },
|
|
65
73
|
};
|
|
66
74
|
this.panel = new ToolPanel(theme, this.panelConfig);
|
|
@@ -90,7 +98,9 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
90
98
|
this.done(undefined);
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
|
-
if (this.
|
|
101
|
+
if (this.sourceView && (data === "j" || data === "k")) {
|
|
102
|
+
this.sourceView.scroll(data === "j" ? 1 : -1);
|
|
103
|
+
} else if (this.keys.matches(data, "tui.select.up") || this.keys.matches(data, "tui.select.down")) {
|
|
94
104
|
this.choice = this.choice === "approve" ? "reject" : "approve";
|
|
95
105
|
} else if (data === "n") {
|
|
96
106
|
this.editing = true;
|
|
@@ -113,6 +123,9 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
113
123
|
|
|
114
124
|
private renderChoices(width: number): string[] {
|
|
115
125
|
const lines: string[] = [];
|
|
126
|
+
if (this.sourceView) {
|
|
127
|
+
lines.push(this.theme.fg("muted", "Complete script:"), ...this.sourceView.render(width), "");
|
|
128
|
+
}
|
|
116
129
|
for (const choice of ["approve", "reject"] as const) {
|
|
117
130
|
const selected = choice === this.choice;
|
|
118
131
|
const prefix = selected ? this.theme.fg("accent", "→ ") : " ";
|
|
@@ -140,6 +153,7 @@ export class ToolApprovalPanel implements Component, Focusable {
|
|
|
140
153
|
? [bindingHint("tui.input.submit", "save"), bindingHint("tui.select.cancel", "cancel note")]
|
|
141
154
|
: [
|
|
142
155
|
bindingsHint(["tui.select.up", "tui.select.down"], "move"),
|
|
156
|
+
...(this.sourceView ? [rawHint("j/k", "scroll script")] : []),
|
|
143
157
|
bindingHint("tui.select.confirm", "choose"),
|
|
144
158
|
rawHint("n", "note"),
|
|
145
159
|
bindingHint("tui.select.cancel", "block"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ast-grep/wasm": "0.45.3",
|
|
38
|
-
"@shanepadgett/tau-tui": "0.44.
|
|
38
|
+
"@shanepadgett/tau-tui": "0.44.1",
|
|
39
39
|
"@vscode/tree-sitter-wasm": "0.3.1",
|
|
40
40
|
"image-size": "2.0.4",
|
|
41
41
|
"smol-toml": "1.8.0",
|
package/shared/events.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Tool } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ToolRowVisualState } from "./tool-row-state.js";
|
|
4
|
+
import type { ScriptSourceStore } from "./script-source.ts";
|
|
4
5
|
import type { FileInjectionRequest, PreparedFileInjection } from "../src/file-injection/index.ts";
|
|
5
6
|
|
|
6
7
|
export type TauAgentEvents = {
|
|
@@ -57,6 +58,10 @@ export type TauAgentEvents = {
|
|
|
57
58
|
request: FileInjectionRequest;
|
|
58
59
|
accept(preparation: Promise<PreparedFileInjection[]>): void;
|
|
59
60
|
};
|
|
61
|
+
/** @internal Script runner's live source store, used to review the code it will execute. */
|
|
62
|
+
"tau:script-runner.source-store": {
|
|
63
|
+
accept(store: ScriptSourceStore): void;
|
|
64
|
+
};
|
|
60
65
|
"tau:footer-item": {
|
|
61
66
|
id: string;
|
|
62
67
|
text?: string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
type Language = "python3" | "node" | "deno";
|
|
2
|
+
|
|
3
|
+
interface StoredScript {
|
|
4
|
+
language: Language;
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScriptSourceStore {
|
|
9
|
+
resolve(input: Record<string, unknown>): { scriptId: string | undefined; source: string };
|
|
10
|
+
remember(scriptId: string, language: Language, source: string): void;
|
|
11
|
+
forget(scriptId: string): void;
|
|
12
|
+
approve(toolCallId: string, input: Record<string, unknown>): void;
|
|
13
|
+
verifyAndConsume(toolCallId: string, input: Record<string, unknown>): void;
|
|
14
|
+
clearApprovals(): void;
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_STORED = 8;
|
|
19
|
+
|
|
20
|
+
export function createScriptSourceStore(): ScriptSourceStore {
|
|
21
|
+
const scripts = new Map<string, StoredScript>();
|
|
22
|
+
const approvals = new Map<string, string>();
|
|
23
|
+
const store: ScriptSourceStore = {
|
|
24
|
+
resolve(input) {
|
|
25
|
+
const language = input.language;
|
|
26
|
+
if (language !== "python3" && language !== "node" && language !== "deno") {
|
|
27
|
+
throw new Error("Invalid script_runner language.");
|
|
28
|
+
}
|
|
29
|
+
const scriptId = input.scriptId;
|
|
30
|
+
if (scriptId !== undefined && typeof scriptId !== "string") {
|
|
31
|
+
throw new Error("Invalid scriptId.");
|
|
32
|
+
}
|
|
33
|
+
const edits = input.edits;
|
|
34
|
+
if (edits !== undefined && !Array.isArray(edits)) throw new Error("Invalid script edits.");
|
|
35
|
+
if (Array.isArray(edits) && edits.length > 0) {
|
|
36
|
+
if (!scriptId) throw new Error("edits require scriptId from the failed run.");
|
|
37
|
+
const stored = scripts.get(scriptId);
|
|
38
|
+
if (!stored) throw new Error(`No stored script for scriptId ${scriptId}. Evicted; resend full script.`);
|
|
39
|
+
if (stored.language !== language) {
|
|
40
|
+
throw new Error(`Language mismatch: scriptId ${scriptId} is ${stored.language}, not ${language}.`);
|
|
41
|
+
}
|
|
42
|
+
let source = stored.source;
|
|
43
|
+
for (const edit of edits) {
|
|
44
|
+
if (
|
|
45
|
+
!edit ||
|
|
46
|
+
typeof edit !== "object" ||
|
|
47
|
+
typeof edit.oldText !== "string" ||
|
|
48
|
+
typeof edit.newText !== "string"
|
|
49
|
+
) {
|
|
50
|
+
throw new Error("Invalid script edit.");
|
|
51
|
+
}
|
|
52
|
+
const idx = source.indexOf(edit.oldText);
|
|
53
|
+
if (idx === -1) throw new Error("edits oldText not found. Copy exact text from the script you wrote.");
|
|
54
|
+
source = source.slice(0, idx) + edit.newText + source.slice(idx + edit.oldText.length);
|
|
55
|
+
}
|
|
56
|
+
return { scriptId, source };
|
|
57
|
+
}
|
|
58
|
+
if (typeof input.script !== "string" || (input.script.length === 0 && !scriptId)) {
|
|
59
|
+
throw new Error("Provide script, or edits + scriptId.");
|
|
60
|
+
}
|
|
61
|
+
return { scriptId, source: input.script };
|
|
62
|
+
},
|
|
63
|
+
remember(scriptId, language, source) {
|
|
64
|
+
scripts.set(scriptId, { language, source });
|
|
65
|
+
while (scripts.size > MAX_STORED) {
|
|
66
|
+
const oldest = scripts.keys().next().value;
|
|
67
|
+
if (oldest === undefined) break;
|
|
68
|
+
scripts.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
forget(scriptId) {
|
|
72
|
+
scripts.delete(scriptId);
|
|
73
|
+
},
|
|
74
|
+
approve(toolCallId, input) {
|
|
75
|
+
approvals.set(toolCallId, JSON.stringify(input));
|
|
76
|
+
},
|
|
77
|
+
verifyAndConsume(toolCallId, input) {
|
|
78
|
+
const approved = approvals.get(toolCallId);
|
|
79
|
+
if (approved === undefined) return;
|
|
80
|
+
approvals.delete(toolCallId);
|
|
81
|
+
if (approved !== JSON.stringify(input)) {
|
|
82
|
+
throw new Error("script_runner request changed after approval; blocked.");
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
clearApprovals() {
|
|
86
|
+
approvals.clear();
|
|
87
|
+
},
|
|
88
|
+
clear() {
|
|
89
|
+
scripts.clear();
|
|
90
|
+
approvals.clear();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return store;
|
|
94
|
+
}
|