Skip to content
Merged
8 changes: 8 additions & 0 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
ToolCallConfirmationDetails,
} from '../../tools/tools.js';
import { getInitialChatHistory } from '../../utils/environmentContext.js';
import { FinishReason } from '@google/genai';
import type {
Content,
Part,
Expand Down Expand Up @@ -533,6 +534,7 @@ export class AgentCore {
let lastUsage: GenerateContentResponseUsageMetadata | undefined =
undefined;
let currentResponseId: string | undefined = undefined;
let wasOutputTruncated = false;

for await (const streamEvent of responseStream) {
if (roundAbortController.signal.aborted) {
Expand All @@ -557,6 +559,9 @@ export class AgentCore {
currentResponseId = resp.responseId;
}
if (resp.functionCalls) functionCalls.push(...resp.functionCalls);
if (resp.candidates?.[0]?.finishReason === FinishReason.MAX_TOKENS) {
wasOutputTruncated = true;
}
const content = resp.candidates?.[0]?.content;
const parts = content?.parts || [];
for (const p of parts) {
Expand Down Expand Up @@ -610,6 +615,7 @@ export class AgentCore {
turnCounter,
toolsList,
currentResponseId,
wasOutputTruncated,
);

// ── P0: Doom loop detection ───────────────────────────
Expand Down Expand Up @@ -820,6 +826,7 @@ export class AgentCore {
currentRound: number,
toolsList: FunctionDeclaration[],
responseId?: string,
wasOutputTruncated = false,
): Promise<Content[]> {
const toolResponseParts: Part[] = [];

Expand Down Expand Up @@ -1082,6 +1089,7 @@ export class AgentCore {
isClientInitiated: true,
prompt_id: promptId,
response_id: responseId,
wasOutputTruncated,
};

const description = this.getToolDescription(toolName, args);
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/agents/runtime/agent-headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import type {
ToolConfig,
} from './agent-types.js';
import { AgentTerminateMode } from './agent-types.js';
import { WriteFileTool } from '../../tools/write-file.js';

vi.mock('../../core/geminiChat.js');
vi.mock('../../core/contentGenerator.js', async (importOriginal) => {
Expand Down Expand Up @@ -1192,6 +1193,100 @@ describe('subagent.ts', () => {
expect(readResult).toBeDefined();
expect(readResult!.success).toBe(true);
});

it('should mark truncated subagent write_file calls as output-truncated errors', async () => {
const writeFileToolDef: FunctionDeclaration = {
name: WriteFileTool.Name,
description: 'Writes a file',
parameters: { type: Type.OBJECT, properties: {} },
};

const { config } = await createMockConfig({
getFunctionDeclarationsFiltered: vi
.fn()
.mockReturnValue([writeFileToolDef]),
getTool: vi.fn().mockImplementation((name: string) => {
if (name === WriteFileTool.Name) {
return new WriteFileTool(config);
}
return undefined;
}),
});

const toolConfig: ToolConfig = { tools: [WriteFileTool.Name] };
const toolResultEvents: AgentToolResultEvent[] = [];
const eventEmitter = new AgentEventEmitter();
eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => {
toolResultEvents.push(event as AgentToolResultEvent);
});

mockSendMessageStream.mockImplementation(async () =>
(async function* () {
yield {
type: 'chunk',
value: {
functionCalls: [
{
id: 'call_write',
name: WriteFileTool.Name,
args: { file_path: '/tmp/truncated.txt' },
},
],
},
};
yield {
type: 'chunk',
value: {
candidates: [
{
finishReason: 'MAX_TOKENS',
content: { parts: [] },
},
],
},
};
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [{ text: 'done' }],
},
},
],
},
};
})(),
);

const scope = await AgentHeadless.create(
'test-agent',
config,
promptConfig,
defaultModelConfig,
defaultRunConfig,
toolConfig,
eventEmitter,
);

await scope.execute(new ContextState());

const writeResult = toolResultEvents.find(
(event) => event.name === WriteFileTool.Name,
);
expect(writeResult).toBeDefined();
expect(writeResult!.success).toBe(false);
expect(writeResult!.error).toContain(
'truncated due to max_tokens limit',
);
expect(writeResult!.error).toContain(
'rejected to prevent writing truncated content',
);
expect(writeResult!.error).not.toContain(
"params must have required property 'content'",
);
});
});
});
});
6 changes: 6 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,12 @@ export class Config {
return;
}

