10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 437 | pkg:pi-permission-system — system-prompt-sanitizer strips the entire Available tools section from the wire prompt |
Retro: #437 — system-prompt-sanitizer strips the entire Available tools section from the wire prompt
Stage: Planning (2026-06-19T00:00:00Z)
Session summary
Planned the fix for sanitizeAvailableToolsSection deleting the whole Available tools: section: narrow it to the allowed tools (Option B — line-filter the lines Pi already rendered into event.systemPrompt) instead of removing it, and recompute/return a byte-stable override every turn.
The decision survived two ask_user rounds: the first chose "retire the sanitizer, read Pi's rebuilt prompt via pi.getSystemPrompt()", which a source investigation then disproved (no such API); the operator then weighed prompt-cache cost and chose Option B for byte-stability from turn 1.
Observations
- Source investigation drove the design, not the issue text.
Verified in
@earendil-works/pi-coding-agent@0.79.1:setActiveToolsByNamerebuilds the prompt (agent-session.js:543); a returned{ systemPrompt }clobbers it (agent-session.js:812);ExtensionAPIhas nogetSystemPrompt(loader.js:149+ type), and the per-eventctx.getSystemPrompt()is overridden to the stale snapshot (runner.js:749);ToolInfoomitspromptSnippet(types.d.ts:1060), so Pi's lines can be line-filtered but not regenerated. - The original plan was invalid —
pi.getSystemPrompt()does not exist; that killed the "retire the module" hypothesis from issue #437's own body and forced the narrowing approach. - Cache-stability is the load-bearing requirement.
The override is byte-stable across turns because
event.systemPrompt's non-tool regions are tool-independent andnarrow(.., allowed)is idempotent, sonarrow(defaultProse, allowed) === narrow(narrowedProse, allowed). This is encoded as an explicit test invariant at both the sanitizer and handler levels. - Gate removal is a justified consequence, not scope creep.
CacheKeyGate.runIfChangedreturnsundefinedon a hit (cache-key-gate.ts:21); withreturn promptResult ?? {}that resets Pi's base to a skill-unfiltered prompt — a latent per-turn skill leak. Recompute-and-return-every-turn fixes it and makesactiveToolsGate/promptStateGate/CacheKeyGate+before-agent-start-cache.tsdead; all deletions verified contained (onlybefore-agent-start.tscallsrunIfChanged). - Classified breaking (
fix!:+BREAKING CHANGE:), confirmed viaask_user: the wire system prompt changes on upgrade (theAvailable tools:section reappears, narrowed). - Deferred: the fully-frozen end-state (return
{}forever) needs an upstream Pi skill-exclusion hook + a live prompt getter — logged as an Open Question / future upstream tracking issue, not in scope. - Doc nuance:
docs/architecture/v3-architecture.mdis a superseded design-era snapshot and is intentionally left unupdated; the livearchitecture.mdmodule listing anddocs/configuration.mdhook wording are updated. The Phase-5 history line inarchitecture.mdmentioningCacheKeyGateis past-tense record and stays.
Stage: Implementation — TDD (2026-06-19T21:00:00Z)
Session summary
Executed all three planned TDD cycles: (1) refactor: dropped the memoization gates (activeToolsGate/promptStateGate/CacheKeyGate/before-agent-start-cache) and made AgentPrepHandler recompute and return the override every turn; (2) fix!: rewrote sanitizeAvailableToolsSection to narrow the Available tools: section per-bullet instead of deleting it; (3) docs: updated configuration.md, architecture.md, and the package SKILL.md.
Test count went 2033 → 2029 (net -4: removed the gate/cache test files and gate-reset assertions, added narrowing, per-turn skill-filter, and byte-stability regressions).
check, root lint, full test, and fallow dead-code all green; pre-completion reviewer returned PASS.
Observations
- Documented deviation —
getPolicyCacheStampremoval. The removed prompt-state cache key was the sole production consumer ofgetPolicyCacheStamp, so the now-dead public method was removed fromPermissionResolverandScopedPermissionManager(the manager's internal policy cache usesloader.getCacheStampdirectly, untouched). Folded into the Step-1refactor:commit with a note; touchedpermission-resolver.ts,permission-manager.ts, and their tests/fixtures beyond the plan's listed files.fallow dead-codeconfirmed no orphans. extractToolBulletNameaccepts colon-optional bullets (/^\s*-\s+([A-Za-z0-9_-]+)/) so both the real Pi format (- read: …) and the test helper format (- read) classify correctly; non-bullet boilerplate returnsnulland is always kept.- Byte-stability is asserted two ways: at the sanitizer level (
narrow(full).prompt === narrow(narrowed).promptandnarrow(narrowed) === narrowed) and at the handler level (effective wire prompt identical across the turn-1 full / turn-2 narrowed drift). Both stayed green through Step 2, confirming the cache invariant holds. - Edit-tool friction: removing the
getPolicyCacheStampdescribe block left an orphaned}); the autoformatter surfaced the parse error immediately and a one-line tail fix resolved it. No shipped defect. - Pre-completion reviewer: PASS (no WARN).
Confirmed the Step-2 narrowing does not regress Step-1's per-turn skill-filter invariant (pinned by the
filters a denied skill ... on every turntest) nor the #385 restrict-only invariant.
Stage: Final Retrospective (2026-06-20T01:17:03Z)
Session summary
Shipped #437 end-to-end in one long session — investigation → issue filing → planning → TDD → ship — landing @gotgenes/pi-permission-system@15.0.0.
The dominant lesson is a missing-context near-miss: I offered "retire the sanitizer and read Pi's rebuilt prompt via pi.getSystemPrompt()" as the recommended ask_user option, the operator chose it, and I then disproved it (no getSystemPrompt on ExtensionAPI), forcing a re-ask.
The recovery was clean — the corrected briefing surfaced the prompt-cache dimension (operator's insight) and produced a better design (Option B, byte-stable narrowing).
Observations
What went well
- Source-level disproof before committing.
Tracing the compiled SDK across
runner.js:749,loader.js:149, andagent-session.js:1810to establish that thepithe factory receives has nogetSystemPrompt(only the stale per-eventctxdoes) caught a design-invalidating assumption during planning, before the plan was committed. Reading the installed.js— not just the.d.ts— was decisive. - Operator's caching insight shaped a testable invariant.
The "the system prompt ought to be frozen" framing turned a correctness fix into a byte-stability requirement, encoded as
narrow(full) === narrow(narrowed)tests at both the sanitizer and handler levels. A strategic user contribution, not mechanical oversight. - Clean TDD execution.
The two-commit split (refactor detangle →
fix!:narrow) kept each commit valid; verification ran incrementally (checkafter interface changes, per-filevitest, full suite +lint+fallowat the end); the pre-completion reviewer returned PASS with no WARN.
What caused friction (agent side)
missing-context(self-identified, operator-impacting) — I assertedpi.getSystemPrompt()exists on theExtensionAPIsurface by analogy toctx.getSystemPrompt()andAgentSession.systemPrompt(agent-session.js:1810), and offered "retire + read via it" as the recommended firstask_useroption. It does not exist onExtensionAPI(loader.js:149); the publicpisurface omits the getter the runner binds on the per-eventctx. Impact: one wasted operator decision + a full re-ask round + a long re-briefing; no shipped defect (caught during planning). Same class as #385's non-existentactiveToolsconfig — asserting a Pi API/mechanism without verifying the exact surface the code holds.other(minor, self-identified) — removing thegetPolicyCacheStampdescribeblock left an orphaned}); the autoformatter flagged the parse error immediately and a one-line tail fix resolved it. One retry, no rework.missing-context(minor, planning) — the plan's Module-Level Changes did not anticipate that removing the prompt-state cache key would orphangetPolicyCacheStamp(its sole consumer). Caught and removed cleanly at TDD time;fallow dead-codeconfirmed no orphans; recorded as a deviation in the Step-1 commit.
What caused friction (user side)
- None that cost rework.
Opportunity (not criticism): the wasted first-round decision was entirely agent-side — verifying
pi.getSystemPrompt()onExtensionAPIbefore presenting it would have spared the operator an invalid choice. The operator's "discuss further / tell me more" response was exemplary and forced the corrected briefing.
Diagnostic details
- Model-performance correlation — one subagent (
pre-completion-reviewer, default model): judgment-heavy review, 38 tool uses, returned PASS plus confirmation of the cross-step invariants. Appropriate fit. Adeepseek-v4-flashselection appears among the sessionmodel_changeentries, but the investigation, planning, and implementation turns show opus/sonnet-level reasoning depth, so that selection likely carried no substantive turn. - Escalation-delay / feedback-loop — no gaps: the deep SDK investigation was productive exploration (not repeated tool calls on one error), and verification ran incrementally rather than only at the end.
Changes made
.pi/skills/code-design/SKILL.md(Pi SDK boundaries) — added a rule: confirm an SDK method on the exact type the code holds (e.g.pi: ExtensionAPI), not an analogous adjacent type, before a design orask_useroption depends on it (thegetSystemPrompt/#437case).