21 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 283 | Formatter extension seam for custom tool input previews |
Tool input formatter extension seam
Problem Statement
Permission prompts exist so the user can make an informed allow/deny decision about a tool call.
For tools with structured JSON input — especially MCP tools — the default truncated-JSON preview is hard to read, which defeats the prompt's purpose.
The issue asks for a generative provider seam on ToolPreviewFormatter so other extensions can register a custom preview formatter for a specific tool name, with the core calling registered formatters during prompt construction and falling back to the default preview when a formatter declines (returns undefined).
This follows the pi-subagents extension-surface model (registerWorkspaceProvider(provider): () => void): a registration method that returns a disposer, where the core invokes the registered provider at execution time.
Goals
- Add a persistent
ToolInputFormatterRegistrythat maps a tool name to a single custom formatter and hands back a disposer on registration. - Expose
registerToolInputFormatter(toolName, formatter): () => voidon the cross-extensionPermissionsService(theSymbol.for()accessor) — the only channel a sibling extension can reach. - Consult a registered formatter first inside
ToolPreviewFormatter.formatToolInputForPrompt; when it returns a string, use it verbatim; when it returnsundefined, fall through to the existing built-in switch (and ultimately the JSON default). - Ship one reference built-in: an MCP input summarizer keyed to the
mcptool, registered through the public seam at startup (dogfooding the API), that renders a compact argument summary instead of leaving the MCP prompt argument-blind. - Keep the change additive and non-breaking: the new constructor and service members are optional/new; existing prompt behavior is unchanged when no formatter is registered.
Non-Goals
- No pi-subagents source changes.
The issue carries a
pkg:pi-subagentslabel because the seam mirrors pi-subagents' provider-surface model, not because pi-subagents registers a formatter — doing so would make pi-subagents reach outward to a consumer, violating its "arrows point inward" principle. This is therefore filed as a single-package plan beside its prerequisites #266 and #282. - No new config field, schema entry, or example — this is a runtime registration mechanism, not a config knob.
- No per-tool override of an already-registered formatter — a tool name holds at most one formatter (see Design Overview, duplicate handling).
- No formatter for arbitrary third-party batch tools (e.g. the
ctx_batch_executeexample from the issue body); those are exactly what a third-party extension would register through this seam. - No change to bash or skill prompts, or to the review-log preview path.
Background
The prerequisites are both shipped and closed:
- #282 extracted
ToolPreviewFormatter(src/tool-preview-formatter.ts) — a class constructed fromToolPreviewFormatterOptions. - #266 wired configurable limits:
handleToolCallconstructs the formatter fresh on every tool call vianew ToolPreviewFormatter(resolveToolPreviewLimits(this.session.config))(src/handlers/permission-gate-handler.ts:146).
Key existing structure this plan builds on:
ToolPreviewFormatter.formatToolInputForPrompt(toolName, input)is the dispatch point: aswitchoveredit/write/read/find/grep/lswith adefaultthat callsformatJsonInputForPrompt(inline truncated JSON).formatAskPrompt(result, agentName, input, formatter?)(src/permission-prompts.ts:30) builds the ask-prompt sentence. It early-returns forbashand for MCP (whenresult.targetis set, renderingrequested MCP target 'server:tool'); only the generic tail callsformatter.formatToolInputForPrompt. MCP calls therefore never reachformatToolInputForPrompttoday — the reference built-in requires a second, deliberate integration in the MCP branch.- The formatter is constructed per tool call, so a registry of custom formatters cannot live as instance state on the formatter; it needs a persistent owner.
PermissionsService(src/service.ts) is aSymbol.for()-backed object published fromindex.ts; sibling extensions retrieve it withgetPermissionsService(). This is the cross-extension channel — a per-call formatter instance is unreachable from another extension.- pi-subagents' precedent:
SubagentsService.registerWorkspaceProvider(provider): () => voidstores a single provider, throws if one already exists, and returns an identity-guarded disposer (subagent-manager.ts:96).
Constraints from AGENTS.md and the package skill that apply:
- Cross-extension communication must go through
pi.eventsorglobalThis+Symbol.for(); module-scoped singletons do not survive jiti's per-extension isolation — so the registry must be owned by the extension factory and reached via the published service. - Enforce permissions deterministically — duplicate-registration behavior must be explicit and testable, not last-write-wins-by-accident.
- "Mechanism is forever" — keep the seam minimal; ship one built-in that proves it rather than a speculative catalog.
- When adding a new exported function that accepts domain objects, keep the parameter type narrow (ISP).
Design Overview
New collaborator: ToolInputFormatterRegistry
A persistent registry owned by the extension factory (index.ts), shared by the service (write side) and the per-call formatter (read side).
// src/tool-input-formatter-registry.ts
/** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
export type ToolInputFormatter = (
input: Record<string, unknown>,
) => string | undefined;
/** Read-only lookup the formatter depends on (ISP — no register/dispose surface). */
export interface ToolInputFormatterLookup {
get(toolName: string): ToolInputFormatter | undefined;
}
export class ToolInputFormatterRegistry implements ToolInputFormatterLookup {
private readonly formatters = new Map<string, ToolInputFormatter>();
/** Register a formatter for `toolName`. Throws if one already exists. Returns a disposer. */
register(toolName: string, formatter: ToolInputFormatter): () => void {
if (this.formatters.has(toolName)) {
throw new Error(
`A tool input formatter is already registered for '${toolName}'.`,
);
}
this.formatters.set(toolName, formatter);
return () => {
if (this.formatters.get(toolName) === formatter) {
this.formatters.delete(toolName);
}
};
}
get(toolName: string): ToolInputFormatter | undefined {
return this.formatters.get(toolName);
}
}
Duplicate handling: one formatter per tool name; a second register for the same name throws.
This mirrors registerWorkspaceProvider and keeps resolution deterministic (a package priority).
The identity-guarded disposer prevents a stale disposer from evicting a later registration.
Read side: seam-first dispatch in ToolPreviewFormatter
The formatter gains an optional ToolInputFormatterLookup (defaulting to absent — backward compatible).
formatToolInputForPrompt consults the lookup first and falls through to the existing switch when the custom formatter declines:
constructor(
private readonly options: ToolPreviewFormatterOptions,
private readonly customFormatters?: ToolInputFormatterLookup,
) {}
formatToolInputForPrompt(toolName: string, input: unknown): string {
const inputRecord = toRecord(input);
const custom = this.customFormatters?.get(toolName);
if (custom) {
const rendered = custom(inputRecord);
if (rendered !== undefined) {
return rendered;
}
}
switch (toolName) {
/* …existing edit/write/read/find/grep/ls/default cases, unchanged… */
}
}
This realizes the chosen precedence: a registered formatter is checked first for any tool; undefined falls through to built-ins; the registrant returns a self-contained fragment to splice after requested tool 'X'.
Write side: registerToolInputFormatter on PermissionsService
The service delegates to the registry — a one-line forward, no reach-through (Tell-Don't-Ask):
const permissionsService: PermissionsService = {
checkPermission(/* … */) {/* … */},
getToolPermission(/* … */) {/* … */},
registerToolInputFormatter(toolName, formatter) {
return registry.register(toolName, formatter);
},
};
Consumer call site (a sibling extension):
const svc = getPermissionsService();
const dispose = svc?.registerToolInputFormatter("my-server:run", (input) =>
Array.isArray(input.commands)
? `runs ${input.commands.length} commands`
: undefined,
);
// later: dispose?.();
Wiring: thread the registry to the per-call formatter
index.ts owns the registry and passes it both to the service and to PermissionGateHandler.
PermissionGateHandler gains an optional ToolInputFormatterLookup constructor parameter and forwards it when constructing the per-call formatter:
// index.ts
const registry = new ToolInputFormatterRegistry();
// …
const gates = new PermissionGateHandler(session, pi.events, toolRegistry, registry);
// permission-gate-handler.ts → handleToolCall
const formatter = new ToolPreviewFormatter(
resolveToolPreviewLimits(this.session.config),
this.customFormatters,
);
The parameter is optional, so the existing handler constructions in test/helpers/handler-fixtures.ts (makeHandler) and the two external-directory-*.test.ts files compile unchanged and exercise the no-formatter path (current behavior).
Only index.ts passes the shared registry.
Reference built-in: MCP input summarizer
A pure formatter that summarizes an MCP call's arguments, plus a registrar that installs it through the public registry:
// src/builtin-tool-input-formatters.ts
export function formatMcpInputForPrompt(
input: Record<string, unknown>,
): string | undefined {
const args = toRecord(input.arguments);
// …compact, truncated "key=value, …" summary of args; undefined when empty…
}
export function registerBuiltinToolInputFormatters(
registry: ToolInputFormatterRegistry,
): void {
registry.register("mcp", formatMcpInputForPrompt);
}
index.ts calls registerBuiltinToolInputFormatters(registry) after constructing the registry — the built-in goes through the same register path a third party would use.
MCP integration point: the MCP branch in formatAskPrompt appends the seam result so a registered mcp formatter enriches the prompt; when it declines (no arguments) the prompt is unchanged:
if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
const patternInfo = result.matchedPattern ? ` (matched '${result.matchedPattern}')` : "";
const preview = formatter ? formatter.formatToolInputForPrompt("mcp", input) : "";
const previewSuffix = preview ? ` ${preview}` : "";
return `${subject} requested MCP target '${result.target}'${patternInfo}${previewSuffix}. Allow this call?`;
}
Edge cases
- No registry / no formatter for a tool →
customFormatters?.getisundefined→ existing behavior verbatim. - Custom formatter throws → not caught here; a registrant is responsible for not throwing. (Noted in Open Questions: whether to guard the call.)
- MCP call with empty/absent
arguments→ built-in returnsundefined→ MCP prompt unchanged. - A registered formatter for a built-in tool (
edit, etc.) returning a string overrides the built-in preview (intended by the chosen precedence); returningundefinedpreserves the built-in. - Duplicate registration for the same tool name → throws (deterministic).
Design-review summary
| Check | Finding | Resolution |
|---|---|---|
| Dependency width | ToolPreviewFormatter gains one optional narrow dep (ToolInputFormatterLookup, 1 method) |
Acceptable; ISP-narrow read interface |
| Law of Demeter | service → registry.register; formatter → lookup.get; no reach-through chains |
Clean |
| Output arguments | none (registry mutates its own Map via its own method) |
Clean |
| Parameter relay | registry threads index.ts → handler ctor → per-call formatter |
Justified: one stable field, not a per-call multi-layer relay |
| ISP on new export | ToolInputFormatter takes Record<string, unknown>; lookup exposes only get |
Narrow |
Verdict: inline (no follow-up extraction needed).
Module-Level Changes
src/tool-input-formatter-registry.ts(new) —ToolInputFormattertype,ToolInputFormatterLookupinterface,ToolInputFormatterRegistryclass.src/tool-preview-formatter.ts— add the optionalcustomFormatters?: ToolInputFormatterLookupconstructor parameter; add the seam-first check at the top offormatToolInputForPrompt. No existing method signature changes.src/service.ts— addregisterToolInputFormatter(toolName: string, formatter: ToolInputFormatter): () => voidto thePermissionsServiceinterface; import/re-exportToolInputFormatterfor consumers.src/handlers/permission-gate-handler.ts— add the optionalcustomFormatters?: ToolInputFormatterLookupconstructor parameter; passthis.customFormattersinto the per-callToolPreviewFormatter.src/builtin-tool-input-formatters.ts(new) —formatMcpInputForPrompt(pure) andregisterBuiltinToolInputFormatters(registry).src/permission-prompts.ts— informatAskPrompt, append the seam preview in the MCP branch.src/index.ts— constructToolInputFormatterRegistry; callregisterBuiltinToolInputFormatters(registry); passregistrytoPermissionGateHandler; addregisterToolInputFormatterto thepermissionsServiceobject.docs/cross-extension-api.md— documentregisterToolInputFormatter(signature, disposer, decline-via-undefined, duplicate-throws) and theToolInputFormattertype.docs/architecture/architecture.md— add the two new modules to the Module structure listing; add a short "Tool input formatter seam (#283)" note.README.md— add a brief mention of the formatter seam under the cross-extension API section if one exists (verify during the docs step).
No exported symbol is removed or renamed; no broad grep-and-replace is required.
Grep confirmed the only PermissionGateHandler constructions are test/helpers/handler-fixtures.ts and the two external-directory-*.test.ts files; the optional parameter keeps them compiling unchanged.
Test Impact Analysis
- New tests the seam enables (previously impossible — there was no registry or injection point):
test/tool-input-formatter-registry.test.ts—registerstores and returns a working disposer;getreturns the formatter; duplicateregisterthrows; disposer is identity-guarded (a stale disposer does not evict a re-registration).test/builtin-tool-input-formatters.test.ts—formatMcpInputForPromptsummarizes arguments, truncates, and returnsundefinedfor empty/absent arguments;registerBuiltinToolInputFormattersinstalls themcpentry.test/tool-preview-formatter.test.ts(extend) — seam-first: custom string is used verbatim; customundefinedfalls through to the built-in switch; absent lookup preserves current behavior.test/service.test.ts(extend) —registerToolInputFormatterdelegates to the registry and returns the disposer.
- Existing tests that must change in lockstep:
test/permission-prompts.test.ts— the MCP ask-prompt cases gain the appended argument summary when a formatter is supplied; the no-formatter case stays unchanged. These updates land in the same step as the MCP-branch change.
- Existing tests that stay as-is (genuinely exercise unchanged layers):
- The
edit/write/read/find/grep/lsformatter cases intool-preview-formatter.test.ts(switch fallthrough unchanged when no custom formatter). extension-config.test.ts, the bash/skill prompt cases, and the review-log preview tests.
- The
TDD Order
- Registry (red → green → commit).
- Surface:
test/tool-input-formatter-registry.test.ts. - Red:
register/get/disposer/duplicate-throws/identity-guarded-disposer. - Green: add
src/tool-input-formatter-registry.ts. - No consumers yet, so this commits in isolation.
- Commit:
feat: add ToolInputFormatterRegistry (#283).
- Surface:
- Seam-first dispatch in the formatter (red → green → commit).
- Surface:
test/tool-preview-formatter.test.ts. - Red: custom formatter string used verbatim;
undefinedfalls through to the switch; absent lookup unchanged. - Green: add the optional
customFormattersparameter and the seam-first check. - Run
pnpm run check— the constructor signature changed (optional, so call sites still compile). - Commit:
feat: consult custom formatter registry in ToolPreviewFormatter (#283).
- Surface:
- Public API + wiring (red → green → commit).
- Surface:
test/service.test.ts. - Red:
registerToolInputFormatterdelegates and returns a disposer. - Green: add the method to the
PermissionsServiceinterface (service.ts) and to thepermissionsServiceobject inindex.ts; add the optionalcustomFormattersparameter toPermissionGateHandlerand forward it inhandleToolCall; construct the registry inindex.tsand pass it to the handler. - The interface change and its sole implementer (the
index.tsobject literal), plus the handler-ctor change and its sole production call site (index.ts), land together — the type checker rejects splitting them. - Run
pnpm run check. - Commit:
feat: expose registerToolInputFormatter on PermissionsService (#283).
- Surface:
- Built-in MCP summarizer + MCP-branch integration (red → green → commit).
- Surface:
test/builtin-tool-input-formatters.test.tsandtest/permission-prompts.test.ts. - Red:
formatMcpInputForPromptbehavior; the MCP ask-prompt appends the summary when a formatter is supplied and is unchanged when arguments are empty/absent. - Green: add
src/builtin-tool-input-formatters.ts; append the seam preview in the MCP branch offormatAskPrompt; register the built-in inindex.ts. - Update the existing MCP prompt assertions in the same commit.
- Commit:
feat: add built-in MCP input summarizer (#283).
- Surface:
- Documentation (build → commit).
- Update
docs/cross-extension-api.md,docs/architecture/architecture.md(module listing + seam note), andREADME.mdif applicable. - Commit:
docs: document tool input formatter seam (#283).
- Update
This is an additive, non-breaking feature throughout — all feat:/docs:, no feat!:.
Risks and Mitigations
- Risk: appending an argument summary changes the MCP prompt text for every MCP call, breaking existing snapshot-style assertions. Mitigation: the MCP-branch change and its test updates are bundled in step 4; the summary is omitted when arguments are empty, preserving the prior text for those cases.
- Risk: a registry shared across the per-call formatter and the service drifts out of sync (two registries).
Mitigation:
index.tsconstructs exactly one registry and passes the same instance to both the handler and the service; tests assert delegation. - Risk: a misbehaving custom formatter throws and aborts prompt construction (a denial-of-service on prompts). Mitigation: documented contract that formatters must not throw; whether to wrap the call in a try/catch is raised in Open Questions — defaulting to no guard keeps behavior transparent, but a guard is cheap if desired.
- Risk: the MCP input shape (
{ tool, server, arguments }) differs from assumptions. Mitigation: write a disposable exploratory check against a realistic MCP tool-call payload before finalizingformatMcpInputForPrompt's argument extraction (per the testing skill). - Risk: duplicate-throws surprises a user who wants to override a built-in (e.g. replace the
mcpsummarizer). Mitigation: documented as a deliberate limitation; overriding is a follow-up if requested (the registry could grow areplaceor precedence model later).
Open Questions
- Should
formatToolInputForPromptwrap the custom formatter call in a try/catch so a throwing registrant cannot break the prompt? Defer to implementation; lean toward no guard initially (transparent failure), revisit if a real registrant misbehaves. - Exact wording of the appended MCP summary (
with input …vs.(args: …)); resolve when writing step 4 against real payloads. - Whether
docs/architecture/architecture.mdshould record this as a formal roadmap phase entry or just a module note; resolve in the docs step.