Skip to content
1 change: 1 addition & 0 deletions packages/core/src/followup/speculation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ ${SUGGESTION_PROMPT}`;
jsonSchema: PIPELINED_SCHEMA,
...(model !== undefined ? { model } : {}),
abortSignal,
preserveTools: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2ab6f80 — new test 'passes preserveTools: true to runForkedAgent' in speculation.test.ts. The test runs a text-only speculation to completion (triggering generatePipelinedSuggestion), then asserts runForkedAgent was called with preserveTools: true. All 9 tests in the file pass.

});

if (abortSignal.aborted) return null;
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/followup/suggestionGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,34 @@ describe('generatePromptSuggestion', () => {
expect.objectContaining({ model: 'openai:fast-model' }),
);
});
it('passes preserveTools: true for Anthropic prompt-cache sharing', async () => {
mockGetCacheSafeParams.mockReturnValue({
generationConfig: {},
history: conversationHistory,
model: 'main-model',
version: 1,
});
mockRunForkedAgent.mockResolvedValue({
text: null,
jsonResult: { suggestion: 'run tests' },
usage: { inputTokens: 10, outputTokens: 3, cacheHitTokens: 5 },
});
const config = {
getFastModel: vi.fn(() => undefined),
getModel: vi.fn(() => 'main-model'),
} as unknown as Config;

await generatePromptSuggestion(
config,
conversationHistory,
new AbortController().signal,
{ enableCacheSharing: true },
);

expect(mockRunForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ preserveTools: true }),
);
});
});

describe('shouldFilterSuggestion', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/followup/suggestionGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async function generateViaForkedQuery(
cacheSafeParams,
jsonSchema: SUGGESTION_SCHEMA,
model,
preserveTools: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 8d53a0e:

  • suggestionGenerator.ts: changed to preserveTools: model === cacheSafeParams.model
  • speculation.ts: introduced resolvedModel = model ?? cacheSafeParams.model, then preserveTools: resolvedModel === cacheSafeParams.model
  • Added tests in both suggestionGenerator.test.ts and speculation.test.ts verifying preserveTools: false when getFastModel() returns a model different from cacheSafeParams.model

All 49 tests pass.

});

if (result.jsonResult) {
Expand Down
182 changes: 182 additions & 0 deletions packages/core/src/utils/forkedAgent.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,188 @@ describe('runForkedAgent (cache path)', () => {
expect(createRuntimeContentGeneratorView).not.toHaveBeenCalled();
});

it('does not strip tools when preserveTools is true', async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2ab6f80 — new test 'filters out functionCall parts when preserveTools is true' in forkedAgent.cache.test.ts. The test mocks a stream chunk containing both a text part and a functionCall part, calls runForkedAgent with preserveTools: true, and asserts only the text content is returned. All 17 tests in the file pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8d53a0e — new test preserves tools and includes jsonSchema fields when both preserveTools and jsonSchema are set in forkedAgent.cache.test.ts.

The test:

  1. Saves cacheSafeParams with tools
  2. Calls runForkedAgent with both preserveTools: true and a jsonSchema
  3. Captures the params passed to sendMessageStream
  4. Asserts config.tools is undefined (tools not stripped — preserveTools working)
  5. Asserts config.responseMimeType === "application/json"
  6. Asserts config.responseJsonSchema matches the passed schema

All 49 tests pass (18 forkedAgent.cache + 21 suggestionGenerator + 10 speculation).

saveCacheSafeParams(
{
systemInstruction: 'You are helpful',
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
{ name: 'shell', description: 'Run a command' },
],
},
],
},
[{ role: 'user', parts: [{ text: 'hello' }] }],
'test-model',
);

let capturedParams: unknown = null;

const mockSendMessageStream = vi.fn(
(_model: string, params: unknown, _promptId: string) => {
capturedParams = params;
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ text: '{"suggestion":"run tests"}' }],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
},
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: true,
});

const sendParams = capturedParams as {
config?: { tools?: unknown };
};
expect(sendParams.config!.tools).toBeUndefined();
});

it('strips tools by default when preserveTools is omitted', async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7405fed — removed this test. The first test at line 199 already covers the default preserveTools omission path more thoroughly.

saveCacheSafeParams(
{
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
],
},
],
},
[],
'test-model',
);

let capturedParams: unknown = null;

const mockSendMessageStream = vi.fn(
(_model: string, params: unknown, _promptId: string) => {
capturedParams = params;
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'ok' }],
},
},
],
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
});

const sendParams = capturedParams as {
config?: { tools?: unknown };
};
expect(sendParams.config!.tools).toEqual([]);
});

it('strips tools when preserveTools is explicitly false', async () => {
saveCacheSafeParams(
{
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
],
},
],
},
[],
'test-model',
);

let capturedParams: unknown = null;

const mockSendMessageStream = vi.fn(
(_model: string, params: unknown, _promptId: string) => {
capturedParams = params;
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'ok' }],
},
},
],
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: false,
});

const sendParams = capturedParams as {
config?: { tools?: unknown };
};
expect(sendParams.config!.tools).toEqual([]);
});

it('throws when CacheSafeParams are not available', async () => {
const mockConfig = {} as unknown as Config;

Expand Down
67 changes: 55 additions & 12 deletions packages/core/src/utils/forkedAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@
*
* The two execution paths are selected by whether cacheSafeParams is supplied:
*
* WITH cacheSafeParams → GeminiChat single-turn, NO tools, shares parent
* prompt cache (systemInstruction + history).
* WITH cacheSafeParams → GeminiChat single-turn, shares parent prompt
* cache (systemInstruction + history). Tools are
* stripped by default (NO_TOOLS) to prevent
* function calls; pass preserveTools: true to
* keep the parent's tools prefix for Anthropic
* prompt-cache hits.
* Use for: /btw, suggestions, pipelined suggestions.
*
* WITHOUT cacheSafeParams → AgentHeadless multi-turn, full tool access,
* isolated session (no shared history).
* Use for: memory extract, dream consolidation.
*
* Tool-deny for forked queries is enforced at the per-request level (NO_TOOLS).
* Tool-deny for forked queries is enforced at the per-request level (NO_TOOLS)
* unless the caller opts out via preserveTools to share the cache prefix.
*
* Callers (extractScheduler, dreamScheduler) own concurrency control.
* runSideQuery() remains a separate primitive for structured-JSON calls that
Expand All @@ -37,6 +42,7 @@ import { ApprovalMode, type Config } from '../config/config.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { createRuntimeContentGeneratorView } from '../models/content-generator-config.js';
import { createApprovalModeOverride } from '../tools/agent/agent.js';
import { createDebugLogger } from './debugLogger.js';
import {
AgentHeadless,
AgentEventEmitter,
Expand All @@ -57,6 +63,8 @@ import {
import { ToolNames } from '../tools/tool-names.js';
import { runWithChatRecordingSuppressed } from './chat-recording-suppression-context.js';

const debugLogger = createDebugLogger('FORKED_AGENT');

// ---------------------------------------------------------------------------
// CacheSafeParams — shared prompt-cache slot
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -145,7 +153,11 @@ export function clearCacheSafeParams(): void {
// Forked chat — shared by runForkedAgent (cache path) and speculation
// ---------------------------------------------------------------------------

/** Per-request config that strips tools so the model never produces function calls. */
/**
* Per-request config that strips tools so the model never produces function
* calls. Applied by default in the cache path; skipped when preserveTools
* is true (to share the Anthropic prompt-cache prefix).
*/
const NO_TOOLS = Object.freeze({ tools: [] as const }) as Pick<
GenerateContentConfig,
'tools'
Expand Down Expand Up @@ -267,7 +279,8 @@ export async function runWithForkedChatModel<T>(

/**
* Result from a cache-path runForkedAgent (with cacheSafeParams).
* Single-turn, text-only — tools are denied.
* Single-turn, text-only. Tools stripped by default; pass preserveTools
* to keep the parent's tools for cache-prefix matching.
*/
export interface ForkedQueryResult {
/** Extracted text response, or null if no text */
Expand Down Expand Up @@ -305,7 +318,7 @@ function extractQueryUsage(
*/
export type ForkedAgentParams = CachePathParams | AgentPathParams;

/** Cache path: single-turn, tool-free, shares parent prompt cache. */
/** Cache path: single-turn, shares parent prompt cache. */
export interface CachePathParams {
/** Runtime config. */
config: Config;
Expand All @@ -319,6 +332,13 @@ export interface CachePathParams {
model?: string;
/** External cancellation signal. */
abortSignal?: AbortSignal;
/**
* When true, keep the parent's tools in the per-request config so the
* Anthropic prompt-cache key (system + tools) matches the main agent's.
* Default (false/omitted): strip tools via NO_TOOLS to prevent function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa04bea! Updated the JSDoc to:

Single-turn, text-only. Tools stripped by default; pass preserveTools to keep the parent's tools for cache-prefix matching.

Thanks for catching the inconsistency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa04bea! Updated the JSDoc to:

Single-turn, text-only. Tools stripped by default; pass preserveTools to keep the parent's tools for cache-prefix matching.

Thanks for catching the inconsistency.

* calls — appropriate for most forked queries.
*/
preserveTools?: boolean;
}

/** AgentHeadless path: multi-turn, full tool access, isolated session. */
Expand Down Expand Up @@ -418,7 +438,8 @@ function isMutatingFileTool(toolName: string): boolean {
* Two overloads selected by the shape of `params`:
*
* params.cacheSafeParams present → cache path (ForkedQueryResult)
* Single-turn, NO tools, shares parent prompt cache.
* Single-turn, tools stripped by default (preserveTools overrides),
* shares parent prompt cache.
* Use for: /btw, suggestions, pipelined suggestions.
*
* params.taskPrompt present → agent path (ForkedAgentResult)
Expand All @@ -436,8 +457,14 @@ export async function runForkedAgent(
): Promise<ForkedQueryResult | ForkedAgentResult> {
// ── Cache path ────────────────────────────────────────────────────────────
if ('cacheSafeParams' in params) {
const { config, userMessage, cacheSafeParams, jsonSchema, abortSignal } =
params;
const {
config,
userMessage,
cacheSafeParams,
jsonSchema,
abortSignal,
preserveTools,
} = params;
const modelSelector = params.model ?? cacheSafeParams.model;
const modelRuntime = await buildForkedModelRuntime(
config,
Expand All @@ -448,7 +475,9 @@ export async function runForkedAgent(
return runWithForkedModelRuntime(modelRuntime, async (model) => {
const chat = createForkedChat(config, cacheSafeParams);

const requestConfig: GenerateContentConfig = { ...NO_TOOLS };
const requestConfig: GenerateContentConfig = preserveTools
? {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Added in fa04bea:

  • Defensive guard in the stream loop: detects functionCall parts when preserveTools is true, logs a warning via debugLogger.warn(), and filters them out before text extraction.
  • Also filters functionCall parts unconditionally in the .filter() chain so they never contribute empty strings to the text output.
  • Added createDebugLogger('FORKED_AGENT') for the warning.

All 57 tests pass (cache: 16, agent: 13, suggestion: 20, speculation: 8).

: { ...NO_TOOLS };
if (abortSignal) requestConfig.abortSignal = abortSignal;
if (jsonSchema) {
requestConfig.responseMimeType = 'application/json';
Expand All @@ -471,8 +500,22 @@ export async function runForkedAgent(
for await (const event of stream) {
if (event.type !== StreamEventType.CHUNK) continue;
const response = event.value;
const text = response.candidates?.[0]?.content?.parts
?.filter((p) => !(p as Record<string, unknown>)['thought'])
const parts = response.candidates?.[0]?.content?.parts ?? [];

// Defensive: when preserveTools is true the model could produce
// functionCall parts instead of text. Log and discard them.
if (
preserveTools &&
parts.some((p) => (p as Record<string, unknown>).functionCall)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2ab6f80 — changed both .functionCall occurrences to bracket notation ['functionCall'], matching the existing ['thought'] pattern. No new TS errors in forkedAgent.ts (confirmed via tsc --noEmit).

) {
debugLogger.warn(
'Cache-path forked query received functionCall with preserveTools; discarding.',
);
}

const text = parts
.filter((p) => !(p as Record<string, unknown>)['thought'])
.filter((p) => !(p as Record<string, unknown>).functionCall)
.map((p) => p.text ?? '')
.join('');
if (text) fullText += text;
Expand Down
Loading