@gotgenes/pi-permission-system 31.1.2 → 31.1.4
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 +25 -0
- package/docs/configuration.md +10 -8
- package/docs/subagent-integration.md +7 -0
- package/package.json +1 -1
- package/src/authority/forwarding-manager.ts +37 -16
- package/src/authority/permission-forwarding.ts +14 -2
- package/src/authority/subagent-detection.ts +10 -3
- package/src/exposure/tool-registry.ts +67 -0
- package/src/exposure/tool-surface-prompt.ts +258 -0
- package/src/handlers/before-agent-start.ts +29 -23
- package/src/index.ts +0 -1
- package/src/exposure/system-prompt-sanitizer.ts +0 -274
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
## [31.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.3...pi-permission-system-v31.1.4) (2026-09-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* keep serving forwarded permissions when a parent-session marker is inherited ([0e1188b](https://github.com/gotgenes/pi-packages/commit/0e1188bb88b8d5b3ee14db3027baf4038644678b)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
|
|
14
|
+
* republish the serving heartbeat when the session id changes mid-session ([3fcb1a1](https://github.com/gotgenes/pi-packages/commit/3fcb1a1b040709510b774d873e08548da519b09a)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
|
|
15
|
+
* refuse a forwarding target that names the requesting session ([30ab5cc](https://github.com/gotgenes/pi-packages/commit/30ab5cc1f1418b38cc572d53b4d166d4097ce3e1)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* record that a UI host always serves forwarded permissions ([f62ca0b](https://github.com/gotgenes/pi-packages/commit/f62ca0b5ed920c0912691a8878375d7ce98d7770)), closes [#907](https://github.com/gotgenes/pi-packages/issues/907)
|
|
20
|
+
|
|
21
|
+
## [31.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.2...pi-permission-system-v31.1.3) (2026-09-08)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** state each session's own tool list instead of editing the inherited one ([190e4c7](https://github.com/gotgenes/pi-packages/commit/190e4c7dc7e1a34e2e3c3ce4b413cd1e3ecd74c7)), closes [#890](https://github.com/gotgenes/pi-packages/issues/890)
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
* record that the inherited region is shared parts, not shared bytes ([#890](https://github.com/gotgenes/pi-packages/issues/890)) ([5755a9a](https://github.com/gotgenes/pi-packages/commit/5755a9a8ea34bf5c7e08a4cb8abc988c12eb3a59))
|
|
31
|
+
* correct the sub-agent bridge description and the header-match residual ([#890](https://github.com/gotgenes/pi-packages/issues/890)) ([b0db764](https://github.com/gotgenes/pi-packages/commit/b0db764e0ffa484eb18bad2883710e77d93ec99b)), closes [#890](https://github.com/gotgenes/pi-packages/issues/890)
|
|
32
|
+
|
|
8
33
|
## [31.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.1...pi-permission-system-v31.1.2) (2026-09-07)
|
|
9
34
|
|
|
10
35
|
|
package/docs/configuration.md
CHANGED
|
@@ -1155,11 +1155,11 @@ permission:
|
|
|
1155
1155
|
|
|
1156
1156
|
The extension integrates via Pi's lifecycle hooks:
|
|
1157
1157
|
|
|
1158
|
-
| Hook | Behavior
|
|
1159
|
-
| -------------------- |
|
|
1160
|
-
| `before_agent_start` | Filters the active tool set (restrict-only),
|
|
1161
|
-
| `tool_call` | Enforces permissions for every tool invocation
|
|
1162
|
-
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy
|
|
1158
|
+
| Hook | Behavior |
|
|
1159
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1160
|
+
| `before_agent_start` | Filters the active tool set (restrict-only), restates the `Available tools:` and `Guidelines:` sections at the end of the system prompt to match, and hides denied skills |
|
|
1161
|
+
| `tool_call` | Enforces permissions for every tool invocation |
|
|
1162
|
+
| `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
|
|
1163
1163
|
|
|
1164
1164
|
Additional behaviors:
|
|
1165
1165
|
|
|
@@ -1167,10 +1167,12 @@ Additional behaviors:
|
|
|
1167
1167
|
- 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`)
|
|
1168
1168
|
- 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.
|
|
1169
1169
|
A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
|
|
1170
|
-
- On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt an extension receives before the extension runs, so the
|
|
1170
|
+
- 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
|
|
1171
1171
|
- 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))
|
|
1172
|
-
- The `Available tools:`
|
|
1173
|
-
|
|
1172
|
+
- The `Available tools:` and `Guidelines:` sections are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after pi's `Current working directory:` footer.
|
|
1173
|
+
Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied
|
|
1174
|
+
- The rendered 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 around pi's built-in ones
|
|
1175
|
+
- The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
|
|
1174
1176
|
A policy change is an intentional cache transition, as a mid-session agent switch already is.
|
|
1175
1177
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
|
|
1176
1178
|
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
|
|
@@ -56,6 +56,13 @@ The variable identifies the session the child forwards its asks to, and naming a
|
|
|
56
56
|
Earlier per-extension variables are grandfathered for compatibility: the markers `PI_IS_SUBAGENT`, `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_NAME` and their siblings still register as child hints, and `PI_AGENT_ROUTER_PARENT_SESSION_ID` is still honored as a parent-session source, checked ahead of the convention name.
|
|
57
57
|
New implementations use `PI_SUBAGENT_PARENT_SESSION` only.
|
|
58
58
|
|
|
59
|
+
Setting the variable in the implementation's **own** root process, so that children inherit it rather than receiving it per spawn, is supported.
|
|
60
|
+
A session with a UI serves its forwarded-permission inbox whatever its environment names, so a root carrying the marker keeps answering its children's asks.
|
|
61
|
+
|
|
62
|
+
A value naming the reading session itself is ignored as a forwarding target, since a request filed into one's own inbox can never be answered.
|
|
63
|
+
That matters when an implementation rewrites an inherited marker with the current session's id: doing so in a child destroys the only record of its real parent, and the child's asks then fail closed with an unresolved-target error.
|
|
64
|
+
Guard such a rewrite on the process being a root — for example, skip it when a child marker such as `PI_SUBAGENT_CHILD` is present.
|
|
65
|
+
|
|
59
66
|
### What an implementation does not owe
|
|
60
67
|
|
|
61
68
|
None of the following is an implementation's responsibility, on either process shape:
|
package/package.json
CHANGED
|
@@ -2,9 +2,11 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import type { DebugReviewLogger } from "#src/logging/session-logger";
|
|
3
3
|
import type { InboxProcessor } from "./forwarded-request-server";
|
|
4
4
|
import { getSessionId } from "./forwarder-context";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizePermissionForwardingSessionId,
|
|
7
|
+
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
8
|
+
} from "./permission-forwarding";
|
|
6
9
|
import type { ServingAnnouncer } from "./serving-registry";
|
|
7
|
-
import type { SubagentDetector } from "./subagent-detection";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
|
@@ -17,8 +19,6 @@ export interface ForwardingController {
|
|
|
17
19
|
|
|
18
20
|
/** Constructor config for {@link ForwardingManager}. */
|
|
19
21
|
export interface ForwardingManagerDeps {
|
|
20
|
-
/** Single owner of subagent detection; gates whether this session may serve. */
|
|
21
|
-
detection: SubagentDetector;
|
|
22
22
|
/** Drains this session's forwarded-permission inbox on each tick. */
|
|
23
23
|
forwarder: InboxProcessor;
|
|
24
24
|
/** Publishes that this session is draining its inbox, for forwarding children. */
|
|
@@ -39,6 +39,13 @@ export interface ForwardingManagerDeps {
|
|
|
39
39
|
* into — and the review log records that id, so a child forwarding to a
|
|
40
40
|
* *different* id is visible as a one-line diff against its
|
|
41
41
|
* `forwarded_permission.request_created` entry (#719).
|
|
42
|
+
*
|
|
43
|
+
* Serving eligibility is `hasUI` and nothing else: a node with a UI has a human
|
|
44
|
+
* who can answer, so it drains its own inbox. It deliberately does **not** ask
|
|
45
|
+
* whether this process looks like a subagent — a spawner may export a
|
|
46
|
+
* parent-session marker from its own root process so the children it later
|
|
47
|
+
* launches inherit it, which made the root withdraw serving and fail every
|
|
48
|
+
* forwarded ask closed (#907).
|
|
42
49
|
*/
|
|
43
50
|
export class ForwardingManager {
|
|
44
51
|
private timer: NodeJS.Timeout | null = null;
|
|
@@ -49,13 +56,13 @@ export class ForwardingManager {
|
|
|
49
56
|
constructor(private readonly deps: ForwardingManagerDeps) {}
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
|
-
* Start polling if `ctx` has UI
|
|
59
|
+
* Start polling if `ctx` has UI.
|
|
53
60
|
* No-op (timer stays running) if already polling — updates the stored
|
|
54
61
|
* context so the next tick uses the latest session.
|
|
55
62
|
* Stops any existing poll when the context does not qualify for forwarding.
|
|
56
63
|
*/
|
|
57
64
|
start(ctx: ExtensionContext): void {
|
|
58
|
-
if (!ctx.hasUI
|
|
65
|
+
if (!ctx.hasUI) {
|
|
59
66
|
this.stop();
|
|
60
67
|
return;
|
|
61
68
|
}
|
|
@@ -99,30 +106,44 @@ export class ForwardingManager {
|
|
|
99
106
|
*
|
|
100
107
|
* A no-op when the id is unchanged, since `start` runs on every
|
|
101
108
|
* `before_agent_start`, `input`, and `tool_call` — the announcement must not
|
|
102
|
-
* cost a log line per turn.
|
|
109
|
+
* cost a log line per turn. Also a no-op for an unreachable id: a record
|
|
110
|
+
* under the `"unknown"` sentinel names a session no child can target.
|
|
103
111
|
*/
|
|
104
112
|
private announceServing(sessionId: string): void {
|
|
105
|
-
|
|
113
|
+
const served = normalizePermissionForwardingSessionId(sessionId);
|
|
114
|
+
if (served === null || this.servingSessionId === served) {
|
|
106
115
|
return;
|
|
107
116
|
}
|
|
108
117
|
this.withdrawServing();
|
|
109
|
-
this.servingSessionId =
|
|
110
|
-
this.deps.serving.markServing(
|
|
118
|
+
this.servingSessionId = served;
|
|
119
|
+
this.deps.serving.markServing(served);
|
|
111
120
|
this.deps.logger.review("forwarded_permission.serving_started", {
|
|
112
|
-
sessionId,
|
|
121
|
+
sessionId: served,
|
|
113
122
|
});
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
/**
|
|
117
126
|
* Re-announce the served session, keeping a decayable channel current.
|
|
118
127
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
128
|
+
* The id is re-resolved from the live context rather than trusted from
|
|
129
|
+
* `start`, because a session id can change in place without a turn event and
|
|
130
|
+
* `ForwardedRequestServer.processInbox` reads the live one on every tick. An
|
|
131
|
+
* announcement pinned to the id captured at `start` therefore drifts away
|
|
132
|
+
* from the inbox actually being drained, stranding children on both sides of
|
|
133
|
+
* the change (#907). A change is rare and diagnosis-worthy, so it is
|
|
134
|
+
* delegated to {@link announceServing} and logged; the unchanged case never
|
|
135
|
+
* reaches it and stays silent, since four review entries a second would drown
|
|
136
|
+
* the log the announcement exists to make readable.
|
|
123
137
|
*/
|
|
124
138
|
private refreshServing(): void {
|
|
125
|
-
if (this.servingSessionId === null) {
|
|
139
|
+
if (this.servingSessionId === null || this.context === null) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const liveSessionId = normalizePermissionForwardingSessionId(
|
|
143
|
+
getSessionId(this.context),
|
|
144
|
+
);
|
|
145
|
+
if (liveSessionId !== null && liveSessionId !== this.servingSessionId) {
|
|
146
|
+
this.announceServing(liveSessionId);
|
|
126
147
|
return;
|
|
127
148
|
}
|
|
128
149
|
this.deps.serving.markServing(this.servingSessionId);
|
|
@@ -319,20 +319,32 @@ export function resolvePermissionForwardingTarget(options: {
|
|
|
319
319
|
return null;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// A candidate naming the requester itself is not a usable target: the
|
|
323
|
+
// request would land in an inbox this node is not draining, and no other node
|
|
324
|
+
// would ever answer it. A child's own copy of a subagent extension can
|
|
325
|
+
// overwrite the spawner's marker with the child's own session id, which is
|
|
326
|
+
// how such a candidate arises (#907).
|
|
327
|
+
const own = normalizePermissionForwardingSessionId(options.currentSessionId);
|
|
328
|
+
const namesAnotherSession = (candidate: string): boolean => candidate !== own;
|
|
329
|
+
|
|
322
330
|
// 1. Registry — in-process subagents register parentSessionId explicitly.
|
|
323
331
|
if (options.registry && options.sessionId) {
|
|
324
332
|
const entry = options.registry.get(options.sessionId);
|
|
325
333
|
const resolved = normalizePermissionForwardingSessionId(
|
|
326
334
|
entry?.parentSessionId,
|
|
327
335
|
);
|
|
328
|
-
if (resolved
|
|
336
|
+
if (resolved && namesAnotherSession(resolved)) {
|
|
337
|
+
return { sessionId: resolved, source: "registry" };
|
|
338
|
+
}
|
|
329
339
|
}
|
|
330
340
|
|
|
331
341
|
// 2. Env vars — process-based subagent extensions.
|
|
332
342
|
const env = options.env ?? process.env;
|
|
333
343
|
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
|
334
344
|
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
|
335
|
-
if (resolved
|
|
345
|
+
if (resolved && namesAnotherSession(resolved)) {
|
|
346
|
+
return { sessionId: resolved, source: "env" };
|
|
347
|
+
}
|
|
336
348
|
}
|
|
337
349
|
return null;
|
|
338
350
|
}
|
|
@@ -8,9 +8,16 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
|
8
8
|
/**
|
|
9
9
|
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
|
10
10
|
*
|
|
11
|
-
* `selectAuthorizer`/`AuthorizerSelection`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `selectAuthorizer`/`AuthorizerSelection` depends on this single-method view so
|
|
12
|
+
* its unit tests inject a one-field fake without casts. It is the
|
|
13
|
+
* Authorizer-selection predicate the Phase 9 spine consumes.
|
|
14
|
+
*
|
|
15
|
+
* It answers "is this process a child", which is **not** "should this node relay
|
|
16
|
+
* rather than decide". A UI host answers `true` here whenever its process
|
|
17
|
+
* carries a parent-session marker — a spawner may export one from the root so
|
|
18
|
+
* the children it launches inherit it. Every consumer therefore tests `hasUI`
|
|
19
|
+
* first: `selectAuthorizer` returns before reaching this predicate, and serving
|
|
20
|
+
* eligibility does not consult it at all (#907).
|
|
14
21
|
*/
|
|
15
22
|
export interface SubagentDetector {
|
|
16
23
|
isSubagent(ctx: SubagentDetectionContext): boolean;
|
|
@@ -9,6 +9,44 @@ export interface ToolRegistry {
|
|
|
9
9
|
setActive(names: string[]): void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Tool names and their guideline bullets, read from one pass over a registry. */
|
|
13
|
+
export interface RegisteredTools {
|
|
14
|
+
/** Resolvable tool names, in registry order. */
|
|
15
|
+
readonly names: string[];
|
|
16
|
+
/** Guideline bullets per tool; a tool declaring none has no entry. */
|
|
17
|
+
readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read a registry listing once, yielding both the answers this package needs.
|
|
22
|
+
*
|
|
23
|
+
* `getActive()` returns bare names and `getAll()` returns `ToolInfo` records,
|
|
24
|
+
* so both are accepted: a listing carrying no guidelines simply produces an
|
|
25
|
+
* empty map.
|
|
26
|
+
*/
|
|
27
|
+
export function readRegisteredTools(
|
|
28
|
+
tools: readonly unknown[],
|
|
29
|
+
): RegisteredTools {
|
|
30
|
+
const names: string[] = [];
|
|
31
|
+
const guidelinesByTool = new Map<string, readonly string[]>();
|
|
32
|
+
|
|
33
|
+
for (const tool of tools) {
|
|
34
|
+
const name = getToolNameFromValue(tool);
|
|
35
|
+
if (!name) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
names.push(name);
|
|
40
|
+
|
|
41
|
+
const guidelines = getToolPromptGuidelinesFromValue(tool);
|
|
42
|
+
if (guidelines.length > 0) {
|
|
43
|
+
guidelinesByTool.set(name, guidelines);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { names, guidelinesByTool };
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
export type ToolRegistrationCheckResult =
|
|
13
51
|
| {
|
|
14
52
|
status: "missing-tool-name";
|
|
@@ -98,6 +136,35 @@ export function getToolNameFromValue(value: unknown): string | null {
|
|
|
98
136
|
return null;
|
|
99
137
|
}
|
|
100
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The guideline bullets a registered tool contributes to the system prompt.
|
|
141
|
+
*
|
|
142
|
+
* Pi carries them per tool on `ToolInfo.promptGuidelines` and flattens them
|
|
143
|
+
* into one `Guidelines:` block when it builds the prompt. Reading them per tool
|
|
144
|
+
* is what lets this package rebuild that block for the allowed set alone,
|
|
145
|
+
* rather than matching Pi's rendered sentences by literal text.
|
|
146
|
+
*
|
|
147
|
+
* Kept defensively wide, like {@link getToolNameFromValue}: anything that is
|
|
148
|
+
* not a non-empty string is dropped, and a value that is not an array of them
|
|
149
|
+
* yields no guidelines rather than throwing.
|
|
150
|
+
*/
|
|
151
|
+
export function getToolPromptGuidelinesFromValue(value: unknown): string[] {
|
|
152
|
+
const guidelines = toRecord(value).promptGuidelines;
|
|
153
|
+
if (!Array.isArray(guidelines)) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const bullets: string[] = [];
|
|
158
|
+
for (const entry of guidelines) {
|
|
159
|
+
const bullet = getNonEmptyString(entry);
|
|
160
|
+
if (bullet) {
|
|
161
|
+
bullets.push(bullet);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return bullets;
|
|
166
|
+
}
|
|
167
|
+
|
|
101
168
|
export function checkRequestedToolRegistration(
|
|
102
169
|
requestedToolName: string | null,
|
|
103
170
|
registeredTools: readonly unknown[],
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool-surface region of a system prompt: which tools this session may
|
|
3
|
+
* call, and the guidance those tools contribute.
|
|
4
|
+
*
|
|
5
|
+
* Pi writes that region near the top of its preamble, a few hundred characters
|
|
6
|
+
* in. `@gotgenes/pi-subagents` copies everything ahead of the skills catalogue
|
|
7
|
+
* into a child's prompt verbatim, so the child's leading bytes match its
|
|
8
|
+
* parent's for prefix-reusing inference engines — which means editing the
|
|
9
|
+
* region in place ends that shared prefix for every child whose allowed set
|
|
10
|
+
* differs from its parent's (#890).
|
|
11
|
+
*
|
|
12
|
+
* So the region is *relocated* rather than narrowed: the sections Pi wrote are
|
|
13
|
+
* removed wherever they sit, and this node's own are rendered at the end of
|
|
14
|
+
* the prompt, past everything a child inherits. Each session then states its
|
|
15
|
+
* own tool surface and no session edits another's bytes.
|
|
16
|
+
*
|
|
17
|
+
* Rendering follows `buildSystemPrompt`'s own rules — a tool is listed only
|
|
18
|
+
* when it has a snippet, and the guideline bullets are the allowed tools' own
|
|
19
|
+
* `promptGuidelines` around Pi's built-in ones — so the block reads as the one
|
|
20
|
+
* Pi would have written for this session's real surface.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What a session's tool surface renders from. */
|
|
24
|
+
export interface ToolSurfaceInputs {
|
|
25
|
+
/** Tools this session may call, in the order they should be listed. */
|
|
26
|
+
readonly allowedTools: readonly string[];
|
|
27
|
+
/** Pi's one-line tool descriptions, keyed by tool name. */
|
|
28
|
+
readonly toolSnippets: Readonly<Record<string, string>>;
|
|
29
|
+
/** Guideline bullets each tool contributes, keyed by tool name. */
|
|
30
|
+
readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type LineSection = {
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
|
|
39
|
+
const GUIDELINES_SECTION_HEADER = "Guidelines:";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pi's filler sentence between the tool list and the guidelines.
|
|
43
|
+
*
|
|
44
|
+
* It refers to "the tools above", so it belongs with the list rather than with
|
|
45
|
+
* the text the list is being moved out of.
|
|
46
|
+
*/
|
|
47
|
+
const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
|
|
48
|
+
|
|
49
|
+
/** Pi's two unconditional guideline bullets, in the order it writes them. */
|
|
50
|
+
const UNIVERSAL_GUIDELINES: readonly string[] = [
|
|
51
|
+
"Be concise in your responses",
|
|
52
|
+
"Show file paths clearly when working with files",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Relocate the tool surface: drop the sections Pi wrote, append this session's.
|
|
57
|
+
*
|
|
58
|
+
* The result always carries a tool-surface block, so a child whose inherited
|
|
59
|
+
* identity has none — its parent's node having already relocated it — still
|
|
60
|
+
* describes its own tools.
|
|
61
|
+
*/
|
|
62
|
+
export function renderToolSurface(
|
|
63
|
+
systemPrompt: string,
|
|
64
|
+
inputs: ToolSurfaceInputs,
|
|
65
|
+
): string {
|
|
66
|
+
const lines = removeToolSurfaceSections(
|
|
67
|
+
normalizePrompt(systemPrompt).split("\n"),
|
|
68
|
+
);
|
|
69
|
+
const body = collapseExtraBlankLines(lines.join("\n"));
|
|
70
|
+
const block = renderToolSurfaceBlock(inputs);
|
|
71
|
+
|
|
72
|
+
return body.length > 0 ? `${body}\n\n${block}` : block;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove the `Available tools:` and `Guidelines:` sections, and the filler
|
|
77
|
+
* sentence between them.
|
|
78
|
+
*
|
|
79
|
+
* Each section is located by its own header, so the two are removed whether
|
|
80
|
+
* they sit adjacent in Pi's preamble or alone in a prompt something downstream
|
|
81
|
+
* rewrote — including a prompt this function already produced, which is what
|
|
82
|
+
* makes it safe to apply to its own output.
|
|
83
|
+
*/
|
|
84
|
+
function removeToolSurfaceSections(lines: readonly string[]): string[] {
|
|
85
|
+
let remaining = [...lines];
|
|
86
|
+
for (const header of [
|
|
87
|
+
AVAILABLE_TOOLS_SECTION_HEADER,
|
|
88
|
+
GUIDELINES_SECTION_HEADER,
|
|
89
|
+
]) {
|
|
90
|
+
const section = findSection(remaining, header);
|
|
91
|
+
if (section) {
|
|
92
|
+
remaining = [
|
|
93
|
+
...remaining.slice(0, section.start),
|
|
94
|
+
...remaining.slice(section.end),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return remaining.filter(
|
|
100
|
+
(line) => !line.trimStart().startsWith(CUSTOM_TOOLS_FILLER_PREFIX),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** This session's tool surface, as Pi would have rendered it. */
|
|
105
|
+
function renderToolSurfaceBlock(inputs: ToolSurfaceInputs): string {
|
|
106
|
+
const sections: string[] = [];
|
|
107
|
+
|
|
108
|
+
const toolList = renderAvailableTools(inputs);
|
|
109
|
+
if (toolList) {
|
|
110
|
+
sections.push(toolList);
|
|
111
|
+
}
|
|
112
|
+
sections.push(renderGuidelines(inputs));
|
|
113
|
+
|
|
114
|
+
return sections.join("\n\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `Available tools:` section for the allowed set, or `null` when none of
|
|
119
|
+
* those tools has a snippet.
|
|
120
|
+
*
|
|
121
|
+
* Pi lists a tool only when the caller supplied a one-line snippet for it, so
|
|
122
|
+
* a tool without one is left unlisted here too rather than rendered bare.
|
|
123
|
+
*/
|
|
124
|
+
function renderAvailableTools(inputs: ToolSurfaceInputs): string | null {
|
|
125
|
+
const bullets = inputs.allowedTools
|
|
126
|
+
.map((toolName) => ({ toolName, snippet: inputs.toolSnippets[toolName] }))
|
|
127
|
+
.filter((tool) => Boolean(tool.snippet))
|
|
128
|
+
.map((tool) => `- ${tool.toolName}: ${tool.snippet}`);
|
|
129
|
+
|
|
130
|
+
return bullets.length > 0
|
|
131
|
+
? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `Guidelines:` section for the allowed set.
|
|
137
|
+
*
|
|
138
|
+
* Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
|
|
139
|
+
* bullet first, then each allowed tool's own contributions, then its two
|
|
140
|
+
* unconditional bullets — de-duplicated in first-seen order, as Pi does.
|
|
141
|
+
*/
|
|
142
|
+
function renderGuidelines(inputs: ToolSurfaceInputs): string {
|
|
143
|
+
const bullets: string[] = [];
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
const addGuideline = (guideline: string): void => {
|
|
146
|
+
const normalized = guideline.trim();
|
|
147
|
+
if (normalized.length === 0 || seen.has(normalized)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
seen.add(normalized);
|
|
151
|
+
bullets.push(normalized);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const fileOperations = fileExplorationGuideline(new Set(inputs.allowedTools));
|
|
155
|
+
if (fileOperations) {
|
|
156
|
+
addGuideline(fileOperations);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const toolName of inputs.allowedTools) {
|
|
160
|
+
for (const guideline of inputs.guidelinesByTool.get(toolName) ?? []) {
|
|
161
|
+
addGuideline(guideline);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const guideline of UNIVERSAL_GUIDELINES) {
|
|
166
|
+
addGuideline(guideline);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [
|
|
170
|
+
GUIDELINES_SECTION_HEADER,
|
|
171
|
+
...bullets.map((bullet) => `- ${bullet}`),
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pi's shell-only file-exploration bullet, or `null` when it does not apply.
|
|
177
|
+
*
|
|
178
|
+
* Pi writes it only when a shell is available and none of the dedicated
|
|
179
|
+
* exploration tools is, so a session holding `grep`/`find`/`ls` is not told to
|
|
180
|
+
* reach for the shell instead.
|
|
181
|
+
*/
|
|
182
|
+
function fileExplorationGuideline(
|
|
183
|
+
allowedTools: ReadonlySet<string>,
|
|
184
|
+
): string | null {
|
|
185
|
+
const hasBash = allowedTools.has("bash");
|
|
186
|
+
const hasPowerShell = allowedTools.has("powershell");
|
|
187
|
+
const hasExplorationTool =
|
|
188
|
+
allowedTools.has("grep") ||
|
|
189
|
+
allowedTools.has("find") ||
|
|
190
|
+
allowedTools.has("ls");
|
|
191
|
+
|
|
192
|
+
if ((!hasBash && !hasPowerShell) || hasExplorationTool) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (hasBash && hasPowerShell) {
|
|
196
|
+
return "Use bash or PowerShell for file operations like listing, searching, and finding files";
|
|
197
|
+
}
|
|
198
|
+
if (hasPowerShell) {
|
|
199
|
+
return "Use PowerShell for file operations like listing, searching, and finding files";
|
|
200
|
+
}
|
|
201
|
+
return "Use bash for file operations like ls, rg, find";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizePrompt(prompt: string): string {
|
|
205
|
+
return (prompt || "").replace(/\r\n/g, "\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collapseExtraBlankLines(text: string): string {
|
|
209
|
+
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isTopLevelSectionHeader(line: string): boolean {
|
|
213
|
+
const trimmed = line.trim();
|
|
214
|
+
return (
|
|
215
|
+
trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isSectionBodyLine(line: string): boolean {
|
|
220
|
+
const trimmed = line.trim();
|
|
221
|
+
if (trimmed.length === 0) return true; // blank line
|
|
222
|
+
if (trimmed.startsWith("- ")) return true; // bullet
|
|
223
|
+
if (line !== line.trimStart()) return true; // indented
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function findSection(
|
|
228
|
+
lines: readonly string[],
|
|
229
|
+
header: string,
|
|
230
|
+
): LineSection | null {
|
|
231
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
232
|
+
if (start === -1) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// If a subsequent recognised section header exists, use it as the boundary.
|
|
237
|
+
// This preserves the original behaviour for the common case where sections
|
|
238
|
+
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
|
|
239
|
+
// ensures any prose continuation between the two headers is also removed.
|
|
240
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
241
|
+
if (isTopLevelSectionHeader(lines[index])) {
|
|
242
|
+
return { start, end: index };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No subsequent section header — stop at the first non-body line so that
|
|
247
|
+
// content after the section (e.g. custom user notes) is not silently deleted.
|
|
248
|
+
let end = start + 1;
|
|
249
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
250
|
+
if (!isSectionBodyLine(lines[index])) {
|
|
251
|
+
end = index;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
end = index + 1;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { start, end };
|
|
258
|
+
}
|
|
@@ -3,12 +3,13 @@ import type {
|
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
|
|
6
|
-
import { sanitizeAvailableToolsSection } from "#src/exposure/system-prompt-sanitizer";
|
|
7
6
|
import {
|
|
8
|
-
|
|
7
|
+
type RegisteredTools,
|
|
8
|
+
readRegisteredTools,
|
|
9
9
|
type ToolRegistry,
|
|
10
10
|
} from "#src/exposure/tool-registry";
|
|
11
11
|
import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
|
|
12
|
+
import { renderToolSurface } from "#src/exposure/tool-surface-prompt";
|
|
12
13
|
import type { DebugLogger } from "#src/logging/session-logger";
|
|
13
14
|
import type { PermissionResolver } from "#src/policy/permission-resolver";
|
|
14
15
|
import type { PermissionSession } from "#src/session/permission-session";
|
|
@@ -17,6 +18,14 @@ import type { TurnPreparation } from "./session-turn-prep";
|
|
|
17
18
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
|
18
19
|
interface BeforeAgentStartPayload {
|
|
19
20
|
systemPrompt: string;
|
|
21
|
+
/**
|
|
22
|
+
* The parts Pi assembled the prompt from. `toolSnippets` is what lets this
|
|
23
|
+
* handler render the session's own tool list instead of editing the one Pi
|
|
24
|
+
* wrote — including in a child, whose inherited identity carries none.
|
|
25
|
+
*/
|
|
26
|
+
systemPromptOptions?: {
|
|
27
|
+
toolSnippets?: Record<string, string>;
|
|
28
|
+
};
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -39,8 +48,12 @@ export function shouldExposeTool(
|
|
|
39
48
|
*
|
|
40
49
|
* Recomputes the active tool set and the returned system-prompt override on
|
|
41
50
|
* every fire (no memoization): the override must be returned each turn so that
|
|
42
|
-
* skill filtering is reapplied and the wire prompt stays
|
|
43
|
-
* than letting Pi reset to its skill-unfiltered base prompt on a cache
|
|
51
|
+
* skill filtering is reapplied and the wire prompt stays stable across turns,
|
|
52
|
+
* rather than letting Pi reset to its skill-unfiltered base prompt on a cache
|
|
53
|
+
* hit.
|
|
54
|
+
*
|
|
55
|
+
* The tool surface is relocated rather than edited in place, so a subagent
|
|
56
|
+
* child's inherited identity stays byte-identical to its parent's (#890).
|
|
44
57
|
*
|
|
45
58
|
* Constructor deps:
|
|
46
59
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
@@ -70,8 +83,9 @@ export class AgentPrepHandler {
|
|
|
70
83
|
this.turnPrep.prepare(ctx);
|
|
71
84
|
|
|
72
85
|
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
|
86
|
+
const registered = readRegisteredTools(this.toolRegistry.getAll());
|
|
73
87
|
const surface = this.session.resolveExposedTools(
|
|
74
|
-
this.observeToolSurface(),
|
|
88
|
+
this.observeToolSurface(registered),
|
|
75
89
|
(toolName) =>
|
|
76
90
|
shouldExposeTool(toolName, agentName, (t, a) =>
|
|
77
91
|
this.resolver.isToolFullyDenied(t, a),
|
|
@@ -88,12 +102,13 @@ export class AgentPrepHandler {
|
|
|
88
102
|
});
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
const
|
|
92
|
-
event.systemPrompt,
|
|
105
|
+
const toolSurfacePrompt = renderToolSurface(event.systemPrompt, {
|
|
93
106
|
allowedTools,
|
|
94
|
-
|
|
107
|
+
toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
|
|
108
|
+
guidelinesByTool: registered.guidelinesByTool,
|
|
109
|
+
});
|
|
95
110
|
const skillPromptResult = resolveSkillPromptEntries(
|
|
96
|
-
|
|
111
|
+
toolSurfacePrompt,
|
|
97
112
|
this.resolver,
|
|
98
113
|
agentName,
|
|
99
114
|
this.session.getPathNormalizer(),
|
|
@@ -104,21 +119,12 @@ export class AgentPrepHandler {
|
|
|
104
119
|
: {};
|
|
105
120
|
}
|
|
106
121
|
|
|
107
|
-
private observeToolSurface(
|
|
122
|
+
private observeToolSurface(
|
|
123
|
+
registered: RegisteredTools,
|
|
124
|
+
): ToolSurfaceObservation {
|
|
108
125
|
return {
|
|
109
|
-
active:
|
|
110
|
-
registered: new Set(
|
|
126
|
+
active: readRegisteredTools(this.toolRegistry.getActive()).names,
|
|
127
|
+
registered: new Set(registered.names),
|
|
111
128
|
};
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
|
-
|
|
115
|
-
function toolNamesOf(tools: readonly unknown[]): string[] {
|
|
116
|
-
const names: string[] = [];
|
|
117
|
-
for (const tool of tools) {
|
|
118
|
-
const toolName = getToolNameFromValue(tool);
|
|
119
|
-
if (toolName) {
|
|
120
|
-
names.push(toolName);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return names;
|
|
124
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -215,7 +215,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
215
215
|
session = new PermissionSession(
|
|
216
216
|
paths,
|
|
217
217
|
new ForwardingManager({
|
|
218
|
-
detection: subagentDetection,
|
|
219
218
|
forwarder: requestServer,
|
|
220
219
|
serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
|
|
221
220
|
logger,
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
export interface SanitizeSystemPromptResult {
|
|
2
|
-
prompt: string;
|
|
3
|
-
removed: boolean;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
type LineSection = {
|
|
7
|
-
start: number;
|
|
8
|
-
end: number;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
type GuidelineRule = {
|
|
12
|
-
matches: (guideline: string) => boolean;
|
|
13
|
-
shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
|
|
17
|
-
const GUIDELINES_SECTION_HEADER = "Guidelines:";
|
|
18
|
-
|
|
19
|
-
const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
|
|
20
|
-
{
|
|
21
|
-
matches: (guideline) =>
|
|
22
|
-
guideline === "use bash for file operations like ls, rg, find",
|
|
23
|
-
shouldKeep: (allowedTools) => allowedTools.has("bash"),
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
matches: (guideline) =>
|
|
27
|
-
guideline ===
|
|
28
|
-
"prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
|
|
29
|
-
shouldKeep: (allowedTools) =>
|
|
30
|
-
allowedTools.has("bash") &&
|
|
31
|
-
(allowedTools.has("grep") ||
|
|
32
|
-
allowedTools.has("find") ||
|
|
33
|
-
allowedTools.has("ls")),
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
matches: (guideline) =>
|
|
37
|
-
guideline ===
|
|
38
|
-
"use read to examine files before editing. you must use this tool instead of cat or sed." ||
|
|
39
|
-
guideline === "use read to examine files instead of cat or sed.",
|
|
40
|
-
shouldKeep: (allowedTools) => allowedTools.has("read"),
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
matches: (guideline) =>
|
|
44
|
-
guideline ===
|
|
45
|
-
"use edit for precise changes (old text must match exactly)",
|
|
46
|
-
shouldKeep: (allowedTools) => allowedTools.has("edit"),
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
matches: (guideline) =>
|
|
50
|
-
guideline === "use write only for new files or complete rewrites",
|
|
51
|
-
shouldKeep: (allowedTools) => allowedTools.has("write"),
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
matches: (guideline) =>
|
|
55
|
-
guideline ===
|
|
56
|
-
"when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
|
|
57
|
-
shouldKeep: (allowedTools) =>
|
|
58
|
-
allowedTools.has("edit") || allowedTools.has("write"),
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
matches: (guideline) =>
|
|
62
|
-
guideline ===
|
|
63
|
-
"use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
|
|
64
|
-
shouldKeep: (allowedTools) => allowedTools.has("task"),
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
matches: (guideline) =>
|
|
68
|
-
guideline ===
|
|
69
|
-
"use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
|
|
70
|
-
shouldKeep: (allowedTools) => allowedTools.has("mcp"),
|
|
71
|
-
},
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
function normalizePrompt(prompt: string): string {
|
|
75
|
-
return (prompt || "").replace(/\r\n/g, "\n");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function collapseExtraBlankLines(text: string): string {
|
|
79
|
-
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function normalizeGuidelineText(line: string): string {
|
|
83
|
-
return line
|
|
84
|
-
.trim()
|
|
85
|
-
.replace(/^[-*]\s+/, "")
|
|
86
|
-
.replace(/\s+/g, " ")
|
|
87
|
-
.toLowerCase();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function isTopLevelSectionHeader(line: string): boolean {
|
|
91
|
-
const trimmed = line.trim();
|
|
92
|
-
return (
|
|
93
|
-
trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function isSectionBodyLine(line: string): boolean {
|
|
98
|
-
const trimmed = line.trim();
|
|
99
|
-
if (trimmed.length === 0) return true; // blank line
|
|
100
|
-
if (trimmed.startsWith("- ")) return true; // bullet
|
|
101
|
-
if (line !== line.trimStart()) return true; // indented
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function findSection(
|
|
106
|
-
lines: readonly string[],
|
|
107
|
-
header: string,
|
|
108
|
-
): LineSection | null {
|
|
109
|
-
const start = lines.findIndex((line) => line.trim() === header);
|
|
110
|
-
if (start === -1) {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// If a subsequent recognised section header exists, use it as the boundary.
|
|
115
|
-
// This preserves the original behaviour for the common case where sections
|
|
116
|
-
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
|
|
117
|
-
// ensures any prose continuation between the two headers is also removed.
|
|
118
|
-
for (let index = start + 1; index < lines.length; index += 1) {
|
|
119
|
-
if (isTopLevelSectionHeader(lines[index])) {
|
|
120
|
-
return { start, end: index };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// No subsequent section header — stop at the first non-body line so that
|
|
125
|
-
// content after the section (e.g. custom user notes) is not silently deleted.
|
|
126
|
-
let end = start + 1;
|
|
127
|
-
for (let index = start + 1; index < lines.length; index += 1) {
|
|
128
|
-
if (!isSectionBodyLine(lines[index])) {
|
|
129
|
-
end = index;
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
end = index + 1;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return { start, end };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
|
|
140
|
-
* `null` for non-tool lines (blank lines, boilerplate prose). Matches the
|
|
141
|
-
* first token after the bullet marker, with or without a trailing colon.
|
|
142
|
-
*/
|
|
143
|
-
function extractToolBulletName(line: string): string | null {
|
|
144
|
-
const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
|
|
145
|
-
return match ? match[1] : null;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
|
|
150
|
-
* bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
|
|
151
|
-
* no tool bullet survives, remove the section header too. This mirrors what Pi
|
|
152
|
-
* itself renders for the active tool set, so the result is byte-stable across
|
|
153
|
-
* turns regardless of whether the input still carries the full default listing.
|
|
154
|
-
*/
|
|
155
|
-
function narrowAvailableToolsSection(
|
|
156
|
-
lines: readonly string[],
|
|
157
|
-
allowedTools: ReadonlySet<string>,
|
|
158
|
-
): { lines: string[]; removed: boolean } {
|
|
159
|
-
const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
|
|
160
|
-
if (!section) {
|
|
161
|
-
return { lines: [...lines], removed: false };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const before = lines.slice(0, section.start);
|
|
165
|
-
const header = lines[section.start];
|
|
166
|
-
const body = lines.slice(section.start + 1, section.end);
|
|
167
|
-
const after = lines.slice(section.end);
|
|
168
|
-
|
|
169
|
-
const filteredBody = body.filter((line) => {
|
|
170
|
-
const toolName = extractToolBulletName(line);
|
|
171
|
-
if (toolName === null) {
|
|
172
|
-
return true; // keep blank lines and non-tool boilerplate
|
|
173
|
-
}
|
|
174
|
-
return allowedTools.has(toolName);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
const removed = filteredBody.length !== body.length;
|
|
178
|
-
if (!removed) {
|
|
179
|
-
return { lines: [...lines], removed: false };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const hasToolBullet = filteredBody.some(
|
|
183
|
-
(line) => extractToolBulletName(line) !== null,
|
|
184
|
-
);
|
|
185
|
-
if (!hasToolBullet) {
|
|
186
|
-
return { lines: [...before, ...after], removed: true };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
lines: [...before, header, ...filteredBody, ...after],
|
|
191
|
-
removed: true,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function shouldKeepGuideline(
|
|
196
|
-
line: string,
|
|
197
|
-
allowedTools: ReadonlySet<string>,
|
|
198
|
-
): boolean {
|
|
199
|
-
const normalized = normalizeGuidelineText(line);
|
|
200
|
-
|
|
201
|
-
for (const rule of TOOL_GUIDELINE_RULES) {
|
|
202
|
-
if (rule.matches(normalized)) {
|
|
203
|
-
return rule.shouldKeep(allowedTools);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function sanitizeGuidelinesSection(
|
|
211
|
-
lines: readonly string[],
|
|
212
|
-
allowedTools: ReadonlySet<string>,
|
|
213
|
-
): { lines: string[]; removed: boolean } {
|
|
214
|
-
const section = findSection(lines, GUIDELINES_SECTION_HEADER);
|
|
215
|
-
if (!section) {
|
|
216
|
-
return { lines: [...lines], removed: false };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const before = lines.slice(0, section.start + 1);
|
|
220
|
-
const after = lines.slice(section.end);
|
|
221
|
-
const body = lines.slice(section.start + 1, section.end);
|
|
222
|
-
const filteredBody = body.filter((line) => {
|
|
223
|
-
const trimmed = line.trim();
|
|
224
|
-
if (!trimmed.startsWith("- ")) {
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return shouldKeepGuideline(line, allowedTools);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
const removed = filteredBody.length !== body.length;
|
|
232
|
-
if (!removed) {
|
|
233
|
-
return { lines: [...lines], removed: false };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
|
|
237
|
-
if (!hasBullet) {
|
|
238
|
-
return {
|
|
239
|
-
lines: [...lines.slice(0, section.start), ...after],
|
|
240
|
-
removed: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
lines: [...before, ...filteredBody, ...after],
|
|
246
|
-
removed: true,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function sanitizeAvailableToolsSection(
|
|
251
|
-
systemPrompt: string,
|
|
252
|
-
allowedToolNames: readonly string[],
|
|
253
|
-
): SanitizeSystemPromptResult {
|
|
254
|
-
const allowedTools = new Set(
|
|
255
|
-
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
|
|
256
|
-
);
|
|
257
|
-
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
|
|
258
|
-
const narrowedToolsSection = narrowAvailableToolsSection(
|
|
259
|
-
normalizedLines,
|
|
260
|
-
allowedTools,
|
|
261
|
-
);
|
|
262
|
-
const sanitizedGuidelines = sanitizeGuidelinesSection(
|
|
263
|
-
narrowedToolsSection.lines,
|
|
264
|
-
allowedTools,
|
|
265
|
-
);
|
|
266
|
-
const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
prompt: removed
|
|
270
|
-
? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
|
|
271
|
-
: systemPrompt,
|
|
272
|
-
removed,
|
|
273
|
-
};
|
|
274
|
-
}
|