fix(coding-agent): restore active tools on session resume (#10887)

The SDK always passed initial tools, so the constructor never restored the
loadout recorded in the transcript, and resumed sessions dropped tools activated
during the session. Sessions that use the default tools now restore the recorded
loadout, with --tools +name/-name entries applied to it.

fixes #10785
This commit is contained in:
David Brailovsky 2026-10-13 15:55:12 +02:00 • committed by GitHub
parent 34a8bb38b2
commit 6f2f164ab7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 5 deletions

View file

@ -5,6 +5,7 @@
### Fixed
- Pinned internal Pi dependencies to exact release versions to prevent fresh installs from mixing incompatible packages ([#10865](https://github.com/earendil-works/pi/issues/10865))
- Fixed resumed sessions dropping tools activated during the session. Resume now restores the session's active tools, with `--tools +name`/`-name` entries applied to them, unless `--tools` with tool names, `--no-tools`, or `--no-builtin-tools` selects tools explicitly ([#10785](https://github.com/earendil-works/pi/issues/10785))
## [1.2.0] - 2026-10-12

View file

@ -267,8 +267,9 @@ export interface AgentSessionConfig {
/** Initial active built-in tool names. Default: [read, bash, edit, write] */
initialActiveToolNames?: string[];
/**
* Whether the initial tools come from the `defaultTools` setting. When true, reload activates
* tools newly added to the setting. Tools removed from it stay active.
* Whether the initial tools come from the `defaultTools` setting. When true, a resumed session
* replaces them with the loadout its transcript records, with `defaultToolModifiers` applied, and
* reload activates tools newly added to the setting. Tools removed from it stay active.
*/
usesDefaultTools?: boolean;
/**
@ -522,7 +523,7 @@ export class AgentSession {
activeToolNames: this._initialActiveToolNames,
includeAllExtensionTools: true,
});
if (this._initialActiveToolNames === undefined) this._restoreToolsFromTranscript();
if (this._usesDefaultTools) this._restoreToolsFromTranscript(this._defaultToolModifiers);
}
get modelRuntime(): ModelRuntime {
@ -1809,12 +1810,16 @@ export class AgentSession {
* Restore the active tool loadout declared by the session transcript, if it declares one.
* Tools reachable only from other tools are never declared, but they do not depend on the active
* set, so the transcript's declarations are the whole loadout.
* @param modifiers `+name`/`-name` entries applied to the restored loadout.
*/
private _restoreToolsFromTranscript(): void {
private _restoreToolsFromTranscript(modifiers: readonly string[] = []): void {
this._pendingToolNames.clear();
const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages);
if (!current) return;
const names = (current.toolsAdded ?? []).map((tool) => tool.name);
const names = applyToolModifiers(
(current.toolsAdded ?? []).map((tool) => tool.name),
modifiers,
);
this._pendingToolNames = new Set(names.filter((name) => this._isAllowedTool(name)));
this._setActiveTools(names);
}

View file

@ -289,6 +289,104 @@ describe("defaultTools setting", () => {
});
});
// #10785
describe("resume", () => {
const extensionTools: InlineExtension = (pi) => {
pi.registerTool({
name: "inactive_tool",
label: "Inactive Tool",
description: "Extension tool registered inactive",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
defaultActive: false,
});
pi.registerTool({
name: "active_tool",
label: "Active Tool",
description: "Extension tool registered active",
parameters: Type.Object({}),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
});
};
/** A session whose transcript records the loadout `toolNames`, as a run records it. */
function createRecordedSession(toolNames: string[]): SessionManager {
const sessionManager = SessionManager.inMemory(tempDir);
sessionManager.appendMessage({
role: "system",
content: "",
toolsAdded: toolNames.map((name) => ({ name, description: name, parameters: Type.Object({}) })),
timestamp: Date.now(),
});
return sessionManager;
}
async function resume(sessionManager: SessionManager, options: ToolOptions = {}) {
const settingsManager = SettingsManager.inMemory();
const resourceLoader = new DefaultResourceLoader({
cwd: tempDir,
agentDir,
settingsManager,
extensionFactories: [extensionTools],
});
await resourceLoader.reload();
return (
await createAgentSession({
cwd: tempDir,
agentDir,
model: getModel("anthropic", "claude-sonnet-4-5")!,
settingsManager,
sessionManager,
resourceLoader,
...options,
})
).session;
}
it("restores the loadout the transcript records", async () => {
// active_tool was deactivated during the session, inactive_tool activated.
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]));
expect(session.getActiveToolNames().sort()).toEqual(["grep", "inactive_tool", "read"]);
session.dispose();
});
it("does not restore excluded tools", async () => {
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), {
excludeTools: ["inactive_tool"],
});
expect(session.getActiveToolNames().sort()).toEqual(["grep", "read"]);
session.dispose();
});
it("uses explicit tool options instead of the recorded loadout", async () => {
const recorded = ["read", "grep", "inactive_tool"];
const allowlisted = await resume(createRecordedSession(recorded), { tools: ["read", "write"] });
expect(allowlisted.getActiveToolNames()).toEqual(["read", "write"]);
allowlisted.dispose();
const builtinless = await resume(createRecordedSession(recorded), { noTools: "builtin" });
expect(builtinless.getActiveToolNames()).toEqual(["active_tool"]);
builtinless.dispose();
});
it("applies +name and -name tool options to the recorded loadout", async () => {
const session = await resume(createRecordedSession(["read", "grep", "inactive_tool"]), {
tools: ["-grep", "+write"],
});
expect(session.getActiveToolNames().sort()).toEqual(["inactive_tool", "read", "write"]);
session.dispose();
});
it("uses the default tools when the transcript records no loadout", async () => {
const sessionManager = SessionManager.inMemory(tempDir);
sessionManager.appendMessage({ role: "user", content: "hi", timestamp: Date.now() });
const session = await resume(sessionManager);
expect(session.getActiveToolNames().sort()).toEqual(["active_tool", "bash", "edit", "read", "write"]);
session.dispose();
});
});
it("applies through service-based session creation", async () => {
const settingsManager = SettingsManager.inMemory({ defaultTools: ["ls"] });
const services = await createAgentSessionServices({ cwd: tempDir, agentDir, settingsManager });

View file

@ -238,6 +238,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
resourceLoader,
baseToolsOverride: toolMap,
initialActiveToolNames: options.initialActiveToolNames,
usesDefaultTools: options.initialActiveToolNames === undefined,
allowedToolNames: options.allowedToolNames,
excludedToolNames: options.excludedToolNames,
extensionRunnerRef,