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 {...@@ -30,7 +30,10 @@ interface RegisteredTool {
3030
31type AgentStartHook = (31type AgentStartHook = (
32 event: { systemPrompt: string },32 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 },
34) => Promise<{ systemPrompt: string }>;37) => Promise<{ systemPrompt: string }>;
3538
36export async function loadMcpBridge(39export async function loadMcpBridge(
packages/provider-pi/src/server/mcpExtensionSource.test.ts+61-18
...@@ -111,18 +111,40 @@ describe("Pi MCP tool results", () => {...@@ -111,18 +111,40 @@ describe("Pi MCP tool results", () => {
111});111});
112112
113describe("Pi MCP tool exposure", () => {113describe("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
114 it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {142 it("keeps orchestration direct and optional bridge tools discoverable on modern Pi", async () => {
115 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });143 const bridge = await loadMcpBridge({ modern: true, toolSearchAvailable: true });
116 await bridge.handlers.get("session_start")!(144 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
117 { systemPrompt: "" },
118 { ui: { notify: () => undefined } },
119 );
120 const start = bridge.handlers.get("before_agent_start");145 const start = bridge.handlers.get("before_agent_start");
121 assert.isDefined(start);146 assert.isDefined(start);
122 const prompt = await start!(147 const prompt = await start!({ systemPrompt: "Pi system prompt" }, discoveryContext);
123 { systemPrompt: "Pi system prompt" },
124 { ui: { notify: () => undefined } },
125 );
126 assert.include(prompt.systemPrompt, "orchestrator_capabilities");148 assert.include(prompt.systemPrompt, "orchestrator_capabilities");
127 assert.equal(bridge.servers.length, 0);149 assert.equal(bridge.servers.length, 0);
128 assert.equal(bridge.tools.length, 8);150 assert.equal(bridge.tools.length, 8);
...@@ -154,6 +176,33 @@ describe("Pi MCP tool exposure", () => {...@@ -154,6 +176,33 @@ describe("Pi MCP tool exposure", () => {
154 assert.equal(bridge.requests.at(-1)?.method, "tools/call");176 assert.equal(bridge.requests.at(-1)?.method, "tools/call");
155 });177 });
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
157 it.each(["legacy Pi", "disabled tool search"])(206 it.each(["legacy Pi", "disabled tool search"])(
158 "keeps tool execution available with %s",207 "keeps tool execution available with %s",
159 async (mode) => {208 async (mode) => {
...@@ -164,7 +213,7 @@ describe("Pi MCP tool exposure", () => {...@@ -164,7 +213,7 @@ describe("Pi MCP tool exposure", () => {
164 });213 });
165 if (mode !== "legacy Pi") {214 if (mode !== "legacy Pi") {
166 const start = bridge.handlers.get("session_start");215 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);
168 }217 }
169 assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);218 assert.equal(bridge.tools.filter((tool) => tool.exposure !== "hidden").length, 4);
170 assert.isTrue(219 assert.isTrue(
...@@ -198,10 +247,7 @@ describe("Pi MCP tool exposure", () => {...@@ -198,10 +247,7 @@ describe("Pi MCP tool exposure", () => {
198 allowsTool: (name) =>247 allowsTool: (name) =>
199 name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),248 name === "read" || name === "tool_search" || name.startsWith("mcp__t3-code__"),
200 });249 });
201 await bridge.handlers.get("session_start")!(250 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
202 { systemPrompt: "" },
203 { ui: { notify: () => undefined } },
204 );
205 assert.equal(bridge.tools.length, 4);251 assert.equal(bridge.tools.length, 4);
206 assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);252 assert.equal(bridge.tools.filter((tool) => tool.exposure === "direct").length, 3);
207 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");253 const tool = bridge.tools.find((tool) => tool.name === "mcp__t3-code__preview_snapshot");
...@@ -218,10 +264,7 @@ describe("Pi MCP tool exposure", () => {...@@ -218,10 +264,7 @@ describe("Pi MCP tool exposure", () => {
218 allowsTool: (name) =>264 allowsTool: (name) =>
219 name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),265 name !== "mcp__t3-code__delegate_task" && (!excludeSearch || name !== "tool_search"),
220 });266 });
221 await bridge.handlers.get("session_start")!(267 await bridge.handlers.get("session_start")!({ systemPrompt: "" }, discoveryContext);
222 { systemPrompt: "" },
223 { ui: { notify: () => undefined } },
224 );
225 bridge.restoreActiveTools([268 bridge.restoreActiveTools([
226 "read",269 "read",
227 "mcp__t3-code__task_status",270 "mcp__t3-code__task_status",
...@@ -229,7 +272,7 @@ describe("Pi MCP tool exposure", () => {...@@ -229,7 +272,7 @@ describe("Pi MCP tool exposure", () => {
229 ]);272 ]);
230 const tree = bridge.handlers.get("session_tree");273 const tree = bridge.handlers.get("session_tree");
231 assert.isDefined(tree);274 assert.isDefined(tree);
232 await tree!({ systemPrompt: "" }, { ui: { notify: () => undefined } });275 await tree!({ systemPrompt: "" }, discoveryContext);
233 assert.deepEqual(bridge.getActiveTools(), [276 assert.deepEqual(bridge.getActiveTools(), [
234 "read",277 "read",
235 "mcp__t3-code__task_status",278 "mcp__t3-code__task_status",
packages/provider-pi/src/server/mcpExtensionSource.ts+23-13
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1/**1/**
2 * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.2 * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server.
3 *3 *
4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. Older versions4 * Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion
5 * keep them directly available. Pi loads this TypeScript via `--extension`; the5 * gateways need their schemas loaded up front. Older versions keep tools
6 * directly available. Pi loads this TypeScript via `--extension`; the
6 * server writes it to a cache so packaged builds need no sibling .ts file.7 * server writes it to a cache so packaged builds need no sibling .ts file.
7 *8 *
8 * Do not import t3code modules from the string body. The Pi process resolves9 * 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";...@@ -24,7 +25,7 @@ export const T3_PI_MCP_EXTENSION_PATH_ENV = "T3_PI_MCP_EXTENSION_PATH";
24export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const;25export const PI_FILE_CHANGE_TOOLS = ["edit", "write"] as const;
2526
26export const PI_T3_MCP_EXTENSION_SOURCE = `\27export 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";
28import * as NodeFSP from "node:fs/promises";29import * as NodeFSP from "node:fs/promises";
29import * as NodePath from "node:path";30import * as NodePath from "node:path";
30import { Type } from "typebox";31import { Type } from "typebox";
...@@ -421,23 +422,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) {...@@ -421,23 +422,31 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
421 // mcp.json configures it; retry a failed connection at session_start.422 // mcp.json configures it; retry a failed connection at session_start.
422 await ensureStarted().catch(() => undefined);423 await ensureStarted().catch(() => undefined);
423424
424 const reconcileDiscovery = () => {425 const reconcileDiscovery = (ctx: Pick<ExtensionContext, "model">) => {
425 if (!supportsExposure) return;426 if (!supportsExposure) return;
426 // A disabled or replaced search builtin cannot discover deferred tools.427 // A disabled or replaced search builtin cannot discover deferred tools.
427 const hasToolSearch = hasBuiltinToolSearch();428 const hasToolSearch = hasBuiltinToolSearch();
428 const exposureChanged = deferOptionalTools !== hasToolSearch;429 const exposureChanged = deferOptionalTools !== hasToolSearch;
429 deferOptionalTools = hasToolSearch;430 deferOptionalTools = hasToolSearch;
430 if (exposureChanged) registerTools();431 if (exposureChanged) registerTools();
431 if (hasToolSearch) {432 const active = pi.getActiveTools();
432 const active = pi.getActiveTools();433 // Completion gateways can silently drop calls to undeclared tools named
433 if (!active.includes("tool_search")) pi.setActiveTools([...active, "tool_search"]);434 // in history or guidance. Eagerly load T3 schemas, including after tree
434 }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]);
435 };443 };
436444
437 // Tree navigation restores its saved loadout after session_start.445 // 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));
439 pi.on("session_start", async (_event, ctx) => {448 pi.on("session_start", async (_event, ctx) => {
440 reconcileDiscovery();449 reconcileDiscovery(ctx);
441 try {450 try {
442 await ensureStarted();451 await ensureStarted();
443 } catch (error) {452 } catch (error) {
...@@ -449,8 +458,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) {...@@ -449,8 +458,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
449 // Deliver orchestration guidance through pi's real system-prompt channel.458 // Deliver orchestration guidance through pi's real system-prompt channel.
450 // Wrapping the first user message instead would stop it from starting459 // Wrapping the first user message instead would stop it from starting
451 // with "/" and silently break slash-command expansion.460 // with "/" and silently break slash-command expansion.
452 pi.on("before_agent_start", (event) => ({461 pi.on("before_agent_start", (event, ctx) => {
453 systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS,462 reconcileDiscovery(ctx);
454 }));463 return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS };
464 });
455}465}
456`;466`;