Files
my-pi/pi-permission-system/docs/plans/0283-tool-input-formatter-seam.md

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 ToolInputFormatterRegistry that maps a tool name to a single custom formatter and hands back a disposer on registration.
  • Expose registerToolInputFormatter(toolName, formatter): () => void on the cross-extension PermissionsService (the Symbol.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 returns undefined, 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 mcp tool, 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-subagents label 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_execute example 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 from ToolPreviewFormatterOptions.
  • #266 wired configurable limits: handleToolCall constructs the formatter fresh on every tool call via new 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: a switch over edit/write/read/find/grep/ls with a default that calls formatJsonInputForPrompt (inline truncated JSON).
  • formatAskPrompt(result, agentName, input, formatter?) (src/permission-prompts.ts:30) builds the ask-prompt sentence. It early-returns for bash and for MCP (when result.target is set, rendering requested MCP target 'server:tool'); only the generic tail calls formatter.formatToolInputForPrompt. MCP calls therefore never reach formatToolInputForPrompt today — 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 a Symbol.for()-backed object published from index.ts; sibling extensions retrieve it with getPermissionsService(). This is the cross-extension channel — a per-call formatter instance is unreachable from another extension.
  • pi-subagents' precedent: SubagentsService.registerWorkspaceProvider(provider): () => void stores 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.events or globalThis + 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?.get is undefined → 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 returns undefined → 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); returning undefined preserves 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) — ToolInputFormatter type, ToolInputFormatterLookup interface, ToolInputFormatterRegistry class.
  • src/tool-preview-formatter.ts — add the optional customFormatters?: ToolInputFormatterLookup constructor parameter; add the seam-first check at the top of formatToolInputForPrompt. No existing method signature changes.
  • src/service.ts — add registerToolInputFormatter(toolName: string, formatter: ToolInputFormatter): () => void to the PermissionsService interface; import/re-export ToolInputFormatter for consumers.
  • src/handlers/permission-gate-handler.ts — add the optional customFormatters?: ToolInputFormatterLookup constructor parameter; pass this.customFormatters into the per-call ToolPreviewFormatter.
  • src/builtin-tool-input-formatters.ts (new) — formatMcpInputForPrompt (pure) and registerBuiltinToolInputFormatters(registry).
  • src/permission-prompts.ts — in formatAskPrompt, append the seam preview in the MCP branch.
  • src/index.ts — construct ToolInputFormatterRegistry; call registerBuiltinToolInputFormatters(registry); pass registry to PermissionGateHandler; add registerToolInputFormatter to the permissionsService object.
  • docs/cross-extension-api.md — document registerToolInputFormatter (signature, disposer, decline-via-undefined, duplicate-throws) and the ToolInputFormatter type.
  • 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

  1. New tests the seam enables (previously impossible — there was no registry or injection point):
    • test/tool-input-formatter-registry.test.tsregister stores and returns a working disposer; get returns the formatter; duplicate register throws; disposer is identity-guarded (a stale disposer does not evict a re-registration).
    • test/builtin-tool-input-formatters.test.tsformatMcpInputForPrompt summarizes arguments, truncates, and returns undefined for empty/absent arguments; registerBuiltinToolInputFormatters installs the mcp entry.
    • test/tool-preview-formatter.test.ts (extend) — seam-first: custom string is used verbatim; custom undefined falls through to the built-in switch; absent lookup preserves current behavior.
    • test/service.test.ts (extend) — registerToolInputFormatter delegates to the registry and returns the disposer.
  2. 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.
  3. Existing tests that stay as-is (genuinely exercise unchanged layers):
    • The edit/write/read/find/grep/ls formatter cases in tool-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.

TDD Order

  1. 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).
  2. Seam-first dispatch in the formatter (red → green → commit).
    • Surface: test/tool-preview-formatter.test.ts.
    • Red: custom formatter string used verbatim; undefined falls through to the switch; absent lookup unchanged.
    • Green: add the optional customFormatters parameter 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).
  3. Public API + wiring (red → green → commit).
    • Surface: test/service.test.ts.
    • Red: registerToolInputFormatter delegates and returns a disposer.
    • Green: add the method to the PermissionsService interface (service.ts) and to the permissionsService object in index.ts; add the optional customFormatters parameter to PermissionGateHandler and forward it in handleToolCall; construct the registry in index.ts and pass it to the handler.
    • The interface change and its sole implementer (the index.ts object 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).
  4. Built-in MCP summarizer + MCP-branch integration (red → green → commit).
    • Surface: test/builtin-tool-input-formatters.test.ts and test/permission-prompts.test.ts.
    • Red: formatMcpInputForPrompt behavior; 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 of formatAskPrompt; register the built-in in index.ts.
    • Update the existing MCP prompt assertions in the same commit.
    • Commit: feat: add built-in MCP input summarizer (#283).
  5. Documentation (build → commit).
    • Update docs/cross-extension-api.md, docs/architecture/architecture.md (module listing + seam note), and README.md if applicable.
    • Commit: docs: document tool input formatter seam (#283).

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.ts constructs 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 finalizing formatMcpInputForPrompt's argument extraction (per the testing skill).
  • Risk: duplicate-throws surprises a user who wants to override a built-in (e.g. replace the mcp summarizer). Mitigation: documented as a deliberate limitation; overriding is a follow-up if requested (the registry could grow a replace or precedence model later).

Open Questions

  • Should formatToolInputForPrompt wrap 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.md should record this as a formal roadmap phase entry or just a module note; resolve in the docs step.