fix(prompting): refresh bash guidance on tool changes

Signed-off-by: Christian Stewart <christian@aperture.us>
This commit is contained in:
Christian Stewart
2026-07-05 18:15:22 -07:00
parent 96850a1642
commit 1936d4f250
6 changed files with 107 additions and 15 deletions
+11
View File
@@ -1525,10 +1525,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// entries capture it at fetch time and are dropped at injection if a newer
// mutation (any tool) bumped it in the meantime.
const fileMutationVersions = new Map<string, number>();
const activeToolNames = new Set<string>();
const setActiveToolNames = (names: Iterable<string>): void => {
activeToolNames.clear();
for (const name of names) {
activeToolNames.add(name);
}
};
const toolSession: ToolSession = {
get cwd() {
return sessionManager.getCwd();
},
isToolActive: name => activeToolNames.has(name),
setActiveToolNames,
hasUI: options.hasUI ?? false,
enableLsp,
get hasEditTool() {
@@ -2540,6 +2549,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
hasRegistered = true;
setActiveToolNames(initialToolNames);
const { systemPrompt } = await logger.time(
"buildSystemPrompt",
rebuildSystemPrompt,
@@ -2834,6 +2844,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
rebuildSystemPrompt,
reloadSshTool,
requestedToolNames: requestedToolNameSet,
setActiveToolNames,
getMcpServerInstructions: mcpManager
? () => {
const raw = mcpManager.getServerInstructions();
@@ -686,6 +686,8 @@ export interface AgentSessionConfig {
toolRegistry?: Map<string, AgentTool>;
/** Tool names whose current registry entry is still the built-in implementation. */
builtInToolNames?: Iterable<string>;
/** Update tool-session predicates that render guidance from the live active tool set. */
setActiveToolNames?: (names: Iterable<string>) => void;
/** Current session pre-LLM message transform pipeline */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
/**
@@ -1717,6 +1719,7 @@ export class AgentSession {
| undefined;
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
#setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
#disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
#requestedToolNames: ReadonlySet<string> | undefined;
#baseSystemPrompt: string[];
@@ -2155,6 +2158,7 @@ export class AgentSession {
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
this.#getMcpServerInstructions = config.getMcpServerInstructions;
this.#reloadSshTool = config.reloadSshTool;
this.#setActiveToolNames = config.setActiveToolNames;
this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
this.#baseSystemPrompt = this.agent.state.systemPrompt;
this.#promptModelKey = this.#currentPromptModelKey();
@@ -6290,6 +6294,7 @@ export class AgentSession {
),
);
}
this.#setActiveToolNames?.(validToolNames);
const activeNameSet = new Set(validToolNames);
for (const name of Array.from(this.#selectedDiscoveredToolNames)) {
if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) {
@@ -6395,6 +6400,7 @@ export class AgentSession {
async refreshBaseSystemPrompt(): Promise<void> {
if (!this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
this.#setActiveToolNames?.(activeToolNames);
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
this.#baseSystemPrompt = built.systemPrompt;
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
+15 -14
View File
@@ -376,7 +376,21 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
};
readonly label = "Bash";
readonly loadMode = "essential";
readonly description: string;
get description(): string {
const evalBackends = resolveEvalBackends(this.session);
const isToolActive = (name: string, fallback: boolean): boolean => this.session.isToolActive?.(name) ?? fallback;
return prompt.render(bashDescription, {
asyncEnabled: this.#asyncEnabled,
autoBackgroundEnabled: this.#autoBackgroundEnabled,
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")),
hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")),
hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")),
hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")),
hasRead: isToolActive("read", true),
hasEval: isToolActive("eval", evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia),
});
}
readonly parameters: BashToolSchema;
// Non-pty calls run alongside each other (the executor isolates overlapping
// runs on the same shell session); pty takes over the terminal UI and must
@@ -398,19 +412,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
),
);
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
const evalBackends = resolveEvalBackends(this.session);
const isToolActive = (name: string, fallback: boolean): boolean => this.session.isToolActive?.(name) ?? fallback;
this.description = prompt.render(bashDescription, {
asyncEnabled: this.#asyncEnabled,
autoBackgroundEnabled: this.#autoBackgroundEnabled,
autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")),
hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")),
hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")),
hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")),
hasRead: isToolActive("read", true),
hasEval: isToolActive("eval", evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia),
});
}
#formatResultOutput(result: BashResult | BashInteractiveResult): string {
+7 -1
View File
@@ -226,6 +226,8 @@ export interface ToolSession {
getToolByName?: (name: string) => AgentTool | undefined;
/** Return whether a built-in tool is active in this turn's tool set. */
isToolActive?: (name: string) => boolean;
/** Update the active built-in tool predicate when a session changes tools mid-run. */
setActiveToolNames?: (names: Iterable<string>) => void;
/** Agent registry for IRC routing across live sessions. */
agentRegistry?: AgentRegistry;
/** Get artifacts directory for artifact:// URLs */
@@ -650,7 +652,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
];
const activeToolNames = new Set(baseEntries.map(([name]) => name));
session.isToolActive = name => activeToolNames.has(name);
if (session.setActiveToolNames) {
session.setActiveToolNames(activeToolNames);
} else {
session.isToolActive = name => activeToolNames.has(name);
}
const baseResults = await Promise.all(
baseEntries.map(async ([name, factory]) => {
@@ -176,6 +176,52 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
expect(rebuildCount).toBe(baseline + 2);
});
it("updates live active-tool predicates before rebuilding the prompt", async () => {
const activeToolNames = new Set(["read", "bash", "grep"]);
const readTool = createBasicTool("read", "Read");
const bashTool = createBasicTool("bash", "Bash");
const grepTool = createBasicTool("grep", "Grep");
Object.defineProperty(bashTool, "description", {
get: () => (activeToolNames.has("grep") ? "bash sees grep" : "bash hides grep"),
enumerable: true,
configurable: true,
});
const toolRegistry = new Map<string, AgentTool>([
[readTool.name, readTool],
[bashTool.name, bashTool],
[grepTool.name, grepTool],
]);
const agent = new Agent({
initialState: {
model: createModel(),
systemPrompt: ["initial"],
tools: [readTool, bashTool, grepTool],
messages: [],
},
});
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry: {} as never,
toolRegistry,
setActiveToolNames: names => {
activeToolNames.clear();
for (const name of names) {
activeToolNames.add(name);
}
},
rebuildSystemPrompt: async (_toolNames, tools) => ({
systemPrompt: [tools.get("bash")?.description ?? "missing bash"],
}),
});
sessions.push(session);
await session.setActiveToolsByName(["read", "bash"]);
expect(agent.state.systemPrompt).toEqual(["bash hides grep"]);
});
it("does not skip when refreshBaseSystemPrompt is called explicitly", async () => {
let rebuildCount = 0;
const { session } = newSession(async toolNames => {
@@ -273,6 +273,28 @@ describe("createTools", () => {
expect(session.isToolActive?.("read")).toBe(false);
});
it("renders bash guidance from the live active tool predicate", async () => {
const activeToolNames = new Set<string>();
const session = createTestSession({
isToolActive: name => activeToolNames.has(name),
setActiveToolNames: names => {
activeToolNames.clear();
for (const name of names) {
activeToolNames.add(name);
}
},
});
const tools = await createTools(session, ["bash", "grep", "read", "glob"]);
const bash = tools.find(tool => tool.name === "bash");
expect(bash?.description).toContain("`grep` tool");
session.setActiveToolNames?.(["bash"]);
expect(bash?.description).not.toContain("`grep` tool");
expect(bash?.description).not.toContain("`ls` → `read`");
expect(bash?.description).not.toContain("`find` → the `glob` tool");
});
it("includes search_tool_bm25 when MCP tool discovery is enabled and executable", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({