@gotgenes/pi-permission-system 36.2.1 → 37.0.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 +17 -0
- package/README.md +6 -0
- package/docs/configuration.md +15 -13
- package/package.json +5 -5
- package/src/exposure/skill-prompt-sanitizer.ts +41 -93
- package/src/exposure/tool-surface-prompt.ts +34 -430
- package/src/handlers/before-agent-start.ts +64 -50
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [37.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.1...pi-permission-system-v37.0.0) (2026-10-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** **breaking:** require Pi 1.0.0 or later ([9c76ae3](https://github.com/gotgenes/pi-packages/commit/9c76ae30dd58b0a94524f753f7c72ff356231294)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** keep prompt sections other extensions add, such as <mcp_servers> ([1abdfe4](https://github.com/gotgenes/pi-packages/commit/1abdfe4901a27cb8ff3d85a965fc07b3be874624)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
|
|
18
|
+
* **pi-permission-system:** keep later prompt sections when a skill is denied ([dafd83b](https://github.com/gotgenes/pi-packages/commit/dafd83b075b01ef39335822f8b5122c56ee00b97)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** note the Pi 1.0.0 requirement under Upgrading ([5bc932c](https://github.com/gotgenes/pi-packages/commit/5bc932cf9936728c0e3c551fdbea6a7009108958)), closes [#970](https://github.com/gotgenes/pi-packages/issues/970)
|
|
23
|
+
* **pi-permission-system:** record prompt changes through systemPromptOptions ([b11051f](https://github.com/gotgenes/pi-packages/commit/b11051f1365a635b67e70dea126c8a49ff9ef31b)), closes [#999](https://github.com/gotgenes/pi-packages/issues/999)
|
|
24
|
+
|
|
8
25
|
## [36.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v36.2.0...pi-permission-system-v36.2.1) (2026-10-01)
|
|
9
26
|
|
|
10
27
|
|
package/README.md
CHANGED
|
@@ -155,6 +155,12 @@ Third-party packages are maintained by their authors; review one before granting
|
|
|
155
155
|
|
|
156
156
|
## Upgrading
|
|
157
157
|
|
|
158
|
+
### The major after 36.x — requires Pi 1.0.0
|
|
159
|
+
|
|
160
|
+
The extension now requires `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` 1.0.0 or later.
|
|
161
|
+
Upgrade Pi to 1.0.0 before upgrading this package; on an older Pi, stay on the 36.x line.
|
|
162
|
+
No configuration change is needed.
|
|
163
|
+
|
|
158
164
|
### 22.0.0 — project config requires project trust
|
|
159
165
|
|
|
160
166
|
Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
|
package/docs/configuration.md
CHANGED
|
@@ -1314,11 +1314,11 @@ permission:
|
|
|
1314
1314
|
|
|
1315
1315
|
The extension integrates via Pi's lifecycle hooks:
|
|
1316
1316
|
|
|
1317
|
-
| Hook | Behavior
|
|
1318
|
-
| -------------------- |
|
|
1319
|
-
| `before_agent_start` | Filters the active tool set (restrict-only),
|
|
1320
|
-
| `tool_call` | Enforces permissions for every tool invocation
|
|
1321
|
-
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy
|
|
1317
|
+
| Hook | Behavior |
|
|
1318
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1319
|
+
| `before_agent_start` | Filters the active tool set (restrict-only), states a subagent child's own tool list and rules, and hides denied skills, all through pi's prompt options rather than a rewritten prompt |
|
|
1320
|
+
| `tool_call` | Enforces permissions for every tool invocation |
|
|
1321
|
+
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
|
|
1322
1322
|
|
|
1323
1323
|
Additional behaviors:
|
|
1324
1324
|
|
|
@@ -1326,16 +1326,18 @@ Additional behaviors:
|
|
|
1326
1326
|
- Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
|
|
1327
1327
|
- Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
|
|
1328
1328
|
A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
|
|
1329
|
-
- On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
|
|
1330
1329
|
- A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
|
|
1331
|
-
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1330
|
+
- Every change this extension makes to the system prompt goes through pi's `systemPromptOptions`; it never returns a rewritten prompt.
|
|
1331
|
+
A returned prompt is sent as the whole prompt, which would drop the sections other extensions add after this one runs, such as pi's `<mcp_servers>` list of `codemode` and `deferred` MCP servers (see [ADR 0015](decisions/0015-prompt-changes-through-system-prompt-options.md)).
|
|
1332
|
+
- On pi's default prompt, the tool list and guidelines stay where pi writes them, as its `<tools>` and `<rules>` sections, and pi renders them from the filtered active set, so a denied tool and its guidelines are absent and a restored tool is listed on the turn it returns.
|
|
1333
|
+
- A custom system prompt (`.pi/SYSTEM.md`, `~/.pi/agent/SYSTEM.md`, `--system-prompt`) is left exactly as pi built it: pi writes no tool list or rules under one, and this extension adds none either, so the prompt describes tools only if you wrote that yourself.
|
|
1335
1334
|
Tool filtering and enforcement still apply, and the model still receives only the allowed tools in the request's tool list.
|
|
1336
|
-
A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it
|
|
1337
|
-
|
|
1338
|
-
-
|
|
1335
|
+
A subagent child is the exception: its prompt is always a custom one assembled by the subagent extension, so it gets its own `<tools>` and `<rules>` sections, placed after the working directory, while any custom text it inherited stays untouched.
|
|
1336
|
+
The child's sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions, then any rules another extension added to `systemPromptOptions.promptGuidelines`, around pi's built-in ones.
|
|
1337
|
+
- A subagent child running with `@gotgenes/pi-subagents` needs a release that drops the parent's `<tools>` and `<rules>` from the prompt the child inherits; with an older one, the child shows the parent's tool list as well as its own.
|
|
1338
|
+
- A denied skill is removed from the skills catalogue pi renders.
|
|
1339
|
+
A skill listed in text pi did not render, such as your own `SYSTEM.md`, is not edited out; using it is still gated.
|
|
1340
|
+
- The prompt options are recomputed on every turn but are stable across turns for a stable policy/agent, so the provider's prompt cache is preserved rather than rewritten each turn.
|
|
1339
1341
|
A policy change is an intentional cache transition, as a mid-session agent switch already is.
|
|
1340
1342
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
|
|
1341
1343
|
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "37.0.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -63,13 +63,13 @@
|
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
67
|
-
"@earendil-works/pi-tui": ">=0.
|
|
66
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
67
|
+
"@earendil-works/pi-tui": ">=1.0.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@biomejs/biome": "^2.5.11",
|
|
71
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
72
|
-
"@earendil-works/pi-tui": "0.
|
|
71
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
72
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
73
73
|
"@types/node": "^22.15.3",
|
|
74
74
|
"rollup": "^4.63.1",
|
|
75
75
|
"rollup-plugin-dts": "^6.5.1",
|
|
@@ -38,8 +38,6 @@ export type SkillPromptEntry = {
|
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
export type SkillPromptSection = {
|
|
41
|
-
start: number;
|
|
42
|
-
end: number;
|
|
43
41
|
entries: ParsedSkillPromptEntry[];
|
|
44
42
|
};
|
|
45
43
|
|
|
@@ -52,15 +50,6 @@ function decodeXml(value: string): string {
|
|
|
52
50
|
.replace(/&/g, "&");
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
function encodeXml(value: string): string {
|
|
56
|
-
return value
|
|
57
|
-
.replace(/&/g, "&")
|
|
58
|
-
.replace(/</g, "<")
|
|
59
|
-
.replace(/>/g, ">")
|
|
60
|
-
.replace(/"/g, """)
|
|
61
|
-
.replace(/'/g, "'");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
53
|
function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
65
54
|
const entries: ParsedSkillPromptEntry[] = [];
|
|
66
55
|
const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
|
|
@@ -115,8 +104,6 @@ export function parseAllSkillPromptSections(
|
|
|
115
104
|
closeStart,
|
|
116
105
|
);
|
|
117
106
|
sections.push({
|
|
118
|
-
start,
|
|
119
|
-
end,
|
|
120
107
|
entries: parseSkillEntries(sectionBody),
|
|
121
108
|
});
|
|
122
109
|
searchStart = end;
|
|
@@ -160,95 +147,56 @@ function createResolvedSkillEntry(
|
|
|
160
147
|
};
|
|
161
148
|
}
|
|
162
149
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
" <skill>",
|
|
170
|
-
` <name>${encodeXml(entry.name)}</name>`,
|
|
171
|
-
` <description>${encodeXml(entry.description)}</description>`,
|
|
172
|
-
` <location>${encodeXml(entry.location)}</location>`,
|
|
173
|
-
" </skill>",
|
|
174
|
-
]),
|
|
175
|
-
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
176
|
-
].join("\n");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function removePromptRange(prompt: string, start: number, end: number): string {
|
|
180
|
-
const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
|
|
181
|
-
const afterSection = prompt.slice(end);
|
|
182
|
-
return `${beforeSection}${afterSection}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function resolveSkillPromptEntries(
|
|
150
|
+
/**
|
|
151
|
+
* The skills listed in the prompt's `<available_skills>` catalogues that
|
|
152
|
+
* policy does not deny, in catalogue order: what skill path matching may
|
|
153
|
+
* treat as a skill's files. Edits no prompt text.
|
|
154
|
+
*/
|
|
155
|
+
export function visibleSkillPromptEntries(
|
|
186
156
|
prompt: string,
|
|
187
157
|
permissionManager: SkillPermissionChecker,
|
|
188
158
|
agentName: string | null,
|
|
189
159
|
normalizer: PathNormalizer,
|
|
190
|
-
):
|
|
191
|
-
const sections = parseAllSkillPromptSections(prompt);
|
|
192
|
-
if (sections.length === 0) {
|
|
193
|
-
return { prompt, entries: [] };
|
|
194
|
-
}
|
|
195
|
-
|
|
160
|
+
): SkillPromptEntry[] {
|
|
196
161
|
const permissionCache = new Map<string, PermissionState>();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
162
|
+
return parseAllSkillPromptSections(prompt)
|
|
163
|
+
.flatMap((section) => section.entries)
|
|
164
|
+
.map((entry) =>
|
|
165
|
+
createResolvedSkillEntry(
|
|
166
|
+
entry,
|
|
167
|
+
resolvePermissionState(
|
|
168
|
+
entry.name,
|
|
169
|
+
permissionManager,
|
|
170
|
+
agentName,
|
|
171
|
+
permissionCache,
|
|
172
|
+
),
|
|
173
|
+
normalizer,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.filter((entry) => entry.state !== "deny");
|
|
177
|
+
}
|
|
200
178
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The skills policy does not deny, judged by name, in their original order.
|
|
181
|
+
*
|
|
182
|
+
* Judging the skill list itself, rather than a catalogue rendered from it,
|
|
183
|
+
* keeps the answer independent of whether that catalogue has been rendered yet.
|
|
184
|
+
*/
|
|
185
|
+
export function withoutDeniedSkills<T extends { readonly name: string }>(
|
|
186
|
+
skills: readonly T[],
|
|
187
|
+
permissionManager: SkillPermissionChecker,
|
|
188
|
+
agentName: string | null,
|
|
189
|
+
): T[] {
|
|
190
|
+
const permissionCache = new Map<string, PermissionState>();
|
|
191
|
+
return skills.filter(
|
|
192
|
+
(skill) =>
|
|
193
|
+
resolvePermissionState(
|
|
194
|
+
skill.name,
|
|
205
195
|
permissionManager,
|
|
206
196
|
agentName,
|
|
207
197
|
permissionCache,
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
const visibleSectionEntries = resolvedEntries.filter(
|
|
213
|
-
(entry) => entry.state !== "deny",
|
|
214
|
-
);
|
|
215
|
-
visibleEntries.push(...visibleSectionEntries);
|
|
216
|
-
|
|
217
|
-
if (visibleSectionEntries.length === resolvedEntries.length) {
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
replacements.push({
|
|
222
|
-
start: section.start,
|
|
223
|
-
end: section.end,
|
|
224
|
-
content:
|
|
225
|
-
visibleSectionEntries.length > 0
|
|
226
|
-
? renderAvailableSkillsSection(visibleSectionEntries)
|
|
227
|
-
: "",
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (replacements.length === 0) {
|
|
232
|
-
return { prompt, entries: visibleEntries };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
let sanitizedPrompt = prompt;
|
|
236
|
-
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
237
|
-
const replacement = replacements[i];
|
|
238
|
-
sanitizedPrompt =
|
|
239
|
-
replacement.content.length > 0
|
|
240
|
-
? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
|
|
241
|
-
: removePromptRange(
|
|
242
|
-
sanitizedPrompt,
|
|
243
|
-
replacement.start,
|
|
244
|
-
replacement.end,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
prompt: sanitizedPrompt,
|
|
250
|
-
entries: visibleEntries,
|
|
251
|
-
};
|
|
198
|
+
) !== "deny",
|
|
199
|
+
);
|
|
252
200
|
}
|
|
253
201
|
|
|
254
202
|
export function findSkillPathMatch(
|
|
@@ -2,36 +2,18 @@
|
|
|
2
2
|
* The tool-surface region of a system prompt: which tools this session may
|
|
3
3
|
* call, and the guidance those tools contribute.
|
|
4
4
|
*
|
|
5
|
-
* Pi
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Pi renders that region itself, as its `<tools>` and `<rules>` sections, from
|
|
6
|
+
* the active tool set this extension narrows — but only when it writes the
|
|
7
|
+
* preamble. A subagent child's prompt is always a `customPrompt`, under which
|
|
8
|
+
* Pi writes neither section, so a child states its own through
|
|
9
|
+
* `systemPromptOptions.sections`, which Pi places after its `<cwd>` section.
|
|
10
|
+
* This module renders those section contents.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Removal is bounded to the text this package or Pi wrote. The prompt is split
|
|
18
|
-
* at the cwd layer Pi writes last and unconditionally: a
|
|
19
|
-
* `Current working directory:` footer through Pi 0.85, a `<cwd>` section from
|
|
20
|
-
* 0.86. Everything after it was appended by an extension, and everything
|
|
21
|
-
* before it is Pi's own only when Pi did not build the prompt from a
|
|
22
|
-
* `customPrompt`. Under a custom prompt Pi writes no tool surface at all, so a
|
|
23
|
-
* section matched above the cwd layer is a user's or another extension's, and
|
|
24
|
-
* removing it destroyed their text (#919, #932). On the 0.86 shape even a
|
|
25
|
-
* Pi-authored head is searched only for Pi's own `<tools>` and `<rules>`,
|
|
26
|
-
* bounded to where Pi writes them, since the plain headers it no longer
|
|
27
|
-
* writes would match only a context file's text.
|
|
28
|
-
*
|
|
29
|
-
* Rendering follows `buildSystemPrompt`'s own rules (a tool is listed only
|
|
30
|
-
* when it has a snippet; the guideline bullets are the allowed tools' own
|
|
31
|
-
* `promptGuidelines`, then other extensions' rules, around Pi's built-in
|
|
32
|
-
* ones) and its shape (plain headers or tagged sections, matching the
|
|
33
|
-
* prompt), so the block reads as the one Pi would have written for this
|
|
34
|
-
* session's real surface.
|
|
12
|
+
* Rendering follows `buildSystemPrompt`'s own rules: a tool is listed only
|
|
13
|
+
* when it has a snippet, and the guideline bullets are the allowed tools' own
|
|
14
|
+
* `promptGuidelines`, then other extensions' rules, around Pi's built-in ones,
|
|
15
|
+
* so the sections read as the ones Pi would have written for this session's
|
|
16
|
+
* real surface.
|
|
35
17
|
*/
|
|
36
18
|
|
|
37
19
|
/** What a session's tool surface renders from. */
|
|
@@ -43,78 +25,25 @@ export interface ToolSurfaceInputs {
|
|
|
43
25
|
/** Guideline bullets each tool contributes, keyed by tool name. */
|
|
44
26
|
readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
|
|
45
27
|
/**
|
|
46
|
-
* `systemPromptOptions.promptGuidelines`: bullets
|
|
47
|
-
* after the tools' own.
|
|
28
|
+
* `systemPromptOptions.promptGuidelines`: the bullets other extensions add
|
|
29
|
+
* to Pi's rules after the tools' own.
|
|
48
30
|
*
|
|
49
|
-
* Only a bullet no registered tool contributes is carried
|
|
50
|
-
*
|
|
51
|
-
* guidelines, flattened, and a denied tool's bullet must not return by this
|
|
52
|
-
* route.
|
|
31
|
+
* Only a bullet no registered tool contributes is carried, so a denied
|
|
32
|
+
* tool's bullet cannot return by this route.
|
|
53
33
|
*/
|
|
54
34
|
readonly promptGuidelines: readonly string[];
|
|
55
|
-
/**
|
|
56
|
-
* Whether Pi wrote the prompt's preamble itself.
|
|
57
|
-
*
|
|
58
|
-
* False when Pi assembled the prompt from `customPrompt` — a user's
|
|
59
|
-
* SYSTEM.md, or a subagent child's assembled prompt — in which case Pi wrote
|
|
60
|
-
* no tool-surface sections and every line above its footer belongs to
|
|
61
|
-
* somebody else.
|
|
62
|
-
*/
|
|
63
|
-
readonly piAuthoredPreamble: boolean;
|
|
64
35
|
}
|
|
65
36
|
|
|
66
|
-
type LineSection = {
|
|
67
|
-
start: number;
|
|
68
|
-
end: number;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
|
|
72
|
-
const GUIDELINES_SECTION_HEADER = "Guidelines:";
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Pi's filler sentence between the tool list and the guidelines.
|
|
76
|
-
*
|
|
77
|
-
* It refers to "the tools above", so it belongs with the list rather than with
|
|
78
|
-
* the text the list is being moved out of.
|
|
79
|
-
*/
|
|
80
|
-
const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
|
|
81
|
-
|
|
82
|
-
/** What Pi writes under `Available tools:` when no selected tool has a snippet. */
|
|
83
|
-
const EMPTY_LIST_PLACEHOLDER = "(none)";
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The first line of the footer Pi writes last, in both of its branches.
|
|
87
|
-
*
|
|
88
|
-
* It is the boundary between what Pi assembled and what extensions appended
|
|
89
|
-
* after it — the same anchor `@gotgenes/pi-subagents` uses to find Pi's
|
|
90
|
-
* session-resolved tail.
|
|
91
|
-
*/
|
|
92
|
-
const PROMPT_FOOTER_PREFIX = "Current working directory: ";
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The tags of the section Pi renders the working directory into from 0.86,
|
|
96
|
-
* last among its own sections and in both of its branches.
|
|
97
|
-
*/
|
|
98
|
-
const CWD_SECTION_OPEN = "<cwd>";
|
|
99
|
-
const CWD_SECTION_CLOSE = "</cwd>";
|
|
100
|
-
|
|
101
|
-
/** The sections Pi 0.86+ writes its tool surface into, in the order it writes them. */
|
|
102
|
-
const TOOL_SURFACE_SECTION_NAMES = ["tools", "rules"] as const;
|
|
103
|
-
|
|
104
37
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* Pi writes `<tools>` and `<rules>` directly under its preamble and ahead of
|
|
108
|
-
* every one of these, so a match that opens or closes past the first of them
|
|
109
|
-
* is text somebody quoted, not Pi's.
|
|
38
|
+
* This session's tool surface as the contents of Pi's `tools` and `rules`
|
|
39
|
+
* prompt sections, untagged: Pi wraps each `sections` entry in its own tags.
|
|
110
40
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
]);
|
|
41
|
+
export interface ToolSurfaceSections {
|
|
42
|
+
/** `- name: snippet` lines; absent when no allowed tool has a snippet. */
|
|
43
|
+
readonly tools?: string;
|
|
44
|
+
/** `- rule` lines, in `buildSystemPrompt`'s order. */
|
|
45
|
+
readonly rules: string;
|
|
46
|
+
}
|
|
118
47
|
|
|
119
48
|
/** Pi's two unconditional guideline bullets, in the order it writes them. */
|
|
120
49
|
const UNIVERSAL_GUIDELINES: readonly string[] = [
|
|
@@ -123,299 +52,18 @@ const UNIVERSAL_GUIDELINES: readonly string[] = [
|
|
|
123
52
|
];
|
|
124
53
|
|
|
125
54
|
/**
|
|
126
|
-
*
|
|
55
|
+
* Render this session's tool surface as section contents, for a node that
|
|
56
|
+
* states it through `systemPromptOptions.sections`.
|
|
127
57
|
*
|
|
128
|
-
* The
|
|
129
|
-
*
|
|
130
|
-
* describes its own tools.
|
|
58
|
+
* The `tools` section is omitted when no allowed tool has a snippet, as Pi
|
|
59
|
+
* lists a tool only when it has one.
|
|
131
60
|
*/
|
|
132
|
-
export function
|
|
133
|
-
systemPrompt: string,
|
|
61
|
+
export function renderToolSurfaceSections(
|
|
134
62
|
inputs: ToolSurfaceInputs,
|
|
135
|
-
):
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
settleRegion(
|
|
140
|
-
lines.slice(0, tailStart),
|
|
141
|
-
inputs.piAuthoredPreamble ? layout.removePiSurface : null,
|
|
142
|
-
),
|
|
143
|
-
settleRegion(lines.slice(tailStart), layout.removeRelocatedSurface),
|
|
144
|
-
]
|
|
145
|
-
.filter((region) => region.length > 0)
|
|
146
|
-
.join("\n")
|
|
147
|
-
.trimEnd();
|
|
148
|
-
const block = layout.renderBlock(toolSurfaceBullets(inputs));
|
|
149
|
-
|
|
150
|
-
return body.length > 0 ? `${body}\n\n${block}` : block;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Lines removed from one region of the prompt; the rest are returned in order. */
|
|
154
|
-
type RegionRemoval = (lines: readonly string[]) => string[];
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* How one prompt shape bounds and writes the tool surface.
|
|
158
|
-
*
|
|
159
|
-
* Pi has written its prompt in more than one shape, and each shape answers the
|
|
160
|
-
* same three questions differently, so the shape is decided once per prompt
|
|
161
|
-
* and every answer is read off the layout it selected.
|
|
162
|
-
*/
|
|
163
|
-
interface PromptLayout {
|
|
164
|
-
/** Pi's own tool surface, removed from the head when Pi wrote the preamble. */
|
|
165
|
-
readonly removePiSurface: RegionRemoval;
|
|
166
|
-
/** A relocated block (this package's or a peer's), removed from the tail. */
|
|
167
|
-
readonly removeRelocatedSurface: RegionRemoval;
|
|
168
|
-
/** This session's block, in this layout's shape. */
|
|
169
|
-
readonly renderBlock: (bullets: ToolSurfaceBullets) => string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** A prompt's layout, and the line at which the text extensions appended begins. */
|
|
173
|
-
interface DetectedLayout {
|
|
174
|
-
readonly layout: PromptLayout;
|
|
175
|
-
readonly tailStart: number;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* The shape Pi wrote through 0.85: `Available tools:` and `Guidelines:`
|
|
180
|
-
* sections in its preamble, and a `Current working directory:` footer last.
|
|
181
|
-
*/
|
|
182
|
-
const HEADER_LAYOUT: PromptLayout = {
|
|
183
|
-
removePiSurface: removeToolSurfaceSections,
|
|
184
|
-
removeRelocatedSurface: removeToolSurfaceSections,
|
|
185
|
-
renderBlock: renderHeaderBlock,
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The shape Pi writes from 0.86: an untagged preamble, then `<name>` sections
|
|
190
|
-
* joined by a blank line (`<tools>` and `<rules>` among them), and a `<cwd>`
|
|
191
|
-
* section last among its own.
|
|
192
|
-
*/
|
|
193
|
-
const SECTION_LAYOUT: PromptLayout = {
|
|
194
|
-
removePiSurface: removePiToolSurfaceSections,
|
|
195
|
-
removeRelocatedSurface: removeRelocatedToolSurface,
|
|
196
|
-
renderBlock: renderSectionBlock,
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The prompt's layout, read off the cwd layer Pi wrote, and where the text
|
|
201
|
-
* extensions appended after that layer begins.
|
|
202
|
-
*
|
|
203
|
-
* Pi writes its cwd layer last among its own, in both of its branches: a
|
|
204
|
-
* `Current working directory:` footer through 0.85, a `<cwd>` section from
|
|
205
|
-
* 0.86. So whichever of the two sits later is Pi's, and a line of either shape
|
|
206
|
-
* quoted in a context file or a custom prompt is always above it. The shape is
|
|
207
|
-
* told apart by which layer is present, never by version sniffing.
|
|
208
|
-
*
|
|
209
|
-
* A prompt carrying neither is read as the header layout and treated as all
|
|
210
|
-
* head, so a block appended to *that* prompt cannot be found and replaced, and
|
|
211
|
-
* a custom preamble would collect a second one. Pi writes a cwd layer in both
|
|
212
|
-
* branches of both renderers, so reaching this needs a downstream rewrite of
|
|
213
|
-
* Pi's whole output.
|
|
214
|
-
*/
|
|
215
|
-
function detectPromptLayout(lines: readonly string[]): DetectedLayout {
|
|
216
|
-
const footerAt = lines.findLastIndex((line) =>
|
|
217
|
-
line.startsWith(PROMPT_FOOTER_PREFIX),
|
|
218
|
-
);
|
|
219
|
-
const cwdCloseAt = lastCwdSectionClose(lines);
|
|
220
|
-
if (cwdCloseAt > footerAt) {
|
|
221
|
-
return { layout: SECTION_LAYOUT, tailStart: cwdCloseAt + 1 };
|
|
222
|
-
}
|
|
223
|
-
return {
|
|
224
|
-
layout: HEADER_LAYOUT,
|
|
225
|
-
tailStart: footerAt === -1 ? lines.length : footerAt + 1,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Line index of the closing tag of the last `<cwd>` section, or -1 when there
|
|
231
|
-
* is none.
|
|
232
|
-
*
|
|
233
|
-
* Pi renders the section as exactly three lines (the opening tag, the
|
|
234
|
-
* directory, the closing tag), so that is the shape matched, the way the
|
|
235
|
-
* footer is matched by its prefix.
|
|
236
|
-
*/
|
|
237
|
-
function lastCwdSectionClose(lines: readonly string[]): number {
|
|
238
|
-
return lines.findLastIndex(
|
|
239
|
-
(line, index) =>
|
|
240
|
-
line === CWD_SECTION_CLOSE && lines[index - 2] === CWD_SECTION_OPEN,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* One region's surviving text: its sections removed, when they are ours to
|
|
246
|
-
* remove, or returned as it arrived when `removal` is `null`.
|
|
247
|
-
*
|
|
248
|
-
* Blank runs are collapsed only where a removal opened one, so a region this
|
|
249
|
-
* pass took nothing out of is returned exactly as it arrived rather than
|
|
250
|
-
* reflowed by a pass that had nothing to do with it.
|
|
251
|
-
*/
|
|
252
|
-
function settleRegion(
|
|
253
|
-
lines: readonly string[],
|
|
254
|
-
removal: RegionRemoval | null,
|
|
255
|
-
): string {
|
|
256
|
-
if (!removal) {
|
|
257
|
-
return lines.join("\n");
|
|
258
|
-
}
|
|
259
|
-
const kept = removal(lines);
|
|
260
|
-
const text = kept.join("\n");
|
|
261
|
-
return kept.length === lines.length ? text : collapseExtraBlankLines(text);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Remove the `Available tools:` and `Guidelines:` sections, and the filler
|
|
266
|
-
* sentence between them, from one region.
|
|
267
|
-
*
|
|
268
|
-
* Each section is located by its own header, so the two are removed whether
|
|
269
|
-
* they sit adjacent in Pi's preamble or alone in the tail — including a block
|
|
270
|
-
* this function already produced, which is what makes it safe to apply to its
|
|
271
|
-
* own output, and what keeps it order-independent with a second writer.
|
|
272
|
-
*/
|
|
273
|
-
function removeToolSurfaceSections(lines: readonly string[]): string[] {
|
|
274
|
-
let remaining = [...lines];
|
|
275
|
-
for (const header of [
|
|
276
|
-
AVAILABLE_TOOLS_SECTION_HEADER,
|
|
277
|
-
GUIDELINES_SECTION_HEADER,
|
|
278
|
-
]) {
|
|
279
|
-
const section = findSection(remaining, header);
|
|
280
|
-
if (section) {
|
|
281
|
-
remaining = [
|
|
282
|
-
...remaining.slice(0, section.start),
|
|
283
|
-
...remaining.slice(section.end),
|
|
284
|
-
];
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return remaining.filter(
|
|
289
|
-
(line) => !line.trimStart().startsWith(CUSTOM_TOOLS_FILLER_PREFIX),
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Remove Pi 0.86+'s own `<tools>` and `<rules>` sections, tags included.
|
|
295
|
-
*
|
|
296
|
-
* A section is Pi's only when it opens and closes before the first section Pi
|
|
297
|
-
* writes after it; the plain headers `removeToolSurfaceSections` matches are
|
|
298
|
-
* never searched here, because Pi writes none on this shape and every match
|
|
299
|
-
* would be a user's or another extension's text.
|
|
300
|
-
*/
|
|
301
|
-
function removePiToolSurfaceSections(head: readonly string[]): string[] {
|
|
302
|
-
return removeTaggedToolSurface(head, laterPiSectionStart);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Remove a relocated block from the extension tail, in either shape.
|
|
307
|
-
*
|
|
308
|
-
* This package writes the tagged shape here, and a peer writer may write
|
|
309
|
-
* either, so both are removed: the pass stays safe to apply to its own output
|
|
310
|
-
* and order-independent with a second writer.
|
|
311
|
-
*/
|
|
312
|
-
function removeRelocatedToolSurface(tail: readonly string[]): string[] {
|
|
313
|
-
return removeToolSurfaceSections(
|
|
314
|
-
removeTaggedToolSurface(tail, (lines) => lines.length),
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Remove the first `<tools>` and the first `<rules>` section, each only when
|
|
320
|
-
* it closes before the line `limitOf` names.
|
|
321
|
-
*/
|
|
322
|
-
function removeTaggedToolSurface(
|
|
323
|
-
lines: readonly string[],
|
|
324
|
-
limitOf: (lines: readonly string[]) => number,
|
|
325
|
-
): string[] {
|
|
326
|
-
let remaining = [...lines];
|
|
327
|
-
for (const name of TOOL_SURFACE_SECTION_NAMES) {
|
|
328
|
-
const section = findTaggedSection(remaining, name, limitOf(remaining));
|
|
329
|
-
if (section) {
|
|
330
|
-
remaining = [
|
|
331
|
-
...remaining.slice(0, section.start),
|
|
332
|
-
...remaining.slice(section.end),
|
|
333
|
-
];
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return remaining;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** Line index of the first section Pi writes after its tool surface. */
|
|
340
|
-
function laterPiSectionStart(lines: readonly string[]): number {
|
|
341
|
-
const at = lines.findIndex((line) => LATER_PI_SECTION_OPENS.has(line));
|
|
342
|
-
return at === -1 ? lines.length : at;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* The first `<name>` section, opening tag through closing tag, that ends
|
|
347
|
-
* before `limit`; `null` when there is none.
|
|
348
|
-
*/
|
|
349
|
-
function findTaggedSection(
|
|
350
|
-
lines: readonly string[],
|
|
351
|
-
name: string,
|
|
352
|
-
limit: number,
|
|
353
|
-
): LineSection | null {
|
|
354
|
-
const start = lines.indexOf(`<${name}>`);
|
|
355
|
-
if (start === -1 || start >= limit) {
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
const closeAt = lines.indexOf(`</${name}>`, start + 1);
|
|
359
|
-
if (closeAt === -1 || closeAt >= limit) {
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
return { start, end: closeAt + 1 };
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* This session's tool surface, as Pi 0.86+ would have rendered it: a `<tools>`
|
|
367
|
-
* section, when any allowed tool has a snippet, then a `<rules>` section.
|
|
368
|
-
*/
|
|
369
|
-
function renderSectionBlock(bullets: ToolSurfaceBullets): string {
|
|
370
|
-
const sections: string[] = [];
|
|
371
|
-
if (bullets.tools.length > 0) {
|
|
372
|
-
sections.push(taggedSection("tools", bullets.tools));
|
|
373
|
-
}
|
|
374
|
-
sections.push(taggedSection("rules", bullets.rules));
|
|
375
|
-
|
|
376
|
-
return sections.join("\n\n");
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** A section in the form Pi 0.86+ renders: its tags on their own lines. */
|
|
380
|
-
function taggedSection(name: string, lines: readonly string[]): string {
|
|
381
|
-
return [`<${name}>`, ...lines, `</${name}>`].join("\n");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** This session's tool surface, as Pi through 0.85 would have rendered it. */
|
|
385
|
-
function renderHeaderBlock(bullets: ToolSurfaceBullets): string {
|
|
386
|
-
const sections: string[] = [];
|
|
387
|
-
|
|
388
|
-
const toolList = renderAvailableTools(bullets.tools);
|
|
389
|
-
if (toolList) {
|
|
390
|
-
sections.push(toolList);
|
|
391
|
-
}
|
|
392
|
-
sections.push(renderGuidelines(bullets.rules));
|
|
393
|
-
|
|
394
|
-
return sections.join("\n\n");
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** The `Available tools:` section, or `null` when it lists no tool. */
|
|
398
|
-
function renderAvailableTools(bullets: readonly string[]): string | null {
|
|
399
|
-
return bullets.length > 0
|
|
400
|
-
? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
|
|
401
|
-
: null;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** The `Guidelines:` section. */
|
|
405
|
-
function renderGuidelines(bullets: readonly string[]): string {
|
|
406
|
-
return [GUIDELINES_SECTION_HEADER, ...bullets].join("\n");
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** The bullets a tool-surface block wraps, whatever shape wraps them. */
|
|
410
|
-
interface ToolSurfaceBullets {
|
|
411
|
-
/** `- name: snippet` lines; empty when no allowed tool has a snippet. */
|
|
412
|
-
readonly tools: readonly string[];
|
|
413
|
-
/** `- rule` lines, in `buildSystemPrompt`'s order. */
|
|
414
|
-
readonly rules: readonly string[];
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function toolSurfaceBullets(inputs: ToolSurfaceInputs): ToolSurfaceBullets {
|
|
418
|
-
return { tools: toolBullets(inputs), rules: ruleBullets(inputs) };
|
|
63
|
+
): ToolSurfaceSections {
|
|
64
|
+
const tools = toolBullets(inputs);
|
|
65
|
+
const rules = ruleBullets(inputs).join("\n");
|
|
66
|
+
return tools.length > 0 ? { tools: tools.join("\n"), rules } : { rules };
|
|
419
67
|
}
|
|
420
68
|
|
|
421
69
|
/**
|
|
@@ -436,7 +84,8 @@ function toolBullets(inputs: ToolSurfaceInputs): string[] {
|
|
|
436
84
|
*
|
|
437
85
|
* Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
|
|
438
86
|
* bullet first, then each allowed tool's own contributions, then the bullets
|
|
439
|
-
* other extensions added, then its two unconditional bullets — de-duplicated
|
|
87
|
+
* other extensions added, then its two unconditional bullets — de-duplicated
|
|
88
|
+
* in first-seen order, as Pi does.
|
|
440
89
|
*/
|
|
441
90
|
function ruleBullets(inputs: ToolSurfaceInputs): string[] {
|
|
442
91
|
const bullets: string[] = [];
|
|
@@ -512,48 +161,3 @@ function fileExplorationGuideline(
|
|
|
512
161
|
}
|
|
513
162
|
return "Use bash for file operations like ls, rg, find";
|
|
514
163
|
}
|
|
515
|
-
|
|
516
|
-
function normalizePrompt(prompt: string): string {
|
|
517
|
-
return (prompt || "").replace(/\r\n/g, "\n");
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function collapseExtraBlankLines(text: string): string {
|
|
521
|
-
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Whether the line belongs to the body of the section above it.
|
|
526
|
-
*
|
|
527
|
-
* Pi writes a section as its header, then bullets — or the `(none)` placeholder
|
|
528
|
-
* when the list is empty — and separates it from what follows with a blank
|
|
529
|
-
* line. Anything else is already outside the section, however it is punctuated:
|
|
530
|
-
* a section that ran on to "the next line ending in a colon" swallowed the
|
|
531
|
-
* prose in between, which is somebody else's text whenever the match was not
|
|
532
|
-
* Pi's own (#919, #932).
|
|
533
|
-
*/
|
|
534
|
-
function isSectionBodyLine(line: string): boolean {
|
|
535
|
-
const trimmed = line.trim();
|
|
536
|
-
if (trimmed.length === 0) return true; // blank line
|
|
537
|
-
if (trimmed.startsWith("- ")) return true; // bullet
|
|
538
|
-
if (trimmed === EMPTY_LIST_PLACEHOLDER) return true; // Pi's empty list
|
|
539
|
-
if (line !== line.trimStart()) return true; // indented
|
|
540
|
-
return false;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** The header line plus its own body, or `null` when the header is absent. */
|
|
544
|
-
function findSection(
|
|
545
|
-
lines: readonly string[],
|
|
546
|
-
header: string,
|
|
547
|
-
): LineSection | null {
|
|
548
|
-
const start = lines.findIndex((line) => line.trim() === header);
|
|
549
|
-
if (start === -1) {
|
|
550
|
-
return null;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
let end = start + 1;
|
|
554
|
-
while (end < lines.length && isSectionBodyLine(lines[end])) {
|
|
555
|
-
end += 1;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
return { start, end };
|
|
559
|
-
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
|
+
NormalizedBuildSystemPromptOptions,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import type { SubagentDetector } from "#src/authority/subagent-detection";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
visibleSkillPromptEntries,
|
|
9
|
+
withoutDeniedSkills,
|
|
10
|
+
} from "#src/exposure/skill-prompt-sanitizer";
|
|
7
11
|
import {
|
|
8
12
|
type RegisteredTools,
|
|
9
13
|
readRegisteredTools,
|
|
10
14
|
type ToolRegistry,
|
|
11
15
|
} from "#src/exposure/tool-registry";
|
|
12
16
|
import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
|
|
13
|
-
import {
|
|
17
|
+
import { renderToolSurfaceSections } from "#src/exposure/tool-surface-prompt";
|
|
14
18
|
import type { DebugLogger } from "#src/logging/session-logger";
|
|
15
19
|
import type { PermissionResolver } from "#src/policy/permission-resolver";
|
|
16
20
|
import type { PermissionSession } from "#src/session/permission-session";
|
|
@@ -18,22 +22,20 @@ import type { TurnPreparation } from "./session-turn-prep";
|
|
|
18
22
|
|
|
19
23
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
|
20
24
|
interface BeforeAgentStartPayload {
|
|
21
|
-
|
|
25
|
+
/** The prompt rendered from `systemPromptOptions` as it stands right now. */
|
|
26
|
+
readonly systemPrompt: string;
|
|
22
27
|
/**
|
|
23
|
-
* The parts Pi
|
|
24
|
-
*
|
|
25
|
-
* wrote
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `promptGuidelines` carries rules other extensions added, which removing
|
|
30
|
-
* Pi's own rules section would otherwise drop.
|
|
28
|
+
* The mutable parts Pi renders the prompt from; later handlers, and Pi
|
|
29
|
+
* itself, see what this handler writes here. `customPrompt` says whether Pi
|
|
30
|
+
* wrote a preamble at all: under one, it writes no tool surface.
|
|
31
|
+
* `toolSnippets` and `promptGuidelines` are what a child's own tool surface
|
|
32
|
+
* renders from, and `sections` is where it is stated. `skills` is the
|
|
33
|
+
* catalogue Pi renders, which policy narrows.
|
|
31
34
|
*/
|
|
32
|
-
systemPromptOptions
|
|
33
|
-
|
|
34
|
-
toolSnippets
|
|
35
|
-
|
|
36
|
-
};
|
|
35
|
+
systemPromptOptions: Pick<
|
|
36
|
+
NormalizedBuildSystemPromptOptions,
|
|
37
|
+
"customPrompt" | "toolSnippets" | "promptGuidelines" | "sections" | "skills"
|
|
38
|
+
>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -54,17 +56,16 @@ export function shouldExposeTool(
|
|
|
54
56
|
/**
|
|
55
57
|
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* hit.
|
|
59
|
+
* Every change to the prompt is stated through `event.systemPromptOptions`,
|
|
60
|
+
* never as a returned `systemPrompt`: a returned prompt is frozen for the run,
|
|
61
|
+
* so the sections an extension later in the chain adds (Pi's own
|
|
62
|
+
* `<mcp_servers>` among them) would never reach the provider (#999).
|
|
62
63
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
64
|
+
* Narrowing the active set is enough for a prompt Pi wrote: Pi renders its
|
|
65
|
+
* `<tools>` and `<rules>` from the reconciled active set. A subagent child's
|
|
66
|
+
* prompt is always a custom one, under which Pi writes neither, so a child
|
|
67
|
+
* states its own as `sections`, which Pi places after its `<cwd>` section. A
|
|
68
|
+
* root under a custom prompt states none, matching Pi.
|
|
68
69
|
*
|
|
69
70
|
* Constructor deps:
|
|
70
71
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
@@ -116,40 +117,53 @@ export class AgentPrepHandler {
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
120
|
+
const options = event.systemPromptOptions;
|
|
121
|
+
if (this.isSubagentUnderCustomPrompt(event, ctx)) {
|
|
122
|
+
const sections = renderToolSurfaceSections({
|
|
123
|
+
allowedTools,
|
|
124
|
+
toolSnippets: options.toolSnippets,
|
|
125
|
+
guidelinesByTool: registered.guidelinesByTool,
|
|
126
|
+
promptGuidelines: options.promptGuidelines,
|
|
127
|
+
});
|
|
128
|
+
// An empty section is one Pi leaves out, so this also clears a stale peer list.
|
|
129
|
+
options.sections.tools = sections.tools ?? "";
|
|
130
|
+
options.sections.rules = sections.rules;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Path-match entries come from every catalogue the rendered prompt lists,
|
|
134
|
+
// read before the skill list is narrowed.
|
|
135
|
+
this.session.setActiveSkillEntries(
|
|
136
|
+
visibleSkillPromptEntries(
|
|
137
|
+
event.systemPrompt,
|
|
138
|
+
this.resolver,
|
|
139
|
+
agentName,
|
|
140
|
+
this.session.getPathNormalizer(),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
// Denials are judged on the list Pi renders `<skills>` from, not on the
|
|
144
|
+
// rendered prompt: that prompt predates this turn's tool changes, and on
|
|
145
|
+
// the turn `read`/`bash` return from a full denial it lists no catalogue.
|
|
146
|
+
options.skills = withoutDeniedSkills(
|
|
147
|
+
options.skills,
|
|
130
148
|
this.resolver,
|
|
131
149
|
agentName,
|
|
132
|
-
this.session.getPathNormalizer(),
|
|
133
150
|
);
|
|
134
|
-
|
|
135
|
-
return skillPromptResult.prompt !== event.systemPrompt
|
|
136
|
-
? { systemPrompt: skillPromptResult.prompt }
|
|
137
|
-
: {};
|
|
151
|
+
return {};
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
/**
|
|
141
|
-
* Whether this node
|
|
155
|
+
* Whether this node states its own tool surface through `sections`.
|
|
142
156
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
157
|
+
* Only a subagent child does: its prompt is always a custom one, under which
|
|
158
|
+
* Pi writes no tool list or rules, and its inherited identity carries none
|
|
159
|
+
* either. A root needs none — Pi renders its own from the narrowed active
|
|
160
|
+
* set, or, under an operator's custom prompt, deliberately writes none.
|
|
147
161
|
*/
|
|
148
|
-
private
|
|
162
|
+
private isSubagentUnderCustomPrompt(
|
|
149
163
|
event: BeforeAgentStartPayload,
|
|
150
164
|
ctx: ExtensionContext,
|
|
151
165
|
): boolean {
|
|
152
|
-
return
|
|
166
|
+
return hasCustomPrompt(event) && this.detector.isSubagent(ctx);
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
private observeToolSurface(
|
|
@@ -167,5 +181,5 @@ export class AgentPrepHandler {
|
|
|
167
181
|
* test, so an empty string reads here the way it reads there: as none.
|
|
168
182
|
*/
|
|
169
183
|
function hasCustomPrompt(event: BeforeAgentStartPayload): boolean {
|
|
170
|
-
return Boolean(event.systemPromptOptions
|
|
184
|
+
return Boolean(event.systemPromptOptions.customPrompt);
|
|
171
185
|
}
|