@andares/pi-toolkits 0.9.5 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/README.zh-CN.md +17 -9
- package/package.json +6 -2
- package/src/features/ask/constants.ts +12 -0
- package/src/features/ask/index.test.ts +158 -15
- package/src/features/ask/index.ts +151 -27
- package/src/features/ask/prompt.test.ts +34 -22
- package/src/features/ask/prompt.ts +26 -39
- package/src/features/ask/reasons.test.ts +42 -3
- package/src/features/ask/reasons.ts +68 -23
- package/src/features/ask/scratch-tool.test.ts +161 -0
- package/src/features/ask/scratch-tool.ts +195 -0
- package/src/features/ask/scratch.test.ts +172 -0
- package/src/features/ask/scratch.ts +324 -0
- package/src/features/permission/index.test.ts +27 -3
- package/src/features/permission/index.ts +6 -0
- package/src/lib/tools.ts +48 -0
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ask feature — read-only
|
|
2
|
+
* ask feature — read-only consult mode.
|
|
3
3
|
*
|
|
4
|
-
* Design notes (see PLAN.md):
|
|
5
|
-
* -
|
|
6
|
-
* via pi.setActiveTools()
|
|
7
|
-
* ("Tool not found")
|
|
4
|
+
* Design notes (see PLAN.md and AGENTS.md):
|
|
5
|
+
* - Project writes stay impossible: write/edit/powershell are removed from the
|
|
6
|
+
* active tool set via pi.setActiveTools(), and pi's runtime rejects calls to
|
|
7
|
+
* non-active tools ("Tool not found"). A gate blocks them again as defence in
|
|
8
|
+
* depth, in case anything re-enables them.
|
|
9
|
+
* - The mode is not "no writes at all": a consult session may need somewhere to
|
|
10
|
+
* keep an outline or a checklist, and confiscating temp files made the mode
|
|
11
|
+
* hostile enough that it went unused. One write channel is allowed —
|
|
12
|
+
* `scratchpad`, which takes a relative note name and can only reach
|
|
13
|
+
* `<os.tmpdir()>/pi-ask/<session>`. It is registered `defaultActive: false`
|
|
14
|
+
* and activated for the duration, so no path ever arrives from the model and
|
|
15
|
+
* the bash sandbox needs no write allowance. When no writable temp dir
|
|
16
|
+
* exists, the tool stays inactive and ask mode degrades to pure read-only.
|
|
8
17
|
* - Read-only helper tools (grep/find/ls) are activated for the duration so
|
|
9
18
|
* the model gets proper search tools; the entry snapshot restores exactly.
|
|
10
19
|
* - bash stays available but is gated by a read-only sandbox
|
|
@@ -13,14 +22,20 @@
|
|
|
13
22
|
* replacing it: each feature owns its own concern and the call is blocked
|
|
14
23
|
* when either objects.
|
|
15
24
|
* - Mode banner appended to the system prompt via before_agent_start
|
|
16
|
-
*
|
|
25
|
+
* (see prompt.ts). It is short on purpose: a mode line, the scratch rule, and
|
|
26
|
+
* the exit path. Everything else is learned from tool declarations and from
|
|
27
|
+
* rejection reasons, which cost tokens only when they apply.
|
|
17
28
|
*/
|
|
18
29
|
import type {
|
|
19
30
|
ExtensionAPI,
|
|
20
31
|
ExtensionContext,
|
|
21
32
|
} from "@earendil-works/pi-coding-agent";
|
|
22
33
|
import type { GateHub } from "../../lib/gates.js";
|
|
34
|
+
// The jailed-tool registry lives here too: ask marks `scratchpad` while the mode
|
|
35
|
+
// is active so the boundary gate (`permission`) knows it has no path to judge.
|
|
23
36
|
import {
|
|
37
|
+
clearJailedTool,
|
|
38
|
+
markJailedTool,
|
|
24
39
|
restoreActiveTools,
|
|
25
40
|
snapshotActiveTools,
|
|
26
41
|
withoutTools,
|
|
@@ -28,17 +43,76 @@ import {
|
|
|
28
43
|
import {
|
|
29
44
|
ASK_BLOCKED_TOOLS,
|
|
30
45
|
ASK_READ_ONLY_HELPERS,
|
|
46
|
+
ASK_SCRATCH_TOOL,
|
|
31
47
|
ASK_STATUS_KEY,
|
|
32
48
|
ASK_STATUS_LABEL,
|
|
33
49
|
} from "./constants.js";
|
|
34
50
|
import { isReadOnlyCommand } from "./bash-sandbox.js";
|
|
35
51
|
import { appendAskBanner } from "./prompt.js";
|
|
36
52
|
import { blockedBashReason, blockedToolReason } from "./reasons.js";
|
|
53
|
+
import { createScratchStore, type ScratchStore } from "./scratch.js";
|
|
54
|
+
import { registerScratchTool } from "./scratch-tool.js";
|
|
37
55
|
|
|
38
|
-
export
|
|
56
|
+
export interface AskOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Base directory for the scratch space. Default: the OS temp dir. Tests
|
|
59
|
+
* inject a fresh directory so they never touch the real temp space.
|
|
60
|
+
*/
|
|
61
|
+
readonly scratchBaseDir?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Session id, or a process-scoped fallback when the host exposes none. */
|
|
65
|
+
function sessionIdOf(ctx: ExtensionContext): string {
|
|
66
|
+
try {
|
|
67
|
+
return ctx.sessionManager.getSessionId();
|
|
68
|
+
} catch {
|
|
69
|
+
return `pid-${process.pid}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function registerAsk(
|
|
74
|
+
pi: ExtensionAPI,
|
|
75
|
+
gates: GateHub,
|
|
76
|
+
options: AskOptions = {},
|
|
77
|
+
): void {
|
|
39
78
|
let enabled = false;
|
|
40
79
|
/** Active tools before ask mode was entered (for exact restore on exit). */
|
|
41
80
|
let snapshot: string[] = [];
|
|
81
|
+
/** Scratch space of this session, or undefined when unavailable. */
|
|
82
|
+
let scratch: ScratchStore | undefined;
|
|
83
|
+
/** Session id the scratch store is bound to (rebound on session switch). */
|
|
84
|
+
let scratchSessionId: string | undefined;
|
|
85
|
+
|
|
86
|
+
// The tool is registered up front but stays inactive: activating it on entry
|
|
87
|
+
// is what puts its declaration (and guidelines) into the request.
|
|
88
|
+
registerScratchTool(pi, () => scratch);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create the scratch store for `sessionId` and bind the state. On failure the
|
|
92
|
+
* mode runs degraded (no scratch tool), with a warning.
|
|
93
|
+
*/
|
|
94
|
+
function bindScratch(ctx: ExtensionContext, sessionId: string): void {
|
|
95
|
+
try {
|
|
96
|
+
scratch = createScratchStore({
|
|
97
|
+
sessionId,
|
|
98
|
+
baseDir: options.scratchBaseDir,
|
|
99
|
+
});
|
|
100
|
+
scratchSessionId = sessionId;
|
|
101
|
+
// Self-jailed: permission has no boundary to judge for this tool.
|
|
102
|
+
markJailedTool(ASK_SCRATCH_TOOL);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// No usable temp dir (read-only TMPDIR, locked-down container, …): fall
|
|
105
|
+
// back to the fully read-only mode rather than half-opening one.
|
|
106
|
+
scratch = undefined;
|
|
107
|
+
scratchSessionId = undefined;
|
|
108
|
+
clearJailedTool(ASK_SCRATCH_TOOL);
|
|
109
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
110
|
+
ctx.ui.notify(
|
|
111
|
+
`Ask mode: no scratch space (${reason}) — fully read-only.`,
|
|
112
|
+
"warning",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
42
116
|
|
|
43
117
|
/** Gray footer status while ask mode is active; cleared on exit. */
|
|
44
118
|
function updateStatus(ctx: ExtensionContext): void {
|
|
@@ -48,30 +122,42 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
48
122
|
);
|
|
49
123
|
}
|
|
50
124
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
125
|
+
/**
|
|
126
|
+
* The tool set ask mode pins: entry snapshot minus the confiscated tools, plus
|
|
127
|
+
* the read-only helpers and the scratch tool.
|
|
128
|
+
*
|
|
129
|
+
* Recomputed (not just applied on entry) because `setActiveTools()` during
|
|
130
|
+
* `session_start` — which is how autostart enters the mode — is overwritten
|
|
131
|
+
* by the host's loadout preparation for the first run: the mode banner still
|
|
132
|
+
* appears, but the run would not declare `scratchpad` (or the helpers).
|
|
133
|
+
* Re-asserting in `before_agent_start` makes the mode's tool surface
|
|
134
|
+
* deterministic for every run.
|
|
135
|
+
*/
|
|
136
|
+
function askToolSet(): string[] {
|
|
137
|
+
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
138
|
+
const helpers = ASK_READ_ONLY_HELPERS.filter(
|
|
139
|
+
(name) => registered.has(name) && !snapshot.includes(name),
|
|
140
|
+
);
|
|
141
|
+
const tools = [...withoutTools(snapshot, ASK_BLOCKED_TOOLS), ...helpers];
|
|
142
|
+
if (scratch !== undefined && registered.has(ASK_SCRATCH_TOOL)) {
|
|
143
|
+
tools.push(ASK_SCRATCH_TOOL);
|
|
144
|
+
}
|
|
145
|
+
return tools;
|
|
55
146
|
}
|
|
56
147
|
|
|
148
|
+
// Synchronous on purpose: creating the scratch store is sync (see scratch.ts),
|
|
149
|
+
// so a `/ask` dispatched from `session_start` completes before the first run.
|
|
57
150
|
function enter(ctx: ExtensionContext): void {
|
|
58
151
|
if (enabled) return;
|
|
59
152
|
enabled = true;
|
|
60
153
|
snapshot = snapshotActiveTools(pi);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// by setActiveTools, but filtering here keeps the intent explicit.
|
|
64
|
-
const registered = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
65
|
-
const helpers = ASK_READ_ONLY_HELPERS.filter(
|
|
66
|
-
(name) => registered.has(name) && !snapshot.includes(name),
|
|
67
|
-
);
|
|
68
|
-
pi.setActiveTools([
|
|
69
|
-
...withoutTools(snapshot, ASK_BLOCKED_TOOLS),
|
|
70
|
-
...helpers,
|
|
71
|
-
]);
|
|
154
|
+
bindScratch(ctx, sessionIdOf(ctx));
|
|
155
|
+
pi.setActiveTools(askToolSet());
|
|
72
156
|
updateStatus(ctx);
|
|
73
157
|
ctx.ui.notify(
|
|
74
|
-
|
|
158
|
+
scratch === undefined
|
|
159
|
+
? "Ask mode ON — read-only, no scratch space. /ask to exit."
|
|
160
|
+
: `Ask mode ON — read-only; notes go to ${scratch.root}. /ask to exit.`,
|
|
75
161
|
"info",
|
|
76
162
|
);
|
|
77
163
|
}
|
|
@@ -79,6 +165,9 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
79
165
|
function exit(ctx: ExtensionContext): void {
|
|
80
166
|
if (!enabled) return;
|
|
81
167
|
enabled = false;
|
|
168
|
+
clearJailedTool(ASK_SCRATCH_TOOL);
|
|
169
|
+
scratch = undefined;
|
|
170
|
+
scratchSessionId = undefined;
|
|
82
171
|
restoreActiveTools(pi, snapshot);
|
|
83
172
|
snapshot = [];
|
|
84
173
|
updateStatus(ctx);
|
|
@@ -94,16 +183,36 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
94
183
|
}
|
|
95
184
|
|
|
96
185
|
pi.registerCommand("ask", {
|
|
97
|
-
description: "Toggle ask mode (
|
|
186
|
+
description: "Toggle ask mode (consult mode: no project writes, scratch notes allowed)",
|
|
98
187
|
handler: async (_args, ctx) => {
|
|
99
188
|
toggle(ctx);
|
|
100
189
|
},
|
|
101
190
|
});
|
|
102
191
|
|
|
192
|
+
// The scratch space is per session: an in-process session switch (/new,
|
|
193
|
+
// /resume, fork) while the mode is on rebinds it to the new session id, so
|
|
194
|
+
// notes do not keep landing in the previous session's directory. A repeated
|
|
195
|
+
// emission for the same session (startup rebind) is a no-op.
|
|
196
|
+
pi.on("session_start", (_event, ctx) => {
|
|
197
|
+
if (!enabled) return;
|
|
198
|
+
const sessionId = sessionIdOf(ctx);
|
|
199
|
+
if (sessionId === scratchSessionId) return;
|
|
200
|
+
bindScratch(ctx, sessionId);
|
|
201
|
+
pi.setActiveTools(askToolSet());
|
|
202
|
+
if (scratch !== undefined) {
|
|
203
|
+
ctx.ui.notify(
|
|
204
|
+
`Ask mode: notes now go to ${scratch.root}.`,
|
|
205
|
+
"info",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
103
210
|
// Defense-in-depth gate while ask mode is active:
|
|
104
211
|
// - write/edit/powershell are already removed from the active set (runtime
|
|
105
212
|
// rejects them); this blocks them again in case anything re-enables them.
|
|
106
213
|
// - bash is kept available but only read-only commands pass the sandbox.
|
|
214
|
+
// Scratch notes are written by the `scratchpad` tool, not by bash, so the
|
|
215
|
+
// sandbox needs no write allowance and no path analysis.
|
|
107
216
|
// The rejection `reason` reaches the model as the tool result, so it names
|
|
108
217
|
// the nearest allowed alternative (see reasons.ts).
|
|
109
218
|
//
|
|
@@ -121,7 +230,9 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
121
230
|
if (ASK_BLOCKED_TOOLS.has(event.toolName)) {
|
|
122
231
|
return {
|
|
123
232
|
kind: "block",
|
|
124
|
-
reason: blockedToolReason(event.toolName
|
|
233
|
+
reason: blockedToolReason(event.toolName, {
|
|
234
|
+
scratch: scratch !== undefined,
|
|
235
|
+
}),
|
|
125
236
|
};
|
|
126
237
|
}
|
|
127
238
|
|
|
@@ -129,7 +240,12 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
129
240
|
const command =
|
|
130
241
|
typeof event.input.command === "string" ? event.input.command : "";
|
|
131
242
|
if (!isReadOnlyCommand(command)) {
|
|
132
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
kind: "block",
|
|
245
|
+
reason: blockedBashReason(command, {
|
|
246
|
+
scratch: scratch !== undefined,
|
|
247
|
+
}),
|
|
248
|
+
};
|
|
133
249
|
}
|
|
134
250
|
}
|
|
135
251
|
return undefined;
|
|
@@ -139,12 +255,20 @@ export function registerAsk(pi: ExtensionAPI, gates: GateHub): void {
|
|
|
139
255
|
// Mode banner: chained append to the current system prompt while enabled.
|
|
140
256
|
pi.on("before_agent_start", (event) => {
|
|
141
257
|
if (!enabled) return;
|
|
258
|
+
// Re-assert the mode's tool surface: entering from `session_start` (the
|
|
259
|
+
// autostart path) is followed by the host's loadout preparation for the
|
|
260
|
+
// first run, which would otherwise drop the tools set on entry.
|
|
261
|
+
// Documented contract: a handler may call setActiveTools() and the live
|
|
262
|
+
// loadout wins over the prepared selection.
|
|
263
|
+
pi.setActiveTools(askToolSet());
|
|
142
264
|
// event.systemPrompt is the current (possibly already modified by other
|
|
143
265
|
// extensions) system prompt — append, don't replace.
|
|
144
266
|
return {
|
|
145
267
|
systemPrompt: appendAskBanner(event.systemPrompt, {
|
|
146
|
-
|
|
268
|
+
scratch: scratch !== undefined,
|
|
147
269
|
}),
|
|
148
270
|
};
|
|
149
271
|
});
|
|
150
272
|
}
|
|
273
|
+
// (No other write path exists in ask mode: bash is read-only, and the scratch
|
|
274
|
+
// tool is the only thing that can create a file — inside its own temp dir.)
|
|
@@ -1,45 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the ask-mode banner
|
|
2
|
+
* Tests for the ask-mode banner.
|
|
3
|
+
*
|
|
4
|
+
* The banner is part of every request while the mode is on, so the contract has
|
|
5
|
+
* two halves: it must state the mode, the scratch allowance and the exit path,
|
|
6
|
+
* and it must stay small and byte-stable (the details live in the tool
|
|
7
|
+
* declaration and in rejection reasons).
|
|
3
8
|
*/
|
|
4
9
|
import { describe, expect, it } from "vitest";
|
|
5
10
|
import { appendAskBanner, buildAskBanner } from "./prompt.js";
|
|
6
11
|
|
|
7
12
|
describe("buildAskBanner", () => {
|
|
8
|
-
it("states the
|
|
9
|
-
const banner = buildAskBanner();
|
|
10
|
-
expect(banner).toContain("[ASK MODE
|
|
11
|
-
expect(banner).toContain("
|
|
13
|
+
it("states the mode, the scratch channel and the exit path", () => {
|
|
14
|
+
const banner = buildAskBanner({ scratch: true });
|
|
15
|
+
expect(banner).toContain("[ASK MODE]");
|
|
16
|
+
expect(banner).toContain("do not implement");
|
|
17
|
+
expect(banner).toContain("scratchpad");
|
|
18
|
+
expect(banner).toContain("is a deliverable");
|
|
12
19
|
expect(banner).toContain("/ask");
|
|
13
20
|
});
|
|
14
21
|
|
|
15
|
-
it("
|
|
16
|
-
const banner = buildAskBanner();
|
|
17
|
-
expect(banner).toContain("
|
|
18
|
-
expect(banner).toContain("
|
|
19
|
-
expect(banner).toContain("
|
|
22
|
+
it("drops the scratch line when the session has no scratch space", () => {
|
|
23
|
+
const banner = buildAskBanner({ scratch: false });
|
|
24
|
+
expect(banner).not.toContain("scratchpad");
|
|
25
|
+
expect(banner).toContain("[ASK MODE]");
|
|
26
|
+
expect(banner).toContain("/ask");
|
|
20
27
|
});
|
|
21
28
|
|
|
22
|
-
it("
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
expect(banner).toContain("
|
|
29
|
+
it("does not restate the bash allowlist", () => {
|
|
30
|
+
// The sandbox and reasons.ts own that list; per-request prose was the
|
|
31
|
+
// bulk of the old banner's ~300 tokens.
|
|
32
|
+
const banner = buildAskBanner({ scratch: true });
|
|
33
|
+
expect(banner).not.toContain("rev-parse");
|
|
34
|
+
expect(banner).not.toContain("sed -n");
|
|
35
|
+
expect(banner).not.toContain("allowlist");
|
|
27
36
|
});
|
|
28
37
|
|
|
29
|
-
it("
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
it("stays within the token budget", () => {
|
|
39
|
+
expect(buildAskBanner({ scratch: true }).length).toBeLessThan(500);
|
|
40
|
+
});
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
it("is byte-stable for the same state", () => {
|
|
43
|
+
// Anything per-turn here would break provider prompt caching.
|
|
44
|
+
expect(buildAskBanner({ scratch: true })).toBe(
|
|
45
|
+
buildAskBanner({ scratch: true }),
|
|
46
|
+
);
|
|
35
47
|
});
|
|
36
48
|
});
|
|
37
49
|
|
|
38
50
|
describe("appendAskBanner", () => {
|
|
39
51
|
it("appends instead of replacing the existing system prompt", () => {
|
|
40
52
|
const original = "You are a helpful coding agent.";
|
|
41
|
-
const combined = appendAskBanner(original, {
|
|
53
|
+
const combined = appendAskBanner(original, { scratch: true });
|
|
42
54
|
expect(combined.startsWith(original)).toBe(true);
|
|
43
|
-
expect(combined).toContain("[ASK MODE
|
|
55
|
+
expect(combined).toContain("[ASK MODE]");
|
|
44
56
|
});
|
|
45
57
|
});
|
|
@@ -2,54 +2,41 @@
|
|
|
2
2
|
* ask feature — system prompt banner.
|
|
3
3
|
*
|
|
4
4
|
* Appended to the system prompt (chained append, not replacement) while ask
|
|
5
|
-
* mode is active
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* mode is active. It is deliberately short: the system prompt is part of every
|
|
6
|
+
* request in a run, so the mode line is kept to a few sentences that stay
|
|
7
|
+
* byte-identical for the whole session (no paths, no timestamps, nothing that
|
|
8
|
+
* changes per turn) and therefore remains a stable, cacheable prefix. Details
|
|
9
|
+
* live where they are cheaper and more timely:
|
|
10
|
+
*
|
|
11
|
+
* - the scratchpad tool's own declaration and `promptGuidelines` carry the
|
|
12
|
+
* "notes, not deliverables" rule, and only while the tool is active;
|
|
13
|
+
* - rejected calls carry the allowed alternative (see reasons.ts), so a model
|
|
14
|
+
* that guesses wrong pays for the detail only on the turn it needs it;
|
|
15
|
+
* - the read-only bash allowlist is *not* spelled out here. That list used to
|
|
16
|
+
* be ~300 tokens per request restating what the sandbox already enforces.
|
|
17
|
+
*
|
|
18
|
+
* Do NOT use the removed `systemPromptAppend` field or wholesale systemPrompt
|
|
19
|
+
* replacement (see PLAN.md).
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
export interface AskBannerOptions {
|
|
12
|
-
/**
|
|
13
|
-
readonly
|
|
23
|
+
/** Whether the session has a usable scratch directory (tool active). */
|
|
24
|
+
readonly scratch?: boolean;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
const BANNER_HEADER = `[ASK MODE — read
|
|
17
|
-
You are in ask mode: answer questions and read code; nothing may be modified.
|
|
18
|
-
|
|
19
|
-
Hard-blocked (calls fail — no workaround): write, edit, powershell.
|
|
20
|
-
If fulfilling a request needs a change, say exactly what to change and tell the
|
|
21
|
-
user to exit ask mode with /ask first.
|
|
27
|
+
const BANNER_HEADER = `[ASK MODE] Consult only — read, answer, plan; do not implement.`;
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
- files: ls, cat, head, tail, sed -n, wc, file, stat, diff, tree
|
|
25
|
-
- search: grep, rg, fd, find (without -exec), jq
|
|
26
|
-
- git: status, log, diff, show, branch, remote, blame, grep, rev-parse,
|
|
27
|
-
describe, shortlog, reflog show, stash list, tag -l, cat-file
|
|
28
|
-
- network: curl -s <url>, wget -O - <url>
|
|
29
|
-
- packages: npm view, pnpm view, pip show/list, python3 -m json.tool
|
|
29
|
+
const SCRATCH_LINE = `Throwaway notes go through the \`scratchpad\` tool (a temp directory outside the project); nothing written there is a deliverable.`;
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
command substitution $(...) / backticks.`;
|
|
31
|
+
const BANNER_FOOTER = `Any other write is blocked, and a blocked call comes back with the allowed alternative — do not retry variants.
|
|
32
|
+
Exit: the operator runs /ask.`;
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** Build the banner, adapting the search hint to the active helper tools. */
|
|
34
|
+
/** Build the banner, dropping the scratch line when no scratch space exists. */
|
|
38
35
|
export function buildAskBanner(options: AskBannerOptions = {}): string {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
? `\n- search with the ${helperLabel} tools (active during ask mode)`
|
|
44
|
-
: "";
|
|
45
|
-
return `${BANNER_HEADER}
|
|
46
|
-
|
|
47
|
-
If a bash command is rejected, do not retry variants or invent workarounds:
|
|
48
|
-
- read file contents with the \`read\` tool instead of cat/sed tricks${helperLine}
|
|
49
|
-
- fetch a URL with \`curl -s <url>\` instead of wget
|
|
50
|
-
- otherwise tell the user which command is blocked and why
|
|
51
|
-
|
|
52
|
-
${BANNER_FOOTER}`;
|
|
36
|
+
const lines = [BANNER_HEADER];
|
|
37
|
+
if (options.scratch === true) lines.push(SCRATCH_LINE);
|
|
38
|
+
lines.push(BANNER_FOOTER);
|
|
39
|
+
return lines.join("\n");
|
|
53
40
|
}
|
|
54
41
|
|
|
55
42
|
/** Append the ask-mode banner to an existing system prompt. */
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for ask-mode rejection messages: each one must point at a next step
|
|
2
|
+
* Tests for ask-mode rejection messages: each one must point at a next step,
|
|
3
|
+
* and the write-shaped ones must point at `scratchpad` rather than at the exit
|
|
4
|
+
* command alone.
|
|
3
5
|
*/
|
|
4
6
|
import { describe, expect, it } from "vitest";
|
|
5
7
|
import { blockedBashReason, blockedToolReason } from "./reasons.js";
|
|
6
8
|
|
|
7
9
|
describe("blockedToolReason", () => {
|
|
8
|
-
it.each(["write", "edit"
|
|
9
|
-
"
|
|
10
|
+
it.each(["write", "edit"])(
|
|
11
|
+
"points %s at the scratch tool and the exit path",
|
|
10
12
|
(tool) => {
|
|
11
13
|
const reason = blockedToolReason(tool);
|
|
12
14
|
expect(reason).toContain(tool);
|
|
15
|
+
expect(reason).toContain("scratchpad");
|
|
13
16
|
expect(reason).toContain("/ask");
|
|
14
17
|
},
|
|
15
18
|
);
|
|
19
|
+
|
|
20
|
+
it("names the alternatives for powershell", () => {
|
|
21
|
+
const reason = blockedToolReason("powershell");
|
|
22
|
+
expect(reason).toContain("powershell");
|
|
23
|
+
expect(reason).toContain("scratchpad");
|
|
24
|
+
expect(reason).toContain("read-only bash");
|
|
25
|
+
});
|
|
16
26
|
});
|
|
17
27
|
|
|
18
28
|
describe("blockedBashReason", () => {
|
|
@@ -29,7 +39,36 @@ describe("blockedBashReason", () => {
|
|
|
29
39
|
expect(blockedBashReason(command)).toContain(needle);
|
|
30
40
|
});
|
|
31
41
|
|
|
42
|
+
it("points write-shaped rejections at the scratch tool", () => {
|
|
43
|
+
expect(blockedBashReason("cat a > b")).toContain("scratchpad");
|
|
44
|
+
expect(blockedBashReason("tee out.txt")).toContain("scratchpad");
|
|
45
|
+
});
|
|
46
|
+
|
|
32
47
|
it("always echoes the blocked command", () => {
|
|
33
48
|
expect(blockedBashReason("git push")).toContain("Command: git push");
|
|
34
49
|
});
|
|
35
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("degraded mode (no scratch space)", () => {
|
|
53
|
+
it("does not point write/edit at the unavailable scratch tool", () => {
|
|
54
|
+
for (const tool of ["write", "edit", "powershell"]) {
|
|
55
|
+
const reason = blockedToolReason(tool, { scratch: false });
|
|
56
|
+
expect(reason).not.toContain("scratchpad");
|
|
57
|
+
expect(reason).toContain("no scratch space");
|
|
58
|
+
expect(reason).toContain("/ask");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("does not point bash rejections at the unavailable scratch tool", () => {
|
|
63
|
+
for (const command of ["cat a > b", "tee out.txt", "wget https://x", "vim f"]) {
|
|
64
|
+
expect(blockedBashReason(command, { scratch: false })).not.toContain(
|
|
65
|
+
"scratchpad",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps pointing at scratchpad by default (scratch available)", () => {
|
|
71
|
+
expect(blockedToolReason("write")).toContain("scratchpad");
|
|
72
|
+
expect(blockedBashReason("cat a > b")).toContain("scratchpad");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -4,27 +4,60 @@
|
|
|
4
4
|
* The `tool_call` hook's `reason` is delivered to the model as the tool result
|
|
5
5
|
* (verified against pi 1.3.0: `extensions/runner.js` returns the `{block,
|
|
6
6
|
* reason}` result unchanged and pi-agent-core's `agent-loop.js` turns it into
|
|
7
|
-
* `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so
|
|
8
|
-
* is the best place to name the nearest allowed alternative and stop the model
|
|
9
|
-
* from retrying variants of a blocked command.
|
|
7
|
+
* `createErrorToolResult(reason)`; both paths are byte-identical to 1.2.0), so
|
|
8
|
+
* it is the best place to name the nearest allowed alternative and stop the model
|
|
9
|
+
* from retrying variants of a blocked command. That is also why the mode banner
|
|
10
|
+
* does not carry the allowlist: this path costs tokens only when it runs.
|
|
11
|
+
*
|
|
12
|
+
* When the session has no scratch space (degraded mode), the reasons must not
|
|
13
|
+
* point at `scratchpad` — the tool is inactive then, and sending the model to
|
|
14
|
+
* it would cost one wasted call per discovery.
|
|
10
15
|
*/
|
|
11
16
|
import { classifyBlockedCommand } from "./bash-sandbox.js";
|
|
12
17
|
|
|
18
|
+
export interface ReasonOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Whether the scratch tool is available. Default true: only the degraded
|
|
21
|
+
* no-temp-dir mode passes false, and only then is the scratch hint omitted.
|
|
22
|
+
*/
|
|
23
|
+
readonly scratch?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scratchClause(options: ReasonOptions, fallback: string): string {
|
|
27
|
+
return options.scratch === false
|
|
28
|
+
? `${fallback} This session has no scratch space, so notes cannot be saved either.`
|
|
29
|
+
: `${fallback} For throwaway planning notes use \`scratchpad\`.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
/** Reason for a hard-blocked tool call (write / edit / powershell). */
|
|
14
|
-
export function blockedToolReason(
|
|
33
|
+
export function blockedToolReason(
|
|
34
|
+
toolName: string,
|
|
35
|
+
options: ReasonOptions = {},
|
|
36
|
+
): string {
|
|
15
37
|
if (toolName === "powershell") {
|
|
16
|
-
return
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
38
|
+
return (
|
|
39
|
+
"Ask mode: powershell is disabled — ask mode does not change project " +
|
|
40
|
+
"files. Inspect files with the read/grep/find/ls tools or the read-only " +
|
|
41
|
+
"bash allowlist. " +
|
|
42
|
+
scratchClause(options, "") +
|
|
43
|
+
" If a real change is needed, tell the operator and ask them to exit ask " +
|
|
44
|
+
"mode with /ask."
|
|
45
|
+
);
|
|
20
46
|
}
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
"
|
|
47
|
+
return (
|
|
48
|
+
`Ask mode: ${toolName} is disabled — ask mode does not change project ` +
|
|
49
|
+
"files. " +
|
|
50
|
+
scratchClause(options, "") +
|
|
51
|
+
" If the answer needs a real change, explain what to change and ask the " +
|
|
52
|
+
"operator to exit ask mode with /ask."
|
|
53
|
+
);
|
|
24
54
|
}
|
|
25
55
|
|
|
26
56
|
/** Reason for a rejected bash command, classified by intent. */
|
|
27
|
-
export function blockedBashReason(
|
|
57
|
+
export function blockedBashReason(
|
|
58
|
+
command: string,
|
|
59
|
+
options: ReasonOptions = {},
|
|
60
|
+
): string {
|
|
28
61
|
const suffix = `\nCommand: ${command}`;
|
|
29
62
|
switch (classifyBlockedCommand(command)) {
|
|
30
63
|
case "substitution":
|
|
@@ -32,9 +65,12 @@ export function blockedBashReason(command: string): string {
|
|
|
32
65
|
"blocked because it can run arbitrary commands. Run the inner " +
|
|
33
66
|
"command directly instead." + suffix;
|
|
34
67
|
case "redirect":
|
|
35
|
-
return "Ask mode: output redirection is blocked — ask mode
|
|
36
|
-
"write files
|
|
37
|
-
|
|
68
|
+
return "Ask mode: output redirection is blocked — ask mode does not " +
|
|
69
|
+
"write project files. " +
|
|
70
|
+
(options.scratch === false
|
|
71
|
+
? "This session has no scratch space either; inspect files with the read/grep/find/ls tools."
|
|
72
|
+
: "Keep throwaway output as a note with `scratchpad` instead, and inspect files with the read/grep/find/ls tools.") +
|
|
73
|
+
suffix;
|
|
38
74
|
case "exec":
|
|
39
75
|
return "Ask mode: this command can run another program (git grep -O, " +
|
|
40
76
|
"git --ext-diff, find -exec/-ok, fd -x, rg --pre, env CMD). Only " +
|
|
@@ -42,27 +78,36 @@ export function blockedBashReason(command: string): string {
|
|
|
42
78
|
"command directly instead." + suffix;
|
|
43
79
|
case "write":
|
|
44
80
|
return "Ask mode: this command writes to disk (curl -o/-O, wget -O, " +
|
|
45
|
-
"dd of=, tee, sed -i, git --output, ...).
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
"dd of=, tee, sed -i, git --output, ...). " +
|
|
82
|
+
(options.scratch === false
|
|
83
|
+
? "There is no writable place in this session."
|
|
84
|
+
: "The only writable place is `scratchpad`, and it is written by the tool, not by bash.") +
|
|
85
|
+
" To read a URL without saving it use `curl -s <url>`; to read files " +
|
|
86
|
+
"use the `read` tool." +
|
|
87
|
+
suffix;
|
|
48
88
|
case "download":
|
|
49
89
|
return "Ask mode: downloads to disk are blocked. Use `curl -s <url>` " +
|
|
50
|
-
"or `wget -O - <url>` to read a URL to stdout instead
|
|
90
|
+
"or `wget -O - <url>` to read a URL to stdout instead" +
|
|
91
|
+
(options.scratch === false ? "." : ", and keep notes with `scratchpad`.") +
|
|
92
|
+
suffix;
|
|
51
93
|
case "git":
|
|
52
94
|
return "Ask mode: only read-only git commands are allowed (status, " +
|
|
53
95
|
"log, diff, show, blame, grep, rev-parse, describe, shortlog, " +
|
|
54
96
|
"reflog show, stash list, tag -l, cat-file). To change the " +
|
|
55
|
-
"repository, ask the
|
|
97
|
+
"repository, ask the operator to exit ask mode with /ask." + suffix;
|
|
56
98
|
case "install":
|
|
57
99
|
return "Ask mode: install/build commands are blocked. Read-only " +
|
|
58
100
|
"package queries (npm view, pnpm view, pip show/list) are allowed; " +
|
|
59
|
-
"to actually install or build, ask the
|
|
101
|
+
"to actually install or build, ask the operator to exit ask mode with " +
|
|
60
102
|
"/ask." + suffix;
|
|
61
103
|
default:
|
|
62
104
|
return "Ask mode: this command is not on the read-only allowlist " +
|
|
63
105
|
"(or is classified as destructive), so it was not executed — it may " +
|
|
64
106
|
"itself be read-only. Do not retry variants: use the " +
|
|
65
|
-
"read/grep/find/ls tools
|
|
66
|
-
|
|
107
|
+
"read/grep/find/ls tools" +
|
|
108
|
+
(options.scratch === false
|
|
109
|
+
? ", a recognised read-only bash command, or tell the operator the command is blocked."
|
|
110
|
+
: ", `scratchpad` for notes, a recognised read-only bash command, or tell the operator the command is blocked.") +
|
|
111
|
+
suffix;
|
|
67
112
|
}
|
|
68
113
|
}
|