16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 746 | pi-permission-system: agent-facing and review-log renderers over the prompt payload |
Retro: #746 — pi-permission-system: agent-facing and review-log renderers over the prompt payload
Stage: Planning (2026-08-16T16:44:39Z)
Session summary
Planned Phase 13 Step 4 — the last two message consumers become renderers over the PromptPayload.
Three design decisions were put to the operator and settled: the agent-facing denial text names the flagged element but never the command; the review log records structured request facts with a uniform width bound applied at the writeLine choke point; and DenialContext dissolves into PromptPayload.
The plan landed as docs/plans/0746-agent-and-review-log-renderers.md with eight TDD steps, two of them breaking.
Observations
- The first
ask_useron the agent-text question was bounced: the operator asked for the agent's correlation need to be addressed before choosing. Answering it took a source trace rather than an argument — anExploresubagent on the sibling Pi checkout (9d2ec7ffa) established that a block reason becomes an error tool result stamped withtoolCallId(packages/agent/src/agent-loop.ts:637-641, 779), pairs correctly under parallel tool calls (489-532), and travels alongside the assistant message's retained arguments (195, 219-221, 295). Correlation is structural, so the renderer never needed to echo input for identification. The residual — which operand of a multi-token bash call tripped the gate — is below tool-call granularity, and that is what option B (flagged element, never the command) buys. - The second
ask_userwas also bounced: options carrying worked examples in theirpreviewpanes were not enough. What landed was seven scenarios in a plain message, each showing the originating tool call above the current text and the three candidate renders. The lesson generalizes theAGENTS.mdclarification-gate rule: for a wording change, the substance is the before/after paired with its input, not the option list. - Log numbers were measured, not estimated, from the operator's live 7.07 MB review log:
messageis 21.5%,command20.2% (largest single value 72 KB),toolInputPreview0.1%. Removingmessageand capping at the existing 1000-character bound saves 28.7% and shortens 4.3% of command entries. The measurement is what showed that droppingmessagealone leaves half the growth unconfigured —commandwould still be unbounded — which is why the cap went to the writer rather than the renderer. DenialContextdissolves cleanly because every field it holds that the payload lacks is a field ADR 0011 §7 forbids rendering (bash_path.command,tool.input, the latter already unread). The one real gap wascheck.reason, the operator'sdeny-with-reason string, whichGateRunnerholds at message-construction time — passing it as an argument both closes the gap and generalizes it beyond the tool/bash arm, which is a small behavior fix riding step 3.- The default
reviewLogFieldMaxWidth(1000) is not a new number: it is today'sTOOL_INPUT_LOG_PREVIEW_MAX_LENGTH, whose own doc comment says it holds "until #746 lands the log's own renderer". Moving it to the writer letsToolPreviewFormatterOptions.toolInputLogPreviewMaxLengthgo, so the log has one bound instead of one bound plus an unbounded remainder. - Largest identified risk: deleting
test/presentation/legacy-message.test.tsremoves the standing proof that the payload is complete (Step 1'sLanded:note calls that suite the proof). The plan makes migrating the three payload-builder suites to per-field assertions the gating deliverable of the deletion step rather than a follow-up. - The flagged-element decision is a documented departure from a literal reading of §7's "needs no separate size bound".
Recorded at the module declaration and in the roadmap
Landed:note, following the precedent Step 2 set for §3 against §5, rather than amending an accepted ADR. Left in Open Questions for a later ADR pass.
Stage: Implementation — TDD (2026-08-16T18:06:00Z)
Session summary
Executed all eight planned TDD cycles plus two tidy-first preparatory commits and a post-review cleanup — twelve commits.
The agent-facing denial text and the review log became renderers over PromptPayload, DenialContext and legacy-message.ts were deleted, and reviewLogFieldMaxWidth now bounds every review-log value at the writeLine choke point.
Test count went 3009 → 3010 across a much larger churn than that suggests: 1072 lines of test deleted (denial-messages.test.ts, legacy-message.test.ts) against four new suites and three migrated ones.
Observations
- Two deviations from the plan, both improvements the plan's own shape surfaced.
The plan had each of the seven gates spread
renderReviewLogFacts(payload)into itslogContext; themakeDescriptorfixture that would have had to restate that spread is what showed it belongs inGateRunner, beside theagentName/requestIdstamp — a gate cannot forget what it never supplies. Second,flaggedElementLabelhad to split intovalueLabel(labelsrequest.value, for the dialog) andflaggedElementLabel(labels whatflaggedElementsreturns, for the agent renderer); they differ only forbash_external_directory, whose value is the command while what it flags are paths. The extraction had silently conflated two functions that only look alike. - The tidy-first assessor's two commits paid for themselves in step 7.
Converging
gate-fixtures.tsonto a sharedmakeGatePromptDetailsmeant removing the requiredmessagefield touched one fixture line instead of two hand-rolled literals, and collapsingmakeDenialDescriptorremoved a factory whose only purpose was supplying the union being deleted. The assessor also correctly rejected extracting a shared gate-descriptor assembler — the seven builders'denialContextblocks were about to be deleted, and there is nothing to extract before a deletion. - The
renderUnavailableDenialwording forced a decision the plan had not anticipated: the boundary clause (outside working directory '/repo') reads badly in the "requires approval" sentence. Resolved by omitting it — no retry shape changes the fact that no human is reachable — and pinned with an explicit test so the omission is a decision rather than an accident. - One test-fixture bug of my own making: the first runner Red used a
kind: "tool"payload carrying a path value, and the renderer dutifully producedfor tool '/etc/passwd'. A payload literal can be internally incoherent in a way no production builder would produce; the fix was making the fixture coherent, not the renderer tolerant. - Deleting
test/presentation/legacy-message.test.tsremoved the standing proof that the payload is complete, which the plan flagged as the largest risk. The three payload-builder suites migrated fromrenderLegacyMessage(...).toContain(...)to direct payload-field assertions — strictly stronger, since a builder test matching a downstream render can pass while a field it never reads is wrong. - The
/dev/null-style live demonstration: an earlyWriteto a mistyped path outside the repo was denied by this very extension, and its denial text (User denied external directory access for tool 'write' path '…') is exactly the shape this issue replaces. - Pre-completion reviewer: WARN on the first pass, PASS on re-review.
The WARN named four stale doc references and two dead test fields; grepping exhaustively per the AGENTS.md guidance found nine sites rather than four, all fixed in one commit (
53647b2b). The reviewer also flagged twocomposition-root.test.tstimeouts under the parallel root run and correctly diagnosed them as contention flakiness, not a regression — both pass in isolation. - Measured outcome:
renderLegacyMessageinsrc/went 17 → 0, and the predicted 28.7% review-log reduction rests on the same live 7.07 MB log the plan measured.
Stage: Final Retrospective (2026-08-16T21:50:31Z)
Session summary
Planned, implemented, and shipped Phase 13 Step 4 in one continuous session — the agent-facing denial text and the review log became renderers over PromptPayload, retiring the flat message string and the parallel DenialContext union.
Twelve implementation commits landed as pi-permission-system-v26.0.0, a breaking release batching #745 and #746 as the "presentation-contract" batch.
The measured outcome held: renderLegacyMessage in src/ went 17 → 0, and the new reviewLogFieldMaxWidth bound removes ~28.7% of a live 7.07 MB review log.
Observations
What went well
- The two-bounce planning gate produced a materially better design than either first draft.
Bounce one forced a source trace instead of an argument, which established that denial correlation is structural (Pi stamps a block reason as that call's own tool result with its
toolCallId, arguments retained) — collapsing "the agent needs the text to identify its call" from a requirement into a non-issue. Bounce two forced seven worked scenarios into a plain message, which is what surfaced the actual discriminating case: a multi-token bash call where the agent cannot tell which operand tripped the gate. Option B exists only because that case became visible. - Measurement replaced estimation at every decision point.
The live 7.07 MB review log answered where the log's growth actually lives (
message21.5%,command20.2%, largest single value 72 KB), which is what showed that droppingmessagealone leaves half the growth unconfigured — and moved the width bound from the renderer towriteLine. The same log supplied the blast radius for the cap (188 of 4325 command entries, 4.3%), which went into the migration note rather than a hedge. - The
tidy-first-assessorearned its dispatch, including by refusing work. Its two preparatory commits made step 7's required-field removal a one-line fixture edit; it also correctly declined to extract a shared gate-descriptor assembler, on the grounds that the seven builders'denialContextblocks were about to be deleted and there is nothing to extract before a deletion. - The exhaustive-grep rule (Refs #441) paid for itself. The pre-completion reviewer's WARN named 5 stale sites; grepping every removed symbol found 12 files. Fixing only the named ones would have invited the second WARN round the rule exists to prevent.
- Model assignment tracked task shape.
Planning, TDD, and this retro ran on
claude-opus-5; the operator switched toclaude-sonnet-5for the 26-turn ship stage — deterministic tool orchestration — and back for the retro.
What caused friction (agent side)
instruction-violation(self-identified, in retro) —.pi/prompts/plan-issue.md:33says to load thedesign-reviewskill before finalizing the design for any change to shared interfaces or layer wiring. This change addedGateDescriptor.payload, removedPromptPermissionDetails.message, and threaded a derived fact through seven gate builders; the skill was never loaded. Its checklist item 5 ("Parameter relay — if intermediaries only relay, the parameter belongs on a shared object, not threaded through every layer") describes the exact defect the plan then prescribed. Impact: the plan specified sevenlogContextspreads ofrenderReviewLogFacts(payload); implementation corrected it to a singleGateRunnerstamp. One deviation, caught cheaply by a fixture that would have had to restate the spread — no rework beyond the correction itself.wrong-abstraction— thefact-vocabulary.tsextraction collapseddialog-renderer's privateflaggedTextsandvalueLabelinto a singleflaggedElementLabel, conflating two functions that only look alike. They diverge forbash_external_directory, whoserequest.valueis the command while what it flags are paths. Thecode-designskill's "structural reasons before extracting duplication" rule covers this and was loaded. Impact: one failing test in the first agent-renderer Green (labels a bash_external_directory ask's value command), split intovalueLabel+flaggedElementLabelin the same cycle. No commit-level rework.other— the first agent-renderer Red used akind: "tool"payload carrying a path value, and the renderer dutifully renderedfor tool '/etc/passwd'. A hand-built payload literal can be internally incoherent in ways no production builder produces. Impact: one failed assertion, fixed by making the fixture coherent rather than the renderer tolerant.other— an earlyWritetargeted/Users/chris/development/pi/pi-permission-system-agent-renderer.test.tmp.ts, outside the repo, and was blocked by this very extension. Impact: one denied call; incidentally a live demonstration of the pre-#746 denial text this issue replaces.
What caused friction (user side)
- Nothing material.
Both
ask_userbounces were the gate working: each rejected an under-grounded question and named precisely what was missing (verify the correlation premise; pair each example with the tool call that produced it). The second bounce also carried a reusable format instruction — "not as content inask_userbut as a user message" — that generalized into anAGENTS.mdrefinement below.
Diagnostic details
- Model-performance correlation — attributed from inline turn labels in the session file, not
model_changeentries.claude-opus-5ran planning (session lines 5–125), TDD (126–607), and this retro (664+);claude-sonnet-5ran the ship stage (609–663, 26 turns). All three subagents (tidy-first-assessor,pre-completion-reviewer×2, and theExploredispatch for the Pi source trace) rananthropic/claude-sonnet-5, matching their frontmatter and theAGENTS.mdguidance for a multi-hop trace in the sibling Pi checkout. No mismatch: no reasoning-weak model on judgment work, no high-cost model on mechanical work. - Unused-tool detection — the
design-reviewskill was available, named by the active prompt, and not loaded; its checklist item 5 targets the one design defect the plan shipped. This is the only unused-tool finding. - Feedback-loop gap analysis — no gap. 57 verification invocations spread continuously across the TDD stage: a four-command green baseline at lines 137–144, then
pnpm run check/ scopedvitest runafter essentially every change through line 590, withlintandfallow dead-codeat each commit boundary. Verification was never deferred to the end. - Escalation-delay tracking — nothing notable.
No
rabbit-holefriction points; the longest same-error sequence was 2 tool calls (the 8-failure agent-renderer Green, resolved in one analysis pass into two distinct causes).
Changes made
.pi/agents/pre-completion-reviewer.md— added a "Source and test comments" bullet to the forward doc-staleness check, directing asrc//test/grep when a change removes a module, export, or type. The existing bullet covered renames across.pi/skills/and.pi/prompts/only, and this session's reviewer pass missed 7 of 12 stale sites, all of them code comments.AGENTS.md— § Clarification gates now namespreviewpanes alongside option descriptions as a place context gets bounced from, with a#746ref. Atype: "preview"ask carrying full worked examples was bounced this session with an explicit instruction to put them in a message instead..pi/prompts/plan-issue.md— added a parameter-relay heuristic to § Design Overview: when N sibling call sites each supply the same derived fact, check whether a shared downstream point already stamps per-call fields. Thedesign-reviewskill covers this, but it is loaded ~115 lines earlier in the prompt; this puts the check where the design is actually written.