// Strip thinking blocks from conversation history on model switch.
// reasoning_content is a non-standard field that causes strict
// OpenAI-compatible providers to reject requests with 422 errors
// when thought parts from a previous model leak into the payload (#3304).
this.geminiClient.stripThoughtsFromHistory();

// Full refresh path
await this.refreshAuth(authType);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,10 @@ export class GeminiClient {
this.config.getChatRecordingService()?.recordUserMessage(request);

// strip thoughts from history before sending the message
// NOTE: backport of upstream #3590 changed sessionService default to
// KEEP thoughts (preserves reasoning_content for DeepSeek/reasoning
// models on resume). The mid-stream stripThoughtsFromHistory() here
// remains for active turns to avoid stale thoughts polluting cache.
this.stripThoughtsFromHistory();

// Capture history length for rewind support.
Expand Down
57 changes: 56 additions & 1 deletion packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Mock } from 'vitest';
import type {
AnyDeclarativeTool,
Config,
ToolCallConfirmationDetails,
ToolConfirmationPayload,
Expand Down Expand Up @@ -42,6 +43,7 @@ import { MessageBusType } from '../confirmation-bus/types.js';
import type { HookExecutionResponse } from '../confirmation-bus/types.js';
import { type NotificationType } from '../hooks/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { WriteFileTool } from '../tools/write-file.js';

vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
Expand Down Expand Up @@ -1801,7 +1803,7 @@ describe('CoreToolScheduler request queueing', () => {

describe('CoreToolScheduler truncated output protection', () => {
function createTruncationTestScheduler(
tool: TestApprovalTool | MockTool,
tool: AnyDeclarativeTool,
toolNames: string[],
) {
const onAllToolCallsComplete = vi.fn();
Expand Down Expand Up @@ -1969,6 +1971,59 @@ describe('CoreToolScheduler truncated output protection', () => {
// Non-Edit tools should still execute even when output was truncated
expect(completedCalls[0].status).toBe('success');
});

it('should prefer truncation rejection over validation errors for truncated write_file calls', async () => {
const writeFileConfig = {
getProjectRoot: () => '/tmp',
getTargetDir: () => '/tmp',
getFileSystemService: () => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getDefaultFileEncoding: () => undefined,
setApprovalMode: vi.fn(),
} as unknown as Config;
const writeFileTool = new WriteFileTool(writeFileConfig);
const { scheduler, onAllToolCallsComplete } = createTruncationTestScheduler(
writeFileTool,
[WriteFileTool.Name],
);

await scheduler.schedule(
[
{
callId: '1',
name: WriteFileTool.Name,
args: { file_path: '/tmp/test.txt' },
isClientInitiated: false,
prompt_id: 'prompt-id-write-file-truncated',
wasOutputTruncated: true,
},
],
new AbortController().signal,
);

await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalled();
});

const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls).toHaveLength(1);
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('error');

if (completedCall.status === 'error') {
const errorMessage = completedCall.response.error?.message;
expect(errorMessage).toContain('truncated due to max_tokens limit');
expect(errorMessage).toContain(
'rejected to prevent writing truncated content',
);
expect(errorMessage).not.toContain(
"params must have required property 'content'",
);
}
});
});

describe('CoreToolScheduler Sequential Execution', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,24 @@ export class CoreToolScheduler {
continue;
}

// Reject file-modifying calls when truncated to prevent
// writing incomplete content, even if params failed schema validation.
if (reqInfo.wasOutputTruncated && toolInstance.kind === Kind.Edit) {
const truncationError = new Error(TRUNCATION_EDIT_REJECTION);
newToolCalls.push({
status: 'error',
request: reqInfo,
tool: toolInstance,
response: createErrorResponse(
reqInfo,
truncationError,
ToolErrorType.OUTPUT_TRUNCATED,
),
durationMs: 0,
});
continue;
}

const invocationOrError = this.buildInvocation(
toolInstance,
reqInfo.args,
Expand Down
Loading
Loading