Skip to content

Commit f0e905e

Browse files
committed
fix(pi): retain gateway orchestration tools after loadout restores
Keep direct orchestration tools in called mode, preserve eager declarations without native tool_search, and document the Pi 0.99 exposure requirement. Verify native selection rules and both instance settings through the subprocess boundary.
1 parent 2d60a4a commit f0e905e

6 files changed

Lines changed: 132 additions & 52 deletions

File tree

‎docs/user/providers-pi.md‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,13 @@ directory, endpoint, or model configuration. `--provider` must be paired with `-
2323
rejects launch arguments that change Pi's execution mode or select a session because T3 owns those
2424
parts of the process lifecycle.
2525

26-
Pi models on OpenAI-compatible chat completion gateways receive every T3 Code tool schema on each
27-
request, because some gateways drop calls to tools the request does not declare. If your gateway
28-
runs tools that Pi loads through `tool_search`, turn off **Load every T3 tool on completion
29-
gateways** in the Pi instance's settings to send smaller requests. Pi then declares the orchestration
30-
tools, `tool_search`, the T3 Code tools `tool_search` loaded, and any T3 Code tool the conversation
31-
has already called. An existing thread keeps every tool it already loaded, including the full set
32-
loaded before you turned the switch off, so the smaller requests apply to new threads and to tools
33-
not yet loaded.
26+
Pi sends every T3 Code tool schema to OpenAI-compatible completion gateways because some gateways
27+
drop calls to undeclared tools. On Pi 0.99 or newer with built-in `tool_search` enabled, turn off
28+
**Load every T3 tool on completion gateways** in the Pi instance's settings if your gateway supports
29+
tools loaded through `tool_search`. Requests then include orchestration tools, `tool_search`, tools
30+
loaded by search, and tools already called in the retained conversation history. Start a new thread
31+
to avoid keeping an earlier eager loadout. Older Pi versions and sessions without built-in
32+
`tool_search` keep all schemas.
3433

3534
On a remote or headless server, connect to that machine over SSH and run `pi`, then `/login`.
3635
Open the authorization URL on your own computer. If the callback cannot reach the server,

‎packages/provider-pi/src/server/adapter.test.ts‎

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,30 +1420,46 @@ describe("PiAdapterV2", () => {
14201420
}).pipe(Effect.scoped, Effect.provide(layerTest)),
14211421
);
14221422

1423-
it.effect("injects the T3 MCP extension and bearer when a session exists", () =>
1424-
Effect.gen(function* () {
1425-
yield* (yield* McpProviderSessions.McpProviderSessions).set({
1426-
environmentId: EnvironmentId.make("environment-pi-mcp"),
1427-
threadId: THREAD_ID,
1428-
providerSessionId: "mcp-session-pi",
1429-
providerInstanceId: PI_INSTANCE_ID,
1430-
endpoint: "http://127.0.0.1:43123/mcp",
1431-
authorizationHeader: "Bearer secret-pi-token",
1432-
browserToolsAvailable: true,
1433-
});
1434-
const fake = yield* makeFakePi;
1435-
yield* openRuntime(fake);
1436-
const spawn = fake.lastSpawn();
1437-
assert.isTrue(spawn.args.includes("--extension"));
1438-
const extensions = spawn.args.flatMap((arg, index) =>
1439-
arg === "--extension" ? [spawn.args[index + 1]] : [],
1440-
);
1441-
assert.isFalse(spawn.args.includes("--no-extensions"));
1442-
assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts")));
1443-
assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp");
1444-
assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token");
1445-
assert.equal(spawn.env.T3_PI_RUNTIME_MODE, "full-access");
1446-
}).pipe(Effect.scoped, Effect.provide(layerTest)),
1423+
it.effect.each([true, false])(
1424+
"injects the T3 MCP session with eager completion gateway tools=%s",
1425+
(eagerCompletionGatewayTools) =>
1426+
Effect.gen(function* () {
1427+
yield* (yield* McpProviderSessions.McpProviderSessions).set({
1428+
environmentId: EnvironmentId.make("environment-pi-mcp"),
1429+
threadId: THREAD_ID,
1430+
providerSessionId: "mcp-session-pi",
1431+
providerInstanceId: PI_INSTANCE_ID,
1432+
endpoint: "http://127.0.0.1:43123/mcp",
1433+
authorizationHeader: "Bearer secret-pi-token",
1434+
browserToolsAvailable: true,
1435+
});
1436+
const fake = yield* makeFakePi;
1437+
const adapter = yield* makePiAdapterV2({
1438+
instanceId: PI_INSTANCE_ID,
1439+
settings: { ...PiAdapterV2Driver.defaultConfig(), eagerCompletionGatewayTools },
1440+
environment: {},
1441+
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, fake.spawner));
1442+
yield* adapter.openSession({
1443+
threadId: THREAD_ID,
1444+
providerSessionId: SESSION_ID,
1445+
modelSelection: modelSelection("default"),
1446+
runtimePolicy,
1447+
});
1448+
const spawn = fake.lastSpawn();
1449+
assert.isTrue(spawn.args.includes("--extension"));
1450+
const extensions = spawn.args.flatMap((arg, index) =>
1451+
arg === "--extension" ? [spawn.args[index + 1]] : [],
1452+
);
1453+
assert.isFalse(spawn.args.includes("--no-extensions"));
1454+
assert.isTrue(extensions.some((path) => path?.endsWith("pi-t3-mcp-extension.ts")));
1455+
assert.equal(spawn.env.T3_MCP_URL, "http://127.0.0.1:43123/mcp");
1456+
assert.equal(spawn.env.T3_MCP_BEARER_TOKEN, "secret-pi-token");
1457+
assert.equal(spawn.env.T3_PI_RUNTIME_MODE, "full-access");
1458+
assert.equal(
1459+
spawn.env.T3_PI_GATEWAY_TOOLS,
1460+
eagerCompletionGatewayTools ? undefined : "called",
1461+
);
1462+
}).pipe(Effect.scoped, Effect.provide(layerTest)),
14471463
);
14481464

14491465
it.effect("rejects a resume while a turn is active", () =>

‎packages/provider-pi/src/server/mcpExtensionSource.test.ts‎

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ describe("Pi MCP tool exposure", () => {
200200
assert.include(bridge.getActiveTools(), "mcp__t3-code__preview_snapshot");
201201
});
202202

203-
it("declares only called T3 tools for gateways that opt out of eager schemas", async () => {
203+
it("keeps orchestration and called tools declared for gateways that opt out of eager schemas", async () => {
204204
const bridge = await loadMcpBridge({
205205
modern: true,
206206
toolSearchAvailable: true,
@@ -218,7 +218,18 @@ describe("Pi MCP tool exposure", () => {
218218
};
219219
await bridge.handlers.get("session_start")!(event, gatewayContext);
220220
await bridge.handlers.get("before_agent_start")!(event, gatewayContext);
221-
assert.deepEqual(bridge.getActiveTools(), ["read", "tool_search"]);
221+
const required = [
222+
"read",
223+
"tool_search",
224+
"mcp__t3-code__orchestrator_capabilities",
225+
"mcp__t3-code__delegate_task",
226+
"mcp__t3-code__task_status",
227+
];
228+
assert.deepEqual(bridge.getActiveTools(), required);
229+
230+
bridge.restoreActiveTools(["read"]);
231+
await bridge.handlers.get("session_tree")!(event, gatewayContext);
232+
assert.deepEqual(bridge.getActiveTools(), required);
222233

223234
messages.push(
224235
{ role: "user", content: [{ type: "text" }] },
@@ -231,17 +242,71 @@ describe("Pi MCP tool exposure", () => {
231242
],
232243
},
233244
);
234-
await bridge.handlers.get("model_select")!(event, gatewayContext);
245+
for (const hookName of ["model_select", "session_tree", "before_agent_start"]) {
246+
bridge.restoreActiveTools(["read"]);
247+
await bridge.handlers.get(hookName)!(event, gatewayContext);
248+
assert.deepEqual(bridge.getActiveTools(), [...required, "mcp__t3-code__preview_snapshot"]);
249+
}
250+
251+
messages.length = 0;
252+
bridge.restoreActiveTools(["read"]);
253+
await bridge.handlers.get("session_tree")!(event, gatewayContext);
254+
assert.deepEqual(bridge.getActiveTools(), required);
255+
});
256+
257+
it("keeps native tool exclusions when restoring a called-tool gateway loadout", async () => {
258+
const bridge = await loadMcpBridge({
259+
modern: true,
260+
toolSearchAvailable: true,
261+
gatewayTools: "called",
262+
allowsTool: (name) =>
263+
name !== "mcp__t3-code__delegate_task" && name !== "mcp__t3-code__preview_snapshot",
264+
});
265+
await bridge.handlers.get("session_tree")!(
266+
{ systemPrompt: "" },
267+
{
268+
model: { api: "openai-completions" },
269+
sessionManager: {
270+
buildSessionProjection: () => ({
271+
messages: [
272+
{
273+
role: "assistant",
274+
content: [{ type: "toolCall", name: "mcp__t3-code__preview_snapshot" }],
275+
},
276+
],
277+
}),
278+
},
279+
ui: { notify: assert.fail },
280+
},
281+
);
235282
assert.deepEqual(bridge.getActiveTools(), [
236283
"read",
237284
"tool_search",
238-
"mcp__t3-code__preview_snapshot",
285+
"mcp__t3-code__orchestrator_capabilities",
286+
"mcp__t3-code__task_status",
239287
]);
288+
});
289+
290+
it("keeps every gateway tool declared when the opt-out has no native tool search", async () => {
291+
const bridge = await loadMcpBridge({
292+
modern: true,
293+
toolSearchAvailable: true,
294+
toolSearchDisabled: true,
295+
gatewayTools: "called",
296+
});
297+
const ctx = {
298+
model: { api: "openai-completions" },
299+
sessionManager: { buildSessionProjection: () => ({ messages: [] }) },
300+
ui: { notify: assert.fail },
301+
};
302+
await bridge.handlers.get("session_start")!({ systemPrompt: "" }, ctx);
240303
bridge.restoreActiveTools(["read"]);
241-
await bridge.handlers.get("session_tree")!(event, gatewayContext);
304+
await bridge.handlers.get("session_tree")!({ systemPrompt: "" }, ctx);
242305
assert.deepEqual(bridge.getActiveTools(), [
243306
"read",
244-
"tool_search",
307+
"mcp__t3-code__orchestrator_capabilities",
308+
"mcp__t3-code__delegate_task",
309+
"mcp__t3-code__task_status",
245310
"mcp__t3-code__preview_snapshot",
246311
]);
247312
});
@@ -283,6 +348,7 @@ describe("Pi MCP tool exposure", () => {
283348
modern: mode !== "legacy Pi",
284349
toolSearchAvailable: mode === "disabled tool search",
285350
toolSearchDisabled: mode === "disabled tool search",
351+
gatewayTools: "called",
286352
});
287353
if (mode !== "legacy Pi") {
288354
const start = bridge.handlers.get("session_start");

‎packages/provider-pi/src/server/mcpExtensionSource.ts‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,8 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
423423
// mcp.json configures it; retry a failed connection at session_start.
424424
await ensureStarted().catch(() => undefined);
425425
426-
// Instances whose gateway runs tools found through tool_search opt out of
427-
// the full catalog and keep only T3 tools already called in this branch,
428-
// plus whatever tool_search loaded into the active loadout. Tools already
429-
// active stay, including ones eagerly loaded before the opt-out.
426+
// Opting out keeps orchestration and called tools alongside the active
427+
// loadout. Previously discovered or eagerly loaded tools remain active.
430428
const eagerGatewayTools = env(GATEWAY_TOOLS_ENV) !== "called";
431429
const calledToolNames = (ctx: Pick<ExtensionContext, "sessionManager">) => {
432430
const names = new Set<string>();
@@ -447,17 +445,15 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
447445
deferOptionalTools = hasToolSearch;
448446
if (exposureChanged) registerTools();
449447
const active = pi.getActiveTools();
450-
// Completion gateways can silently drop calls to undeclared tools named
451-
// in history or guidance. Eagerly load T3 schemas, including after tree
452-
// restores, while retaining deferred exposure for Pi's allowlist rules.
453-
// Pi still applies --tools/--exclude-tools to this active loadout.
448+
// Gateways can drop undeclared calls. Keep all schemas unless the instance
449+
// opts out with native discovery available. Pi still applies its native
450+
// --tools/--exclude-tools rules to this loadout, including after restores.
454451
let gatewayTools: string[] = [];
455452
if (ctx.model?.api === "openai-completions") {
456-
gatewayTools = catalog.map((tool) => "mcp__t3-code__" + tool.name);
457-
if (!eagerGatewayTools) {
458-
const called = calledToolNames(ctx);
459-
gatewayTools = gatewayTools.filter((name) => called.has(name));
460-
}
453+
const called = !eagerGatewayTools && hasToolSearch ? calledToolNames(ctx) : undefined;
454+
gatewayTools = catalog
455+
.filter((tool) => called === undefined || directTools.has(tool.name) || called.has("mcp__t3-code__" + tool.name))
456+
.map((tool) => "mcp__t3-code__" + tool.name);
461457
}
462458
const required = [...(hasToolSearch ? ["tool_search"] : []), ...gatewayTools];
463459
const missing = required.filter((name) => !active.includes(name));

‎packages/provider-pi/src/server/mcpInjection.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ describe("pi T3 MCP injection", () => {
6767
[T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale",
6868
[T3_MCP_BEARER_ENV]: "stale-token",
6969
[T3_PI_MCP_EXTENSION_PATH_ENV]: "/stale/extension.ts",
70+
[T3_PI_GATEWAY_TOOLS_ENV]: "called",
7071
},
7172
mcpSession: undefined,
7273
extensionPath: "/tmp/cache/pi-t3-mcp-extension.ts",
7374
runtimeMode: "auto-accept-edits",
75+
eagerCompletionGatewayTools: false,
7476
});
7577
assert.deepEqual(permissionOnly.args, [
7678
"--mode",
@@ -81,6 +83,7 @@ describe("pi T3 MCP injection", () => {
8183
assert.isFalse(permissionOnly.hasT3Mcp);
8284
assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]);
8385
assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]);
86+
assert.isUndefined(permissionOnly.env[T3_PI_GATEWAY_TOOLS_ENV]);
8487
assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits");
8588
assert.equal(
8689
permissionOnly.env[T3_PI_MCP_EXTENSION_PATH_ENV],

‎packages/provider-pi/src/settings.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const PiSettings = makeProviderSettingsSchema(
3737
Schema.annotateKey({
3838
title: "Load every T3 tool on completion gateways",
3939
description:
40-
"Declare all T3 tool schemas on OpenAI-compatible chat completion requests, for gateways that drop calls to undeclared tools. Turn off if your gateway runs tools found through tool_search. Requests then declare the orchestration tools, tool_search and the T3 tools tool_search loaded or the conversation already called. Existing threads keep tools they already loaded, including eagerly loaded ones, so the reduction applies to new threads and tools not yet loaded.",
40+
"Send all T3 tool schemas to OpenAI-compatible completion gateways. Turn off if your gateway supports tools loaded through tool_search. Smaller requests require Pi 0.99 or newer and built-in tool_search. Start a new thread to avoid keeping an earlier eager loadout.",
4141
providerSettingsForm: { control: "switch" },
4242
}),
4343
),

0 commit comments

Comments
 (0)