authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-13 19:40:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 12:40:19-05:00
log4d7683557ad4a2411d6de405e500e4a56b43d0e1
tree2a9de8298af3b6ac202918a513ffed02ee9ded25
parent9a9fd2e3ed613275b0c1fdf356def2230480c47e
signature Signed by PGP key B5690EEEBB952194

fix(pi): load T3 tool schemas for completion gateways (#18643)


3 files changed, 88 insertions(+), 32 deletions(-)

packages/provider-pi/src/server/mcpBridge.testkit.ts+4-1
......@@ -30,7 +30,10 @@ interface RegisteredTool {
3030
3131type AgentStartHook = (
3232 event: { systemPrompt: string },
33 ctx: { ui: { notify: (message: string, severity: string) => void } },
33 ctx: {
34 model?: { api: string };
35 ui: { notify: (message: string, severity: string) => void };
36 },
3437) => Promise<{ systemPrompt: string }>;
3538
3639export async function loadMcpBridge(
packages/provider-pi/src/server/mcpExtensionSource.test.ts+61-18
......@@ -111,18 +111,40 @@ describe("Pi MCP tool results", () => {
111111});
112112
113113describe("Pi MCP tool exposure", () => {
114 const discoveryContext = {
115 model: { api: "anthropic-messages" },
116 ui: { notify: () => undefined },
117 };
118
119 it.each(["session_start", "session_tree", "model_select", "before_agent_start"])(
120 "declares optional T3 tools for OpenAI-compatible gateways at %s",
121 async (hookName) => {
122 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
123 const hook = bridge.handlers.get(hookName);
124 assert.isDefined(hook);
125 await hook!(
126 { systemPrompt: "Pi system prompt" },
127 { model: { api: "openai-completions" }, ui: { notify: assert.fail } },
128 );
129 assert.isTrue(
130 bridge.tools
131 .filter((tool) => tool.name.startsWith("mcp__t3-code__"))
132 .every((tool) => bridge.getActiveTools().includes(tool.name)),
133 );
134 const result = await bridge.tools
135 .find((tool) => tool.name === "mcp__t3-code__preview_snapshot")!
136 .execute("gateway-call", {});
137 assert.equal(result.content[0]?.text, "browser snapshot");
138 assert.equal(bridge.tools.filter((tool) => tool.exposure === "hidden").length, 4);
139 },
140 );
141
114142 it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {
115143 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
116 await bridge.handlers.get("session_start")!(
117 { systemPrompt: "" },
118 { ui: { notify: () => undefined } },
119 );
144 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
120145 const start = bridge.handlers.get("before_agent_start");
121146 assert.isDefined(start);
122 const prompt = await start!(
123 { systemPrompt: "Pi system prompt" },
124 { ui: { notify: () => undefined } },
125 );
147 const prompt = await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
126148 assert.include(prompt.systemPrompt, "orchestrator_capabilities");
127149 assert.equal(bridge.servers.length, 0);
128150 assert.equal(bridge.tools.length, 8);
......@@ -154,6 +176,33 @@ describe("Pi MCP tool exposure", () => {
154176 assert.equal(bridge.requests.at(-1)?.method, "tools/call");
155177 });
156178
179 it("restores gateway declarations after model switches and older tree loadouts without excluded tools", async () => {
180 const bridge = await loadMcpBridge({
181 modern: true,
182 toolSearchAvailable: true,
183 allowsTool: (name) => name !== "mcp__t3-code__delegate_task",
184 });
185 const event = { systemPrompt: "" };
186 await bridge.handlers.get("session_start")!(event, discoveryContext);
187 const gatewayContext = { model: { api: "openai-completions" }, ui: { notify: assert.fail } };
188 await bridge.handlers.get("model_select")!(event, gatewayContext);
189 bridge.restoreActiveTools(["read", "mcp__t3-code__task_status"]);
190 await bridge.handlers.get("session_tree")!(event, gatewayContext);
191 assert.deepEqual(bridge.getActiveTools(), [
192 "read",
193 "mcp__t3-code__task_status",
194 "tool_search",
195 "mcp__t3-code__orchestrator_capabilities",
196 "mcp__t3-code__preview_snapshot",
197 ]);
198 await bridge.handlers.get("model_select")!(event, discoveryContext);
199 assert.equal(
200 bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot")?.exposure,
201 "deferred",
202 );
203 assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot");
204 });
205
157206 it.each(["legacy Pi", "disabled tool search"])(
158207 "keeps tool execution available with %s",
159208 async (mode) => {
......@@ -164,7 +213,7 @@ describe("Pi MCP tool exposure", () => {
164213 });
165214 if (mode !== "legacy Pi") {
166215 const start = bridge.handlers.get("session_start");
167 await start!({ systemPrompt: "Pi system prompt" }, { ui: { notify: () => undefined } });
216 await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
168217 }
169218 assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);
170219 assert.isTrue(
......@@ -198,10 +247,7 @@ describe("Pi MCP tool exposure", () => {
198247 allowsTool: (name) =>
199248 name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),
200249 });
201 await bridge.handlers.get("session_start")!(
202 { systemPrompt: "" },
203 { ui: { notify: () => undefined } },
204 );
250 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
205251 assert.equal(bridge.tools.length, 4);
206252 assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);
207253 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
......@@ -218,10 +264,7 @@ describe("Pi MCP tool exposure", () => {
218264 allowsTool: (name) =>
219265 name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),
220266 });
221 await bridge.handlers.get("session_start")!(
222 { systemPrompt: "" },
223 { ui: { notify: () => undefined } },
224 );
267 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
225268 bridge.restoreActiveTools([
226269 "read",
227270 "mcp__t3-code__task_status",
......@@ -229,7 +272,7 @@ describe("Pi MCP tool exposure", () => {
229272 ]);
230273 const tree = bridge.handlers.get("session_tree");
231274 assert.isDefined(tree);
232 await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } });
275 await tree!({ systemPrompt: "" }, discoveryContext);
233276 assert.deepEqual(bridge.getActiveTools(), [
234277 "read",
235278 "mcp__t3-code__task_status",
packages/provider-pi/src/server/mcpExtensionSource.ts+23-13
......@@ -1,8 +1,9 @@
11/**
22 * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.
33 *
4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions
5 * keep them directly available. Pi loads this TypeScript via `--extension`; the
4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion
5 * gateways need their schemas loaded up front. Older versions keep tools
6 * directly available. Pi loads this TypeScript via `--extension`; the
67 * server writes it to a cache so packaged builds need no sibling .ts file.
78 *
89 * Do not import t3code modules from the string body. The Pi process resolves
......@@ -24,7 +25,7 @@ export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";
2425export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const;
2526
2627export const PI_T3_MCP_EXTENSION_SOURCE = `\
27import { stripFrontmatter, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
28import { stripFrontmatter, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2829import * as NodeFSP from "node:fs/promises";
2930import * as NodePath from "node:path";
3031import { Type } from "typebox";
......@@ -421,23 +422,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
421422 // mcp.json configures it; retry a failed connection at session_start.
422423 await ensureStarted().catch(() => undefined);
423424
424 const reconcileDiscovery = () => {
425 const reconcileDiscovery = (ctx: Pick<ExtensionContext, "model">) => {
425426 if (!supportsExposure) return;
426427 // A disabled or replaced search builtin cannot discover deferred tools.
427428 const hasToolSearch = hasBuiltinToolSearch();
428429 const exposureChanged = deferOptionalTools !== hasToolSearch;
429430 deferOptionalTools = hasToolSearch;
430431 if (exposureChanged) registerTools();
431 if (hasToolSearch) {
432 const active = pi.getActiveTools();
433 if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]);
434 }
432 const active = pi.getActiveTools();
433 // Completion gateways can silently drop calls to undeclared tools named
434 // in history or guidance. Eagerly load T3 schemas, including after tree
435 // restores, while retaining deferred exposure for Pi's allowlist rules.
436 // Pi still applies --tools/--exclude-tools to this active loadout.
437 const required = [
438 ...(hasToolSearch ? ["tool_search"] : []),
439 ...(ctx.model?.api === "openai-completions" ? catalog.map((tool) => "mcp__t3-code__" + tool.name) : []),
440 ];
441 const missing = required.filter((name) => !active.includes(name));
442 if (missing.length > 0) pi.setActiveTools([...active, ...missing]);
435443 };
436444
437445 // Tree navigation restores its saved loadout after session_start.
438 pi.on("session_tree", reconcileDiscovery);
446 pi.on("session_tree", (_event, ctx) => reconcileDiscovery(ctx));
447 pi.on("model_select", (_event, ctx) => reconcileDiscovery(ctx));
439448 pi.on("session_start", async (_event, ctx) => {
440 reconcileDiscovery();
449 reconcileDiscovery(ctx);
441450 try {
442451 await ensureStarted();
443452 } catch (error) {
......@@ -449,8 +458,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
449458 // Deliver orchestration guidance through pi's real system-prompt channel.
450459 // Wrapping the first user message instead would stop it from starting
451460 // with "/" and silently break slash-command expansion.
452 pi.on("before_agent_start", (event) => ({
453 systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS,
454 }));
461 pi.on("before_agent_start", (event, ctx) => {
462 reconcileDiscovery(ctx);
463 return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS };
464 });
455465}
456466`;