fix(core): retain deferred Agent guidance

This commit is contained in:
yiliang114 2026-09-30 17:09:46 +08:00
parent dc84c2e615
commit 091672639f
5 changed files with 69 additions and 15 deletions

View file

@ -2892,6 +2892,7 @@ export class Config {
* built. See {@link getPromptToolSnapshot}.
*/
private promptToolSnapshot: ReadonlySet<string> | undefined;
private promptAgentReachable = false;
/**
* Volatile system-prompt layer: the managed auto-memory section
@ -8661,6 +8662,14 @@ export class Config {
this.promptToolSnapshot = names;
}
getPromptAgentReachable(): boolean {
return this.promptAgentReachable;
}
setPromptAgentReachable(reachable: boolean): void {
this.promptAgentReachable = reachable;
}
/**
* The managed auto-memory section of the system prompt (volatile layer).
* Empty when managed memory is unavailable. Callers assembling a system

View file

@ -652,6 +652,7 @@ describe('Gemini Client (client.ts)', () => {
isTodoWriteEnabled: vi.fn().mockReturnValue(false),
getStaticSystemPrefix: vi.fn().mockReturnValue(undefined),
setStaticSystemPrefix: vi.fn(),
setPromptAgentReachable: vi.fn(),
getFullContext: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
takeActiveTodoReminder: vi.fn().mockReturnValue(undefined),
@ -1816,6 +1817,7 @@ describe('Gemini Client (client.ts)', () => {
// can stub the deferred-summary + bridge availability per case.
function getRegistryMock() {
return vi.mocked(mockConfig.getToolRegistry)() as unknown as {
getFunctionDeclarations: ReturnType<typeof vi.fn>;
getDeferredToolSummary: ReturnType<typeof vi.fn>;
getTool: ReturnType<typeof vi.fn>;
isDeferredToolRevealed: ReturnType<typeof vi.fn>;
@ -1825,6 +1827,28 @@ describe('Gemini Client (client.ts)', () => {
};
}
it('records bridge-reachable Agent for prompt guidance', async () => {
const reg = getRegistryMock();
reg.getFunctionDeclarations.mockReturnValue([
{ name: ToolNames.TOOL_SEARCH },
{ name: ToolNames.TOOL_CALL },
]);
reg.getDeferredToolSummary.mockReturnValue([
{ name: ToolNames.AGENT, description: 'delegate work' },
]);
reg.getTool.mockImplementation((name: string) =>
name === ToolNames.TOOL_SEARCH || name === ToolNames.TOOL_CALL
? ({} as never)
: null,
);
const setReachable = vi.mocked(mockConfig.setPromptAgentReachable);
setReachable.mockClear();
await client.startChat();
expect(setReachable).toHaveBeenLastCalledWith(true);
});
it('re-reveals deferred tools that appear in resumed history', async () => {
// Resume contract: a transcript referencing `cron_create` (a
// deferred tool) must re-reveal it on startChat so the API

View file

@ -395,7 +395,10 @@ type MainSessionPromptConfig = Pick<
Partial<
Pick<
Config,
'isTrustedFolder' | 'getPromptToolSnapshot' | 'getShellExecutionSandbox'
| 'isTrustedFolder'
| 'getPromptToolSnapshot'
| 'getPromptAgentReachable'
| 'getShellExecutionSandbox'
>
>;
@ -419,6 +422,7 @@ export function getMainSessionBaseSystemPrompt(
config.getCodeModeOnly(),
{
declaredTools: config.getPromptToolSnapshot?.(),
agentReachable: config.getPromptAgentReachable?.(),
executionSandboxFilesystem:
config.getShellExecutionSandbox?.()?.filesystem,
executionSandboxBackend:
@ -2695,13 +2699,18 @@ export class LlmClient {
// Optional call: partial Config stubs (tests, derived agent shims) do not
// carry the setter, and a missing snapshot simply leaves the prompt
// ungated rather than failing session startup.
this.config.setPromptToolSnapshot?.(
new Set(
toolRegistry
.getFunctionDeclarations()
.map((declaration) => declaration.name)
.filter((name): name is string => Boolean(name)),
),
const declaredTools = new Set(
toolRegistry
.getFunctionDeclarations()
.map((declaration) => declaration.name)
.filter((name): name is string => Boolean(name)),
);
this.config.setPromptToolSnapshot?.(declaredTools);
this.config.setPromptAgentReachable?.(
declaredTools.has(ToolNames.AGENT) ||
(declaredTools.has(ToolNames.TOOL_SEARCH) &&
declaredTools.has(ToolNames.TOOL_CALL) &&
deferredSummary.some(({ name }) => name === ToolNames.AGENT)),
);
const deferredTools = profiler.timeSync('deferred_reminder_setup', () => {
const resolved = this.resolveDeferredToolsForReminder(deferredSummary);

View file

@ -1736,7 +1736,7 @@ describe('resident tool gating (#12032)', () => {
expect(codeModePrompt(new Set([ToolNames.EXEC]))).toBe(codeModePrompt());
});
it('takes the declared set from the Config snapshot', () => {
it('keeps Agent guidance when Agent is bridge-reachable', () => {
const base = {
getSystemPrompt: () => undefined,
getModel: () => 'gpt-4',
@ -1748,15 +1748,18 @@ describe('resident tool gating (#12032)', () => {
isTodoWriteEnabled: () => false,
};
// The snapshot is the single source `/context` and the request share, so
// the prompt must actually read it rather than recompute from a registry.
// `/context` and the request share these session snapshots, so the prompt
// must read them rather than recompute from a live registry.
const gated = getMainSessionBaseSystemPrompt({
...base,
getPromptToolSnapshot: () => FILE_WORK_TOOLS,
getPromptToolSnapshot: () =>
new Set([...FILE_WORK_TOOLS, ToolNames.TOOL_CALL]),
getPromptAgentReachable: () => true,
});
const ungated = getMainSessionBaseSystemPrompt(base);
expect(gated).not.toContain('- **Subagent Delegation:**');
expect(gated).toContain('- **Subagent Delegation:**');
expect(gated).toContain('- **Codebase Search:**');
expect(ungated).toContain('- **Subagent Delegation:**');
expect(gated.length).toBeLessThan(ungated.length);
});

View file

@ -270,6 +270,7 @@ export function getCustomSystemPrompt(
*/
export interface PromptToolSurface {
declaredTools?: ReadonlySet<string>;
agentReachable?: boolean;
executionSandboxFilesystem?: 'read-only' | 'workspace-write';
executionSandboxBackend?: 'bwrap' | 'landlock';
}
@ -311,7 +312,8 @@ const TOOL_GUIDANCE_LINE_GATES: ReadonlyArray<{
const PREFER_DEDICATED_TOOLS_PREFIX = '- **Prefer Dedicated Tools:**';
/**
* Drops the tool-guidance lines whose tools this session did not declare.
* Drops tool-guidance lines whose tools the session cannot call. Agent policy
* also survives when Agent is reachable through the deferred-tool bridge.
*
* Implemented as a line filter rather than a rebuilt template on purpose: with
* no snapshot the section returns unchanged, so the default prompt cannot drift
@ -328,7 +330,14 @@ function gateToolGuidance(
const gate = TOOL_GUIDANCE_LINE_GATES.find((entry) =>
line.startsWith(entry.prefix),
);
return !gate || gate.tools.every((tool) => declared.has(tool));
return (
!gate ||
gate.tools.every(
(tool) =>
declared.has(tool) ||
(tool === ToolNames.AGENT && surface?.agentReachable === true),
)
);
});
// The "prefer dedicated tools" bullet only introduces its sub-bullets, so it
// goes when every tool it was going to recommend is gone.