feat(curator): tool extension, generated prompt check, two pi findings

curator-tools.ts registers no schema of its own: it fetches the specs from the
backend bridge, so contracts.py stays the single owner and there is no TypeScript
copy to drift. It refuses to activate without a bridge URL and token, because an
agent that silently loses its tools still answers -- from the model's memory of
what a media library might contain.

deploy-scenario.sh now vendors listed shared/extensions modules into
.pi/extensions/_shared/. A tracked extension importing from shared/ cannot
resolve that path once installed outside the repository, so the deployed tree has
to be self-contained; this overwrites rather than merges, keeping the repository
authoritative. common.sh gains toml_list, using tomllib rather than more awk
because an array can span lines or carry comments.

verify-generated.sh checks that generated regions in tracked prompts match the
backend that generates them, and is wired into the pre-commit hook. This is
needed because of finding 22 below: the tool list has to be copied into the
prompt, and a copy drifts silently.

Two findings recorded in docs/pi-runtime-notes.md, both measured:

  21. An extension that fails to import is silent -- exit 0, empty stderr, no
      tools. A missing --extension path exits 1 with a clear message, but a
      module that throws while loading reports nothing. The agent then invented a
      complete library listing with plausible episode counts, quality and size.
      A later identical run said it had no data instead, so the failure is both
      silent and inconsistent.

  22. --system-prompt suppresses the tool list. The customPrompt branch returns
      before toolsList and guidelines are built, so promptSnippet and
      promptGuidelines are inert. The tools stay callable over the provider API,
      so tool use becomes a coin flip: one run in four looked at the library and
      three said they had not been given any results.

SYSTEM.phase3.md is staged alongside the deployed SYSTEM.md rather than replacing
it: profile.toml still describes the phase-0 configuration that is actually
running, and the live service is untouched.
This commit is contained in:
Kai
2026-08-28 00:35:04 -07:00
parent b5564e3c60
commit eaa3f6a8a1
7 changed files with 359 additions and 0 deletions
@@ -0,0 +1,66 @@
/**
* Curator's tools.
*
* Every tool is a proxy to the loopback bridge in `curator/agent_api.py`. The
* schemas and descriptions are fetched from the backend at `/tools` rather than
* declared here, because `registerTool` accepts a plain JSON Schema object and a
* second copy in TypeScript is a copy that drifts. When contracts.py changes,
* this file needs no edit.
*
* What this file adds on top of the shared bridge helper:
*
* - It refuses to start without a bridge URL and token. A silent start would
* produce an agent with no tools that answers from memory instead -- which
* looks like a working system and is the failure mode hardest to notice.
* - It installs the guard, so no built-in tool can be reached even if a future
* pi version changes which tools are on by default.
*
* Nothing here decides whether a write happens. `propose_write` posts a proposal
* and relays the verdict; the decision is in `service.ACTION_RISK`.
*
* `./_shared/` is vendored by scripts/deploy-scenario.sh from shared/extensions/
* so the deployed tree is self-contained. It is not edited in place -- the
* deploy script overwrites it, and pi-diff.sh reports drift against the repo.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
fetchBridgeSpecs,
installGuard,
registerBridgeTools,
} from "./_shared/pi-guard-base.ts";
export default async function activate(pi: ExtensionAPI): Promise<void> {
const baseUrl = process.env.CURATOR_BRIDGE_URL;
const token = process.env.CURATOR_BRIDGE_TOKEN;
if (!baseUrl || !token) {
// Fail loudly. An agent that silently loses its tools still answers, just
// from the model's memory of what a media library might contain.
throw new Error(
"curator-tools: CURATOR_BRIDGE_URL and CURATOR_BRIDGE_TOKEN are required. " +
"Without them the agent would have no way to see the library and would " +
"answer from memory.",
);
}
const bridge = { baseUrl, token, timeoutMs: 45_000 };
const specs = await fetchBridgeSpecs(bridge);
if (specs.length === 0) {
throw new Error("curator-tools: the bridge served an empty tool list");
}
// Deny every built-in tool. --no-builtin-tools is set on the command line too;
// this is the second lock, because the flag is a launch argument while this is
// enforced per call. The allow-list is derived from what the backend actually
// serves, so a tool cannot be advertised and then blocked.
installGuard(pi, {
scenario: "curator",
allowedTools: specs.map((spec) => spec.name),
onBlocked: (name: string) =>
console.error(`curator-tools: blocked built-in tool ${name}`),
});
registerBridgeTools(pi, bridge, specs);
}