From 1de5e625ee0d4699236dd87da72039954febc8d5 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 7 Jul 2026 13:38:12 +0800 Subject: [PATCH 1/2] fix: align agent-core-v2 llm protocol --- .../contextProjector/contextProjector.ts | 1 + .../contextProjectorService.ts | 59 +++ .../agent/llmRequester/llmRequesterService.ts | 105 ++-- .../src/agent/profile/configSection.ts | 1 + .../src/agent/profile/profileOps.ts | 2 +- .../src/agent/profile/profileService.ts | 44 +- .../src/app/llmProtocol/capability.ts | 9 + .../src/app/llmProtocol/catalog.ts | 3 + .../src/app/llmProtocol/errors.ts | 110 +++++ .../src/app/llmProtocol/generate.ts | 10 +- .../src/app/llmProtocol/message.ts | 30 ++ .../src/app/llmProtocol/messageHelpers.ts | 3 +- .../src/app/llmProtocol/provider.ts | 28 +- .../app/llmProtocol/providers/anthropic.ts | 154 ++++-- .../app/llmProtocol/providers/google-genai.ts | 148 +++--- .../src/app/llmProtocol/providers/kimi.ts | 34 +- .../providers/merge-user-messages.ts | 64 +++ .../llmProtocol/providers/openai-common.ts | 22 +- .../llmProtocol/providers/openai-legacy.ts | 5 + .../llmProtocol/providers/openai-responses.ts | 6 +- .../agent-core-v2/src/app/llmProtocol/tool.ts | 9 + .../agent-core-v2/src/app/model/modelImpl.ts | 9 + .../src/app/model/modelInstance.ts | 3 + .../src/app/model/modelResolverService.ts | 4 + .../projector-tool-exchanges.test.ts | 42 ++ .../anthropic-context-management.test.ts | 90 ++++ .../test/llmProtocol/errors.test.ts | 457 ++++++++++++++++++ .../test/llmProtocol/select-tools.test.ts | 353 ++++++++++++++ .../test/llmRequester/strict-resend.test.ts | 167 +++++++ .../test/model/modelResolver.test.ts | 14 + .../test/profile/profile-wire.test.ts | 106 +++- .../protocol/protocolAdapterRegistry.test.ts | 11 + 32 files changed, 1924 insertions(+), 179 deletions(-) create mode 100644 packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts create mode 100644 packages/agent-core-v2/test/llmProtocol/anthropic-context-management.test.ts create mode 100644 packages/agent-core-v2/test/llmProtocol/errors.test.ts create mode 100644 packages/agent-core-v2/test/llmProtocol/select-tools.test.ts create mode 100644 packages/agent-core-v2/test/llmRequester/strict-resend.test.ts diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts index 6e86d341a..53c7c2029 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -7,6 +7,7 @@ export interface IAgentContextProjectorService { readonly _serviceBrand: undefined; project(messages: readonly ContextMessage[]): readonly Message[]; + projectStrict(messages: readonly ContextMessage[]): readonly Message[]; } export const IAgentContextProjectorService = createDecorator( diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 21a0031f5..9fd73fdc8 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -17,6 +17,10 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi return project(this.microCompaction().compact(messages)); } + projectStrict(messages: readonly ContextMessage[]): readonly Message[] { + return projectStrict(this.microCompaction().compact(messages)); + } + private microCompaction(): IAgentMicroCompactionService { return this.instantiation.invokeFunction((accessor) => accessor.get(IAgentMicroCompactionService), @@ -24,6 +28,61 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi } } +function projectStrict(history: readonly ContextMessage[]): Message[] { + const projected = project(history); + return dropLeadingNonUserMessages(mergeConsecutiveAssistantMessages(dedupeDuplicateToolCalls(projected))); +} + +function dedupeDuplicateToolCalls(messages: readonly Message[]): Message[] { + const seenToolCallIds = new Set(); + const seenToolResultIds = new Set(); + const out: Message[] = []; + for (const message of messages) { + if (message.role === 'assistant' && message.toolCalls.length > 0) { + const kept = message.toolCalls.filter((toolCall) => { + if (seenToolCallIds.has(toolCall.id)) return false; + seenToolCallIds.add(toolCall.id); + return true; + }); + if (kept.length === message.toolCalls.length) { + out.push(message); + } else if (kept.length > 0 || message.content.length > 0) { + out.push({ ...message, toolCalls: kept }); + } + continue; + } + if (message.role === 'tool' && message.toolCallId !== undefined) { + if (seenToolResultIds.has(message.toolCallId)) continue; + seenToolResultIds.add(message.toolCallId); + } + out.push(message); + } + return out; +} + +function mergeConsecutiveAssistantMessages(messages: readonly Message[]): Message[] { + const out: Message[] = []; + for (const message of messages) { + const previous = out.at(-1); + if (previous !== undefined && previous.role === 'assistant' && message.role === 'assistant') { + out[out.length - 1] = { + ...previous, + content: [...previous.content, ...message.content], + toolCalls: [...previous.toolCalls, ...message.toolCalls], + }; + continue; + } + out.push(message); + } + return out; +} + +function dropLeadingNonUserMessages(messages: readonly Message[]): Message[] { + let start = 0; + while (start < messages.length && messages[start]?.role !== 'user') start += 1; + return start === 0 ? [...messages] : messages.slice(start); +} + // Projects the stored context history into the wire messages sent to the // model, in a single pass over the history. // diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 0dea4166c..c40643804 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -23,7 +23,16 @@ import { IAgentProfileService } from '#/agent/profile'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import { IAgentUsageService } from '#/agent/usage'; import { IConfigService } from '#/app/config/config'; -import { APIConnectionError, APIContextOverflowError, APIEmptyResponseError, APIStatusError, APITimeoutError, isContextOverflowStatusError, isRetryableGenerateError } from '#/app/llmProtocol/errors'; +import { + APIConnectionError, + APIContextOverflowError, + APIEmptyResponseError, + APIStatusError, + APITimeoutError, + isContextOverflowStatusError, + isRecoverableRequestStructureError, + isRetryableGenerateError, +} from '#/app/llmProtocol/errors'; import { type Message } from '#/app/llmProtocol/message'; import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import { type Tool } from '#/app/llmProtocol/tool'; @@ -214,55 +223,71 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { fields: request.logFields, }); - const input = { + const requestInput = (strict: boolean) => ({ systemPrompt: request.systemPrompt, tools: request.tools, - messages: this.projector.project(request.messages), - }; + messages: strict + ? this.projector.projectStrict(request.messages) + : this.projector.project(request.messages), + }); - let message: Message | undefined; - let usage = emptyUsage(); - let timing: LLMStreamTiming | undefined; - let finish: Extract | undefined; + const run = async (strict: boolean): Promise => { + let message: Message | undefined; + let usage = emptyUsage(); + let timing: LLMStreamTiming | undefined; + let finish: Extract | undefined; - for await (const event of request.model.request(input, signal)) { - switch (event.type) { - case 'part': - await onPart(event.part); - break; - case 'usage': - usage = event.usage; - break; - case 'finish': - finish = event; - message = event.message; - break; - case 'timing': { - const { type: _type, ...streamTiming } = event; - timing = streamTiming; - break; + for await (const event of request.model.request(requestInput(strict), signal)) { + switch (event.type) { + case 'part': + await onPart(event.part); + break; + case 'usage': + usage = event.usage; + break; + case 'finish': + finish = event; + message = event.message; + break; + case 'timing': { + const { type: _type, ...streamTiming } = event; + timing = streamTiming; + break; + } } } - } - if (message === undefined || finish === undefined) { - throw new Error('LLM request stream ended without a finish event.'); - } + if (message === undefined || finish === undefined) { + throw new Error('LLM request stream ended without a finish event.'); + } - const usageModel = request.modelAlias; - this.usage.record(usageModel, usage, request.source); - this.contextSize.measured(request.messages, [message], usage); - this.logResponse(request.logFields, usage, timing); + const usageModel = request.modelAlias; + this.usage.record(usageModel, usage, request.source); + this.contextSize.measured(request.messages, [message], usage); + this.logResponse(request.logFields, usage, timing); - return { - message, - usage, - model: usageModel, - providerFinishReason: finish.providerFinishReason, - rawFinishReason: finish.rawFinishReason, - providerMessageId: finish.id, - timing, + return { + message, + usage, + model: usageModel, + providerFinishReason: finish.providerFinishReason, + rawFinishReason: finish.rawFinishReason, + providerMessageId: finish.id, + timing, + }; }; + + try { + return await run(false); + } catch (error) { + if (signal?.aborted === true || !isRecoverableRequestStructureError(error)) throw error; + signal?.throwIfAborted(); + this.log.warn('provider rejected request structure; resending with strict projection', { + model: request.model.name, + ...request.logFields, + }); + return await run(true); + } } private resolveRequest( diff --git a/packages/agent-core-v2/src/agent/profile/configSection.ts b/packages/agent-core-v2/src/agent/profile/configSection.ts index 643e92214..94b6c8449 100644 --- a/packages/agent-core-v2/src/agent/profile/configSection.ts +++ b/packages/agent-core-v2/src/agent/profile/configSection.ts @@ -19,6 +19,7 @@ export const DEFAULT_THINKING_SECTION = 'defaultThinking'; export const ThinkingConfigSchema = z.object({ mode: z.enum(['auto', 'on', 'off']).optional(), effort: z.string().optional(), + keep: z.string().optional(), }); export type ThinkingConfig = z.infer; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index d3ccd0b7d..fcb52c7a1 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -33,7 +33,7 @@ export interface ProfileModelState { readonly cwd?: string; readonly modelAlias?: string; readonly profileName?: string; - readonly thinkingLevel: ThinkingEffort; + readonly thinkingLevel: string; readonly systemPrompt: string; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index c885dd5cb..7eaf776a1 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -294,13 +294,17 @@ export class AgentProfileService implements IAgentProfileService { if (overrides !== undefined) { if (overrides.temperature !== undefined) kwargs.temperature = overrides.temperature; if (overrides.topP !== undefined) kwargs.top_p = overrides.topP; - if ( - model.protocol === 'kimi' && - thinkingLevel !== 'off' && - overrides.thinkingKeep !== undefined && - overrides.thinkingKeep.length > 0 - ) { - kwargs.extra_body = { thinking: { keep: overrides.thinkingKeep } }; + } + const keep = resolveThinkingKeep( + overrides?.thinkingKeep, + this.config.get(THINKING_SECTION)?.keep, + thinkingLevel, + ); + if (keep !== undefined) { + if (model.protocol === 'kimi') { + kwargs.extra_body = { thinking: { keep } }; + } else if (model.protocol === 'anthropic') { + model = model.withThinkingKeep(keep); } } if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs); @@ -499,6 +503,32 @@ export class AgentProfileService implements IAgentProfileService { } } +const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); + +type KeepResolution = + | { readonly specified: false } + | { readonly specified: true; readonly value: string | undefined }; + +function parseKeepValue(raw: string | undefined): KeepResolution { + const trimmed = raw?.trim(); + if (trimmed === undefined || trimmed.length === 0) return { specified: false }; + if (KEEP_OFF_VALUES.has(trimmed.toLowerCase())) return { specified: true, value: undefined }; + return { specified: true, value: trimmed }; +} + +function resolveThinkingKeep( + envKeep: string | undefined, + configKeep: string | undefined, + thinkingEffort: ThinkingEffort, +): string | undefined { + if (thinkingEffort === 'off') return undefined; + const fromEnv = parseKeepValue(envKeep); + if (fromEnv.specified) return fromEnv.value; + const fromConfig = parseKeepValue(configKeep); + if (fromConfig.specified) return fromConfig.value; + return 'all'; +} + registerScopedService( LifecycleScope.Agent, IAgentProfileService, diff --git a/packages/agent-core-v2/src/app/llmProtocol/capability.ts b/packages/agent-core-v2/src/app/llmProtocol/capability.ts index 08eb7b3eb..27d004860 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/capability.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/capability.ts @@ -15,6 +15,13 @@ export interface ModelCapability { readonly thinking: boolean; readonly tool_use: boolean; readonly max_context_tokens: number; + /** + * Model accepts message-level tool declarations (`messages[].tools`), the + * primitive behind select_tools progressive disclosure. Absent means + * unsupported: only models explicitly catalogued or declared with this + * capability may ever receive a message carrying `tools`. + */ + readonly select_tools?: boolean; } const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY'); @@ -33,6 +40,7 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( thinking: false, tool_use: false, max_context_tokens: 0, + select_tools: false, }, UNKNOWN_CAPABILITY_MARKER, { value: true }, @@ -50,6 +58,7 @@ export function isUnknownCapability(capability: ModelCapability): boolean { !capability.audio_in && !capability.thinking && !capability.tool_use && + capability.select_tools !== true && capability.max_context_tokens === 0 ); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts index 40975430c..ff1fb9437 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts @@ -13,6 +13,8 @@ export interface CatalogModelEntry { readonly limit?: { readonly context?: number; readonly output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; + /** Accepts message-level tool declarations (`messages[].tools`). Defaults to false. */ + readonly select_tools?: boolean; readonly interleaved?: boolean | { readonly field?: string }; readonly modalities?: { readonly input?: readonly string[]; @@ -136,6 +138,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel thinking: Boolean(model.reasoning), tool_use: model.tool_call ?? true, max_context_tokens: context, + select_tools: model.select_tools === true, }, }; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index 989f02249..cd7076d83 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -98,6 +98,33 @@ export function isRetryableGenerateError(error: unknown): boolean { return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode); } +// `terminated` is the undici signature for an SSE/HTTP body stream that is +// dropped mid-flight (common with Node's native fetch on long reasoning +// streams). It surfaces as a raw `TypeError: terminated`, so it must be +// recognized here as a transport-layer connection failure. Shared by the +// Anthropic and OpenAI providers so a raw, non-SDK transport error classifies +// the same way regardless of which provider was streaming. +const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; +const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; + +/** + * Classify a raw (non-SDK) error message into the right transport-layer + * `ChatProviderError` subclass: a timeout becomes a retryable `APITimeoutError`, + * a dropped connection / undici `terminated` becomes a retryable + * `APIConnectionError`, and anything else stays a non-retryable base + * `ChatProviderError`. Timeout is checked first so "connection timed out" + * classifies as a timeout rather than a bare connection error. + */ +export function classifyBaseApiError(message: string): ChatProviderError { + if (TIMEOUT_RE.test(message)) { + return new APITimeoutError(message); + } + if (NETWORK_RE.test(message)) { + return new APIConnectionError(message); + } + return new ChatProviderError(`Error: ${message}`); +} + const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ /context[ _-]?length/, /(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/, @@ -143,6 +170,89 @@ export function isContextOverflowStatusError(statusCode: number, message: string return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +// Strict providers reject a request whose assistant `tool_use`/`tool_calls` and +// `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing +// result, a stray result with no matching call, or a result that does not +// immediately follow its call. Anthropic phrases this in terms of +// `tool_use`/`tool_result`. OpenAI-compatible providers phrase it in terms of +// `tool_call_id` / `role 'tool'` / `tool_calls`: Moonshot / Kimi as a +// `tool_call_id` that "is not found", and OpenAI / DeepSeek / vLLM / Qwen as a +// `role 'tool'` message without a preceding `tool_calls`, or an assistant +// `tool_calls` not followed by its tool results. The validation runs before any +// generation, so the error is a non-retryable 4xx. A caller can react by +// resending a re-projected, strictly wire-compliant request rather than leaving +// the session permanently stuck. +const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ + /tool_use[\s\S]*tool_result/, + /tool_result[\s\S]*tool_use/, + /unexpected\s+`?tool_result/, + // OpenAI-compatible (Moonshot / Kimi): a `tool` message references a + // `tool_call_id` with no matching `tool_calls` entry in the preceding + // assistant message. Observed verbatim as `tool_call_id is not found` + // (doubled space). Anchored on `tool_call_id` so an unrelated "not found" + // (e.g. a 404-style body) cannot trip the recovery. + /tool_call_id[\s\S]*not found/, + // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the + // same structural rejection in terms of `role 'tool'` / `tool_calls` instead + // of Anthropic's `tool_use` / `tool_result`, in two mirror-image shapes: + // + // - An orphan `tool` result whose preceding assistant carries no matching + // `tool_calls`: "messages with role 'tool' must be a response to a + // preceding message with 'tool_calls'". + // - An assistant `tool_calls` with no following `tool` results: "an + // assistant message with 'tool_calls' must be followed by tool messages + // responding to each 'tool_call_id'. the following tool_call_ids did not + // have response messages: ...", or the terse "(insufficient tool messages + // following tool_calls message)". + // + // Both are wire-structure defects the strict resend repairs (drop the orphan + // result / synthesize the missing one). Quote style around `tool`/`tool_calls` + // varies by provider (straight or backtick), so the anchors tolerate an + // optional surrounding quote char. + /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, + /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, + /tool_call_ids? did not have response messages/, + /insufficient tool messages following/, +] as const; + +export function isToolExchangeAdjacencyError(error: unknown): boolean { + if (!(error instanceof APIStatusError)) return false; + if (error instanceof APIContextOverflowError) return false; + if (error.statusCode !== 400 && error.statusCode !== 422) return false; + const lowerMessage = error.message.toLowerCase(); + return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +// The broader family of structural request rejections a strict provider returns +// when the message array itself is malformed — tool_use/tool_result pairing, +// empty or whitespace-only text blocks, a non-user first message, or +// non-alternating roles. All are deterministic 4xx validation failures (no +// generation happened) on a history that is re-sent every turn, so the only +// recovery is to resend a re-projected, strictly wire-compliant request rather +// than leave the session permanently stuck. Context-overflow 400s are excluded — +// they are handled by compaction, not by re-projection. +const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ + /text content blocks must be non-empty/, + /text content blocks must contain non-whitespace/, + /first message must use the .*user.* role/, + /roles must alternate/, + /multiple .*(?:user|assistant).* roles in a row/, + // Anthropic rejects a request whose assistant messages carry two `tool_use` + // blocks with the same id: "messages: `tool_use` ids must be unique". Seen + // when a provider reused a call id (e.g. per-response counter ids) earlier + // in the session; the strict resend dedupes the ids. + /tool_use[\s\S]*ids must be unique/, +] as const; + +export function isRecoverableRequestStructureError(error: unknown): boolean { + if (isToolExchangeAdjacencyError(error)) return true; + if (!(error instanceof APIStatusError)) return false; + if (error instanceof APIContextOverflowError) return false; + if (error.statusCode !== 400 && error.statusCode !== 422) return false; + const lowerMessage = error.message.toLowerCase(); + return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + export function isProviderRateLimitError(error: unknown): boolean { if (error instanceof APIProviderRateLimitError) return true; diff --git a/packages/agent-core-v2/src/app/llmProtocol/generate.ts b/packages/agent-core-v2/src/app/llmProtocol/generate.ts index ad475ed52..5d6a90608 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/generate.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/generate.ts @@ -103,8 +103,16 @@ export async function generate( throwAbortError(); } + // Deferred tools are executable client-side but must not appear in the + // request's top-level `tools[]` (their schemas travel via message-level + // `tools` declarations; the top-level list stays byte-stable for prompt + // caching). This is the single strip point for every provider call. + const wireTools = tools.some((tool) => tool.deferred === true) + ? tools.filter((tool) => tool.deferred !== true) + : tools; + options?.onRequestStart?.(); - const stream = await provider.generate(systemPrompt, tools, history, options); + const stream = await provider.generate(systemPrompt, wireTools, history, options); // Post-await abort check: `provider.generate()` may have resolved before // noticing a mid-flight abort. Reject immediately rather than draining diff --git a/packages/agent-core-v2/src/app/llmProtocol/message.ts b/packages/agent-core-v2/src/app/llmProtocol/message.ts index 611cfb8c0..12db25c84 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/message.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/message.ts @@ -1,3 +1,5 @@ +import type { Tool } from './tool'; + export type Role = 'system' | 'user' | 'assistant' | 'tool'; export interface TextPart { @@ -100,6 +102,16 @@ export interface Message { readonly toolCallId?: string; /** When `true`, indicates the message was not fully received (e.g. stream interrupted). */ readonly partial?: boolean; + /** + * Full tool definitions carried by this message. Meaningful only on + * `role: 'system'` messages: it is the append-only primitive for loading a + * tool mid-conversation without touching the request's top-level `tools[]` + * (which must stay byte-stable to preserve the provider's prompt cache). + * Providers that support message-level tool declarations (Kimi + * `messages[].tools`) serialize it; callers must not send such a message to + * a provider without that capability. + */ + readonly tools?: readonly Tool[] | undefined; } /** Check if a streamed part is a ContentPart (text, think, image_url, audio_url, video_url). */ @@ -110,6 +122,24 @@ export function isContentPart(part: StreamedMessagePart): part is ContentPart { ); } +/** + * True for a message whose only payload is `tools` — the dynamic tool-loading + * primitive (see {@link Message.tools}). Message-level tool declarations are a + * Kimi wire feature; every other provider must skip such a message entirely: + * their explicit field construction already keeps the `tools` field off the + * wire, but the leftover empty message would be rejected (OpenAI: system + * message without content) or serialized as a garbage `` + * turn (Anthropic/Google system-to-user wrapping). + */ +export function isToolDeclarationOnlyMessage(message: Message): boolean { + return ( + message.tools !== undefined && + message.tools.length > 0 && + message.content.length === 0 && + message.toolCalls.length === 0 + ); +} + /** Check if a streamed part is a ToolCall. */ export function isToolCall(part: StreamedMessagePart): part is ToolCall { return part.type === 'function'; diff --git a/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts b/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts index f5d9891ff..02b9faa86 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts @@ -3,7 +3,7 @@ * wire messages / content parts / tool calls. * * Constructors: `createAssistantMessage | createToolMessage | createUserMessage`. - * Predicates: `isContentPart | isToolCall | isToolCallPart`. + * Predicates: `isContentPart | isToolCall | isToolCallPart | isToolDeclarationOnlyMessage`. * Utilities: `extractText | mergeInPlace` (in-place merge of streamed * tool-call argument deltas). * @@ -20,5 +20,6 @@ export { isContentPart, isToolCall, isToolCallPart, + isToolDeclarationOnlyMessage, mergeInPlace, } from './message'; diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index fad76d196..4e44aac6b 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -3,16 +3,28 @@ import type { Tool } from './tool'; import type { TokenUsage } from './usage'; /** - * Normalized thinking effort level used across providers. + * Thinking effort passed to {@link ChatProvider.withThinking}. * - * `'on'` is the boolean-thinking signal for models that do not expose named - * efforts. Values above `high` are provider/model-specific and may be clamped - * by the adapter when the native API has no matching level. OpenAI maps `max` - * to its `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`; - * Anthropic supports `xhigh`/`max` only on selected models and otherwise clamps - * to `high`. + * `'off'` and `'on'` are the only reserved values: `'off'` disables thinking, + * and `'on'` is the on-signal for boolean models (models that do not declare + * `support_efforts`). Everything else is a model-declared effort (e.g. + * `"low"`, `"high"`, `"max"`) carried as an open string. The type collapses to + * `string` at runtime; it exists purely as a semantic marker that a value is + * expected to be `'off'`, `'on'`, or a model-declared effort. + * + * The model's `support_efforts` is the single source of truth for which + * efforts are valid — providers normalize any unrecognized effort by omitting + * the effort on the wire rather than rejecting it. */ -export type ThinkingEffort = 'off' | 'on' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +export type ThinkingEffort = + | 'off' + | 'on' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | (string & {}); /** * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index d52e9a41d..c5be13d5d 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -2,9 +2,11 @@ import { APIConnectionError, APITimeoutError, ChatProviderError, + classifyBaseApiError, normalizeAPIStatusError, } from '../errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message'; +import { isToolDeclarationOnlyMessage } from '../message'; import type { ChatProvider, FinishReason, @@ -37,6 +39,7 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; +import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { normalizeToolCallIdsForProvider, @@ -111,9 +114,27 @@ interface AnthropicGenerationKwargs { thinking?: MessageCreateParams['thinking'] | undefined; output_config?: MessageCreateParams['output_config'] | undefined; betaFeatures?: string[] | undefined; + contextManagement?: AnthropicContextManagement | undefined; } +/** + * Anthropic beta context-management payload (`context-management-2025-06-27`). + * Only the `clear_thinking_20251015` edit is emitted today, with `keep` + * forwarded as a string (`"all"`); the `{ type, value }` turn-count form is + * not used because the shared `[thinking] keep` config is a string. + */ +interface AnthropicContextManagement { + edits: Array<{ type: string; keep?: unknown }>; +} + +// Anthropic's native effort values. `ThinkingEffort` is an open string, so after +// clamping (and ruling out 'off') we narrow to this concrete set before writing +// `output_config.effort` / computing a token budget. +type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; +const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; +const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/; const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const; const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { @@ -330,17 +351,7 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean { return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); } -type AnthropicThinkingEffort = Exclude; - -function normalizeAnthropicEffort(effort: ThinkingEffort): AnthropicThinkingEffort { - return effort === 'on' ? 'high' : effort; -} - -function clampEffort( - effort: AnthropicThinkingEffort, - model: string, - adaptive: boolean, -): AnthropicThinkingEffort { +function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort { if (effort === 'off') { return effort; } @@ -350,10 +361,22 @@ function clampEffort( if (effort === 'max' && !adaptive) { return 'high'; } + // 'on' (boolean models) or any effort Anthropic does not recognize: fall + // back to 'high' so budgetTokensForEffort / output_config.effort never see + // an unsupported value. + if ( + effort !== 'low' && + effort !== 'medium' && + effort !== 'high' && + effort !== 'xhigh' && + effort !== 'max' + ) { + return 'high'; + } return effort; } -function budgetTokensForEffort(effort: AnthropicThinkingEffort): number { +function budgetTokensForEffort(effort: ThinkingEffort): number { switch (effort) { case 'low': return 1024; @@ -403,15 +426,10 @@ function injectCacheControlOnLastBlock(messages: MessageParam[]): void { } /** - * Check whether a MessageParam is a user message whose content consists - * entirely of `tool_result` blocks. - * - * Used to detect adjacent tool-result-only messages that must be merged - * before hitting the Anthropic wire. Per the Messages API parallel-tool-use - * spec, all `tool_result` blocks answering parallel `tool_use` calls must - * live in a single user message — splitting them across consecutive user - * messages fails on strict Anthropic-compatible backends (HTTP 400) and - * silently degrades parallel tool use on api.anthropic.com. + * Whether a user MessageParam consists solely of `tool_result` blocks. Used to + * keep tool results bundled with each other (parallel-tool-use spec) while + * not merging a tool-result user message into an adjacent plain-text user + * message — the two carry different semantics and must stay separate. */ function isToolResultOnly(message: MessageParam): boolean { if (message.role !== 'user') return false; @@ -649,8 +667,13 @@ export function convertAnthropicError(error: unknown): ChatProviderError { if (error instanceof AnthropicError) { return new ChatProviderError(`Anthropic error: ${error.message}`); } + // Raw, non-SDK errors (e.g. undici's `TypeError: terminated` raised when a + // streaming response body is dropped mid-flight) are never wrapped by the + // Anthropic SDK during stream iteration. Route them through the shared + // transport-layer heuristic so genuine connection failures become retryable + // instead of fatal generic errors. if (error instanceof Error) { - return new ChatProviderError(`Error: ${error.message}`); + return classifyBaseApiError(error.message); } return new ChatProviderError(`Error: ${String(error)}`); } @@ -1010,25 +1033,39 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; - // Convert messages, merging consecutive tool-result-only user messages - // into a single user message (Anthropic parallel-tool-use spec). - const messages: MessageParam[] = []; - const normalizedHistory = normalizeToolCallIdsForProvider( - history, - ANTHROPIC_TOOL_CALL_ID_POLICY, + // Convert messages, then merge consecutive user messages into one. Strict + // Anthropic-compatible backends reject consecutive user messages with HTTP + // 400 ("roles must alternate"), and api.anthropic.com concatenates them + // anyway — so merging is safe for native Anthropic and required for strict + // backends. Consecutive plain-text user messages arise naturally after + // compaction (kept user prompts + user-role summary + injected reminders) + // and from back-to-back system messages converted to user role above; a + // tool-result user turn followed by a text turn arises from steering after + // a tool result. The shared helper applies the asymmetric merge rule (see + // mergeConsecutiveUserMessages) so this provider and Gemini/Vertex stay in + // step. + const messages = mergeConsecutiveUserMessages( + normalizeToolCallIdsForProvider( + // Message-level tool declarations are a Kimi wire feature; here the + // whole message is skipped (an empty leftover would serialize as a + // garbage `` user turn). See isToolDeclarationOnlyMessage. + history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), + ANTHROPIC_TOOL_CALL_ID_POLICY, + ).map((msg) => + convertMessage(msg, this._model), + ), + { + isUser: (message) => message.role === 'user', + isToolResultOnly, + merge: (last, next) => ({ + ...last, + content: [ + ...(last.content as ContentBlockParam[]), + ...(next.content as ContentBlockParam[]), + ], + }), + }, ); - for (const msg of normalizedHistory) { - const converted = convertMessage(msg, this._model); - const last = messages.at(-1); - if (last !== undefined && isToolResultOnly(last) && isToolResultOnly(converted)) { - last.content = [ - ...(last.content as ContentBlockParam[]), - ...(converted.content as ContentBlockParam[]), - ]; - } else { - messages.push(converted); - } - } // Inject cache_control on last content block of last message (after merge, // so it lands on the final tool_result block in the merged user message). @@ -1059,6 +1096,9 @@ export class AnthropicChatProvider implements ChatProvider { if (this._generationKwargs.output_config !== undefined) { kwargs['output_config'] = this._generationKwargs.output_config; } + if (this._generationKwargs.contextManagement !== undefined) { + kwargs['context_management'] = this._generationKwargs.contextManagement; + } // Build the beta feature list. On the standard Messages API these travel // via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the @@ -1232,10 +1272,11 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } - const effectiveEffort = clampEffort(normalizeAnthropicEffort(effort), this._model, adaptive); - if (effectiveEffort === 'off') { + const clamped = clampEffort(effort, this._model, adaptive); + if (clamped === 'off') { throw new Error('Non-off thinking effort unexpectedly clamped to off.'); } + const effectiveEffort = clamped as AnthropicEffort; let newBetas = [...(this._generationKwargs.betaFeatures ?? [])]; @@ -1264,6 +1305,35 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } + withThinkingKeep(keep: string): AnthropicChatProvider { + const current = this._generationKwargs.betaFeatures ?? []; + const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA) + ? current + : [...current, CONTEXT_MANAGEMENT_BETA]; + // Preserve any existing context-management edits (e.g. clear_tool_uses) and + // keep clear_thinking first, as Anthropic requires when combining edits. Drop + // a previous clear_thinking edit so re-applying stays idempotent. + const existingEdits = this._generationKwargs.contextManagement?.edits ?? []; + const edits = [ + { type: CLEAR_THINKING_EDIT, keep }, + ...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT), + ]; + const clone = this._withGenerationKwargs({ + contextManagement: { edits }, + betaFeatures, + }); + // clear_thinking_20251015 is honored only on the beta Messages API + // (client.beta.messages.create), so enabling keep forces the beta endpoint + // here even when the provider was constructed with betaApi: false. Setting + // `[thinking] keep` to an off-value (or KIMI_MODEL_THINKING_KEEP=off) is the + // escape hatch that disables keep and returns requests to the standard + // endpoint. This also routes adaptive models (whose withThinking would + // otherwise drop the interleaved-thinking beta and leave betaFeatures empty) + // onto the beta endpoint with a body `betas=[context-management-...]`. + clone._betaApi = true; + return clone; + } + withGenerationKwargs(kwargs: Partial): AnthropicChatProvider { return this._withGenerationKwargs(kwargs); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index 399f2ba2f..607a470d9 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -5,6 +5,7 @@ import { normalizeAPIStatusError, } from '../errors'; import type { Message, StreamedMessagePart, ToolCall } from '../message'; +import { isToolDeclarationOnlyMessage } from '../message'; import type { ChatProvider, FinishReason, @@ -16,6 +17,7 @@ import type { import type { Tool } from '../tool'; import type { TokenUsage } from '../usage'; import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai'; +import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { requireProviderApiKey, resolveAuthBackedClient } from './request-auth'; @@ -74,6 +76,14 @@ function normalizeGoogleGenAIFinishReason(raw: unknown): { export interface GoogleGenAIOptions { apiKey?: string | undefined; model: string; + /** + * Override the endpoint the SDK talks to (forwarded as + * `httpOptions.baseUrl`). When unset, the SDK falls back to its default + * (`generativelanguage.googleapis.com` for Gemini, the regional + * `*-aiplatform.googleapis.com` for Vertex). Set this to route through a + * Gemini-compatible proxy/gateway. + */ + baseUrl?: string | undefined; vertexai?: boolean | undefined; project?: string | undefined; location?: string | undefined; @@ -83,36 +93,36 @@ export interface GoogleGenAIOptions { } export interface GoogleGenAIGenerationKwargs { - max_output_tokens?: number | undefined; + maxOutputTokens?: number | undefined; temperature?: number | undefined; - top_k?: number | undefined; - top_p?: number | undefined; - thinking_config?: ThinkingConfig | undefined; + topK?: number | undefined; + topP?: number | undefined; + thinkingConfig?: ThinkingConfig | undefined; [key: string]: unknown; } interface ThinkingConfig { - include_thoughts?: boolean; - thinking_budget?: number; - thinking_level?: string; + includeThoughts?: boolean; + thinkingBudget?: number; + thinkingLevel?: string; } interface GoogleFunctionDeclaration { name: string; description: string; - parameters_json_schema: Record; + parametersJsonSchema: Record; } interface GoogleTool { - function_declarations: GoogleFunctionDeclaration[]; + functionDeclarations: GoogleFunctionDeclaration[]; } function toolToGoogleGenAI(tool: Tool): GoogleTool { return { - function_declarations: [ + functionDeclarations: [ { name: tool.name, description: tool.description, - parameters_json_schema: tool.parameters, + parametersJsonSchema: tool.parameters, }, ], }; @@ -124,13 +134,13 @@ interface GoogleContent { interface GooglePart { text?: string; - function_call?: { name: string; args: Record }; - function_response?: { + functionCall?: { name: string; args: Record }; + functionResponse?: { name: string; response: Record; parts: unknown[]; }; - thought_signature?: string; + thoughtSignature?: string; [key: string]: unknown; } @@ -265,15 +275,15 @@ function messageToGoogleGenAI(message: Message): GoogleContent { } const functionCallPart: GooglePart = { - function_call: { + functionCall: { name: toolCall.name, args, }, }; - // Restore thought_signature if available + // Restore thoughtSignature if available if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) { - functionCallPart['thought_signature'] = toolCall.extras['thought_signature_b64'] as string; + functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string; } parts.push(functionCallPart); @@ -326,7 +336,7 @@ function toolMessageToFunctionResponseParts( } const functionResponsePart: GooglePart = { - function_response: { + functionResponse: { name: toolCallIdToName(message.toolCallId, toolNameById), response: { output: textOutput }, parts: [], @@ -345,6 +355,15 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten const message = messages[i]; if (message === undefined) break; + // Message-level tool declarations are a Kimi wire feature. The system + // branch below would already drop the empty leftover via its text-length + // check, but skip explicitly so the behavior does not hinge on that + // coincidence (and covers a non-system carrier defensively). + if (isToolDeclarationOnlyMessage(message)) { + i += 1; + continue; + } + if (message.role === 'system') { // Google GenAI's `Content.role` only accepts "user" or "model", so a // system message in the history (e.g. from session restore or @@ -352,7 +371,7 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten // the content by wrapping it in a `` tag and attaching it as // a user turn — mirrors the Anthropic provider's behavior. The // dedicated top-level `systemPrompt` still flows into - // `system_instruction` separately; only historical system messages + // `systemInstruction` separately; only historical system messages // come through here. const text = message.content .filter((p): p is { type: 'text'; text: string } => p.type === 'text') @@ -447,7 +466,16 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten i += 1; } - return contents; + // Gemini/Vertex require strictly alternating user/model turns. Consecutive + // user Contents arise after compaction (`[prompts, summary, reminders]`) and + // when a user turn follows a tool result; collapse them into one user turn. + return mergeConsecutiveUserMessages(contents, { + isUser: (content) => content.role === 'user', + isToolResultOnly: (content) => + content.parts.length > 0 && + content.parts.every((part) => part.functionResponse !== undefined), + merge: (last, next) => ({ ...last, parts: [...last.parts, ...next.parts] }), + }); } export class GoogleGenAIStreamedMessage implements StreamedMessage { private _id: string | null = null; @@ -672,6 +700,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { private _vertexai: boolean; private _stream: boolean; private _apiKey: string | undefined; + private _baseUrl: string | undefined; private _project: string | undefined; private _location: string | undefined; private _defaultHeaders: Record | undefined; @@ -685,6 +714,8 @@ export class GoogleGenAIChatProvider implements ChatProvider { const apiKey = options.apiKey ?? process.env['GOOGLE_API_KEY']; this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; + this._baseUrl = + options.baseUrl === undefined || options.baseUrl.length === 0 ? undefined : options.baseUrl; this._project = options.project; this._location = options.location; this._defaultHeaders = options.defaultHeaders; @@ -694,6 +725,19 @@ export class GoogleGenAIChatProvider implements ChatProvider { } private _buildClient(apiKey: string | undefined): GenAIClient { + // The Google GenAI SDK reads the endpoint and headers from `httpOptions`, + // deep-merging them over its defaults: a `baseUrl` here overrides the + // default host (`generativelanguage.googleapis.com` / Vertex regional), + // and a `User-Agent` overrides the SDK default (`google-genai-sdk/ …`) + // while preserving the other default headers (`x-goog-api-client`, + // `Content-Type`). Build the object once so both can coexist. + const httpOptions: { headers?: Record; baseUrl?: string } = {}; + if (this._defaultHeaders !== undefined) { + httpOptions.headers = this._defaultHeaders; + } + if (this._baseUrl !== undefined) { + httpOptions.baseUrl = this._baseUrl; + } return new GenAIClient({ apiKey, ...(this._vertexai @@ -703,13 +747,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { location: this._location, } : {}), - // The Google GenAI SDK deep-merges `httpOptions.headers` into its - // default request headers, so a `User-Agent` here overrides the SDK - // default (`google-genai-sdk/ …`) while preserving the other - // defaults (`x-goog-api-client`, `Content-Type`). - ...(this._defaultHeaders !== undefined - ? { httpOptions: { headers: this._defaultHeaders } } - : {}), + ...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}), }); } @@ -718,16 +756,16 @@ export class GoogleGenAIChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - const thinkingConfig = this._generationKwargs.thinking_config; + const thinkingConfig = this._generationKwargs.thinkingConfig; if (thinkingConfig === undefined) return null; - // For gemini-3 models that use thinking_level - if (thinkingConfig.thinking_level !== undefined) { - switch (thinkingConfig.thinking_level) { + // For gemini-3 models that use thinkingLevel + if (thinkingConfig.thinkingLevel !== undefined) { + switch (thinkingConfig.thinkingLevel) { case 'MINIMAL': // MINIMAL + suppressed thoughts is how 'off' is encoded for Gemini 3, // which has no true "disabled" level. - return thinkingConfig.include_thoughts === false ? 'off' : 'low'; + return thinkingConfig.includeThoughts === false ? 'off' : 'low'; case 'LOW': return 'low'; case 'MEDIUM': @@ -739,11 +777,11 @@ export class GoogleGenAIChatProvider implements ChatProvider { } } - // For other models that use thinking_budget - if (thinkingConfig.thinking_budget !== undefined) { - if (thinkingConfig.thinking_budget === 0) return 'off'; - if (thinkingConfig.thinking_budget <= 1024) return 'low'; - if (thinkingConfig.thinking_budget <= 4096) return 'medium'; + // For other models that use thinkingBudget + if (thinkingConfig.thinkingBudget !== undefined) { + if (thinkingConfig.thinkingBudget === 0) return 'off'; + if (thinkingConfig.thinkingBudget <= 1024) return 'low'; + if (thinkingConfig.thinkingBudget <= 4096) return 'medium'; return 'high'; } @@ -773,7 +811,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { const config: Record = { ...this._generationKwargs, - system_instruction: systemPrompt, + systemInstruction: systemPrompt, ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), }; @@ -838,53 +876,53 @@ export class GoogleGenAIChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): GoogleGenAIChatProvider { - const thinkingConfig: ThinkingConfig = { include_thoughts: true }; + const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (this._model.includes('gemini-3')) { - // Gemini 3 models use thinking_level (MINIMAL/LOW/MEDIUM/HIGH). The SDK + // Gemini 3 models use thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH). The SDK // does not expose a "disabled" level, so 'off' maps to MINIMAL with // thought output suppressed — the lowest thinking intensity available. switch (effort) { case 'off': - thinkingConfig.thinking_level = 'MINIMAL'; - thinkingConfig.include_thoughts = false; + thinkingConfig.thinkingLevel = 'MINIMAL'; + thinkingConfig.includeThoughts = false; break; case 'low': - thinkingConfig.thinking_level = 'LOW'; + thinkingConfig.thinkingLevel = 'LOW'; break; case 'medium': - thinkingConfig.thinking_level = 'MEDIUM'; + thinkingConfig.thinkingLevel = 'MEDIUM'; break; case 'high': case 'xhigh': case 'max': - thinkingConfig.thinking_level = 'HIGH'; + thinkingConfig.thinkingLevel = 'HIGH'; break; } } else { switch (effort) { case 'off': - thinkingConfig.thinking_budget = 0; - thinkingConfig.include_thoughts = false; + thinkingConfig.thinkingBudget = 0; + thinkingConfig.includeThoughts = false; break; case 'low': - thinkingConfig.thinking_budget = 1024; - thinkingConfig.include_thoughts = true; + thinkingConfig.thinkingBudget = 1024; + thinkingConfig.includeThoughts = true; break; case 'medium': - thinkingConfig.thinking_budget = 4096; - thinkingConfig.include_thoughts = true; + thinkingConfig.thinkingBudget = 4096; + thinkingConfig.includeThoughts = true; break; case 'high': case 'xhigh': case 'max': - thinkingConfig.thinking_budget = 32_000; - thinkingConfig.include_thoughts = true; + thinkingConfig.thinkingBudget = 32_000; + thinkingConfig.includeThoughts = true; break; } } - return this.withGenerationKwargs({ thinking_config: thinkingConfig }); + return this.withGenerationKwargs({ thinkingConfig }); } withGenerationKwargs(kwargs: GoogleGenAIGenerationKwargs): GoogleGenAIChatProvider { @@ -894,7 +932,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { } withMaxCompletionTokens(maxCompletionTokens: number): GoogleGenAIChatProvider { - return this.withGenerationKwargs({ max_output_tokens: maxCompletionTokens }); + return this.withGenerationKwargs({ maxOutputTokens: maxCompletionTokens }); } private _clone(): GoogleGenAIChatProvider { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index 269c40253..819cefc63 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -46,7 +46,10 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; - supportEfforts?: readonly string[]; + /** Efforts the model advertises (e.g. ["low", "high", "max"]). When + * present and non-empty, withThinking sends the chosen effort on the wire; + * when absent/empty, only thinking.type is sent. */ + supportEfforts?: readonly string[] | undefined; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -74,6 +77,7 @@ export interface GenerationKwargs { export interface ThinkingConfig { type?: 'enabled' | 'disabled'; + effort?: string; keep?: unknown; [key: string]: unknown; } @@ -93,6 +97,8 @@ interface OpenAIMessage { tool_call_id?: string | undefined; name?: string | undefined; reasoning_content?: string | undefined; + /** Message-level tool declarations (`messages[].tools`), see convertMessage. */ + tools?: OpenAIToolParam[] | undefined; } interface OpenAIToolCallOut { @@ -166,6 +172,16 @@ function convertMessage(message: Message): OpenAIMessage { result.reasoning_content = reasoningContent; } + // Message-level tool declarations: a system message carrying `tools` loads + // those definitions mid-conversation (`messages[].tools` in the Kimi + // contract; each entry is a full OpenAI-compatible tool param). Reusing + // convertTool keeps schema normalization and the `$` builtin_function + // branch identical to the top-level `tools[]` path. Such a message carries + // no `content` — the empty-content branch above already omits the field. + if (message.tools !== undefined && message.tools.length > 0) { + result.tools = message.tools.map((tool) => convertTool(tool)); + } + return result; } function convertTool(tool: Tool): OpenAIToolParam { @@ -419,8 +435,8 @@ export class KimiChatProvider implements ChatProvider { const thinking = this._generationKwargs.extra_body?.thinking; if (thinking === undefined) return null; if (thinking.type === 'disabled') return 'off'; - const effort = thinking['effort']; - return typeof effort === 'string' ? (effort as ThinkingEffort) : 'on'; + // A model that enables thinking without an effort is treated as boolean ("on"). + return thinking.effort ?? 'on'; } get modelParameters(): Record { @@ -492,8 +508,8 @@ export class KimiChatProvider implements ChatProvider { try { const client = this._createClient(options?.auth); - // Use type assertion via unknown because we pass Moonshot-proprietary fields - // (reasoning_effort, thinking) that don't exist in the OpenAI type definitions. + // Use type assertion via unknown because we pass the Moonshot-proprietary + // `thinking` field (via extra_body) that doesn't exist in the OpenAI type definitions. options?.onRequestSent?.(); const response = (await client.chat.completions.create( createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, @@ -510,10 +526,18 @@ export class KimiChatProvider implements ChatProvider { if (effort === 'off') { thinking = { type: 'disabled' }; } else { + // Only efforts the model explicitly declares via `support_efforts` are + // sent on the wire. When `support_efforts` is absent/empty, or the + // requested effort is not declared, only thinking.type is sent. thinking = this._supportEfforts.includes(effort) ? { type: 'enabled', effort } : { type: 'enabled' }; } + // Replace extra_body.thinking wholesale so a stale `effort` from a previous + // withThinking call can never linger on a disabled or non-effort thinking + // object — but carry over a `keep` set earlier via withExtraBody (the + // KIMI_MODEL_THINKING_KEEP path applies keep after withThinking and merges + // on top, so it is unaffected either way). const oldExtra = this._generationKwargs.extra_body ?? {}; const keep = oldExtra.thinking?.keep; if (keep !== undefined) { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts new file mode 100644 index 000000000..95db1182e --- /dev/null +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts @@ -0,0 +1,64 @@ +/** + * Collapses consecutive same-role "user" turns in a provider's already-converted + * wire message list into one turn. + * + * Strict providers (Anthropic, Gemini/Vertex) reject consecutive user turns with + * HTTP 400 ("roles must alternate"). Consecutive user turns arise naturally: + * - after compaction, whose shape is `[kept user prompts, user-role summary, + * injected reminders]` — all role 'user'; and + * - when a user turn (steer/injection) follows a tool result. + * + * Both only become visible once tool messages have been converted to user-role + * turns, which is why this runs at each provider's conversion boundary rather + * than in the provider-agnostic projector: the projector deliberately preserves + * message structure for lenient providers (OpenAI/Kimi) that accept — and read + * more clearly — distinct turns, while strict providers normalize for their own + * protocol here. Keeping the algorithm in one place stops a provider from + * silently omitting it (the original cause of the Gemini regression). + * + * The merge is asymmetric, keyed on whether the running turn is tool-result-only: + * - a tool-result-only running turn absorbs whatever follows — another + * tool-result-only turn (the parallel-tool-use spec requires all tool + * results answering parallel calls to share one user turn) or a following + * text turn, yielding a valid `[tool_result, …, text]` turn; + * - a text running turn absorbs only a following text turn, never a leading + * tool-result turn (a tool-result must answer the immediately preceding + * assistant tool_use, which a text turn is not — though in well-formed + * transcripts this ordering never arises). + * + * @typeParam T - the provider's wire message type (e.g. Anthropic `MessageParam` + * or Google `Content`). + * @param messages - the converted wire messages, in order. + * @param ops - provider-specific predicates and a content merger. + * @param ops.isUser - whether a wire message is a user-role turn. + * @param ops.isToolResultOnly - whether a user-role turn carries only tool + * results (no plain text/media). + * @param ops.merge - produces a new wire message combining `last` and `next` + * (must not mutate its arguments). + * @returns a new array with consecutive user turns merged. + */ +export function mergeConsecutiveUserMessages( + messages: readonly T[], + ops: { + readonly isUser: (message: T) => boolean; + readonly isToolResultOnly: (message: T) => boolean; + readonly merge: (last: T, next: T) => T; + }, +): T[] { + const out: T[] = []; + for (const message of messages) { + const lastIndex = out.length - 1; + const last = lastIndex >= 0 ? out[lastIndex] : undefined; + if ( + last !== undefined && + ops.isUser(last) && + ops.isUser(message) && + (ops.isToolResultOnly(last) || !ops.isToolResultOnly(message)) + ) { + out[lastIndex] = ops.merge(last, message); + } else { + out.push(message); + } + } + return out; +} diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index 77c81fd21..b00ddb792 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -2,6 +2,7 @@ import { APIConnectionError, APITimeoutError, ChatProviderError, + classifyBaseApiError, normalizeAPIStatusError, } from '../errors'; import { extractText } from '../message'; @@ -84,22 +85,6 @@ export function toolToOpenAI(tool: Tool): OpenAIToolParam { }, }; } -// `terminated` is the undici signature for an SSE/HTTP body stream that is -// dropped mid-flight (common with Node's native fetch on long reasoning -// streams). It surfaces as a raw `TypeError: terminated`, so it must be -// recognized here as a transport-layer connection failure. -const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; -const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; - -function classifyBaseApiError(message: string): ChatProviderError { - if (TIMEOUT_RE.test(message)) { - return new APITimeoutError(message); - } - if (NETWORK_RE.test(message)) { - return new APIConnectionError(message); - } - return new ChatProviderError(`Error: ${message}`); -} /** * Convert an OpenAI SDK error (or raw Error) to a kosong `ChatProviderError`. @@ -177,7 +162,10 @@ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string case 'max': return 'xhigh'; default: - throw new Error(`Unknown thinking effort: ${String(effort)}`); + // 'on' (boolean models) or any model-declared effort OpenAI does not + // recognize: send no reasoning_effort and let the model use its own + // default, rather than throwing on a value the model itself advertised. + return undefined; } } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index fb6a80184..b9b326e31 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -1,4 +1,5 @@ import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message'; +import { isToolDeclarationOnlyMessage } from '../message'; import type { ChatProvider, FinishReason, @@ -286,6 +287,10 @@ function convertHistoryMessages( const pendingToolResultMedia: OpenAIContentPart[] = []; for (const msg of history) { + // Message-level tool declarations are a Kimi wire feature; skipped here + // because the leftover `{role:"system"}` without content is rejected by + // the Chat Completions API. See isToolDeclarationOnlyMessage. + if (isToolDeclarationOnlyMessage(msg)) continue; if (msg.role !== 'tool') { appendToolResultMediaMessage(messages, pendingToolResultMedia); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 947f2f923..45e71ea1f 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -5,7 +5,7 @@ import { isContextOverflowErrorCode, } from '../errors'; import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '../message'; -import { extractText } from '../message'; +import { extractText, isToolDeclarationOnlyMessage } from '../message'; import type { ChatProvider, FinishReason, @@ -614,6 +614,10 @@ function convertHistoryMessages( }; for (const msg of history) { + // Message-level tool declarations are a Kimi wire feature; skipped here + // because the leftover content-free message item is rejected by the + // Responses API. See isToolDeclarationOnlyMessage. + if (isToolDeclarationOnlyMessage(msg)) continue; if (msg.role !== 'tool') { flushPendingMedia(); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/tool.ts b/packages/agent-core-v2/src/app/llmProtocol/tool.ts index 470b1f937..8ebc77897 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/tool.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/tool.ts @@ -12,4 +12,13 @@ export interface Tool { description: string; /** JSON Schema describing the tool's parameters. */ parameters: Record; + /** + * Client-internal marker: the tool is executable but its schema must not be + * serialized into the request's top-level `tools[]` — it was (or will be) + * delivered through a message-level `tools` declaration instead, and the + * top-level list must stay byte-stable for prompt caching. `generate()` + * strips marked tools before the provider builds the request; the marker + * itself never reaches the wire. + */ + deferred?: true; } diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index d5134ef8b..acfa8f858 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -169,6 +169,15 @@ export class ModelImpl implements Model { }); } + withThinkingKeep(keep: string): Model { + return this.clone((p) => { + const applied = (p as ChatProvider & { + withThinkingKeep?: (k: string) => ChatProvider; + }).withThinkingKeep; + return applied !== undefined ? applied.call(p, keep) : p; + }); + } + /** Materialize the transformed kosong ChatProvider. Cached per Model instance. */ private resolveChatProvider(): ChatProvider { if (this.cachedChatProvider !== undefined) return this.cachedChatProvider; diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index 7e9844780..ae2fcf575 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -127,6 +127,9 @@ export interface Model { /** Return a new Model wrapper with additional protocol-constructor options applied. */ withProviderOptions(options: ProtocolProviderOptions): Model; + /** Return a new Model wrapper with preserved-thinking keep applied when the protocol supports it. */ + withThinkingKeep(keep: string): Model; + /** * Drive one LLM request end-to-end. Streams `LLMEvent`s until the stream * terminates (either normally with `usage`+`finish`, or with an error). diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index d7ae578a0..323c53306 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -362,6 +362,10 @@ function resolveModelCapabilities( thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, tool_use: declared.has('tool_use') || detected.tool_use, max_context_tokens: maxContextSize, + // Message-level tool declarations (select_tools progressive disclosure). + // Every kosong capability bit must be forwarded explicitly here, or the + // agent layer can never gate progressive disclosure on it. + select_tools: declared.has('select_tools') || detected.select_tools === true, }; } diff --git a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts index e6671e290..c6ce512bc 100644 --- a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts @@ -68,6 +68,10 @@ describe('projector tool-exchange normalization', () => { ); } + function projectStrict(history: readonly ContextMessage[]): readonly Message[] { + return projector.projectStrict(history); + } + it('leaves a fully resolved exchange untouched', () => { const history = [user('go'), assistant('', ['c1']), toolResult('c1', 'one'), user('next')]; expect(shape(history)).toEqual(['user', 'assistant', 'tool:c1', 'user']); @@ -212,4 +216,42 @@ describe('projector tool-exchange normalization', () => { expect(project([message])).toHaveLength(1); }); + it('strict mode dedupes duplicate assistant tool call ids', () => { + const history = [ + user('go'), + assistant('first', ['dup']), + toolResult('dup', 'one'), + assistant('second', ['dup']), + toolResult('dup', 'two'), + ]; + + const projected = projectStrict(history); + + expect(projected.map((message) => (message.role === 'tool' ? `tool:${message.toolCallId}` : message.role))).toEqual([ + 'user', + 'assistant', + 'tool:dup', + 'assistant', + ]); + expect(projected[1]?.toolCalls.map((call) => call.id)).toEqual(['dup']); + expect(projected.filter((message) => message.role === 'tool')).toHaveLength(1); + }); + + it('strict mode drops leading non-user messages', () => { + const projected = projectStrict([assistant('stale'), toolResult('ghost', 'orphaned'), user('hi')]); + + expect(projected.map((message) => message.role)).toEqual(['user']); + expect(projected[0]?.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('strict mode merges consecutive assistant messages', () => { + const projected = projectStrict([user('go'), assistant('one'), assistant('two')]); + + expect(projected.map((message) => message.role)).toEqual(['user', 'assistant']); + expect(projected[1]?.content).toEqual([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ]); + }); + }); diff --git a/packages/agent-core-v2/test/llmProtocol/anthropic-context-management.test.ts b/packages/agent-core-v2/test/llmProtocol/anthropic-context-management.test.ts new file mode 100644 index 000000000..e75b7b0f2 --- /dev/null +++ b/packages/agent-core-v2/test/llmProtocol/anthropic-context-management.test.ts @@ -0,0 +1,90 @@ +import type { Message } from '#/app/llmProtocol/message'; +import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; +import { describe, expect, it, vi } from 'vitest'; + +const HISTORY: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }]; + +function createProvider(): AnthropicChatProvider { + return new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + }); +} + +function makeAnthropicResponse() { + return { + id: 'msg_test_123', + type: 'message', + role: 'assistant', + model: 'kimi-for-coding', + content: [{ type: 'text', text: 'Hello' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }; +} + +async function captureBetaRequestBody(provider: AnthropicChatProvider): Promise> { + let capturedParams: Record | undefined; + const standardCreate = vi.fn(); + + (provider as unknown as { _client: { beta: { messages: { create: unknown } }; messages: { create: unknown } } })._client.beta.messages.create = + vi.fn().mockImplementation((params: unknown) => { + capturedParams = params as Record; + return Promise.resolve(makeAnthropicResponse()); + }); + (provider as unknown as { _client: { messages: { create: unknown } } })._client.messages.create = standardCreate; + + const stream = await provider.generate('', [], HISTORY); + for await (const part of stream) void part; + + if (capturedParams === undefined) { + throw new Error('Expected provider.generate() to call beta.messages.create'); + } + expect(standardCreate).not.toHaveBeenCalled(); + return capturedParams; +} + +describe('Anthropic withThinkingKeep context_management parity', () => { + it('forces the beta endpoint and emits context_management clear_thinking keep', async () => { + const body = await captureBetaRequestBody(createProvider().withThinkingKeep('all')); + + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toContain('context-management-2025-06-27'); + }); + + it('prepends clear_thinking before existing context-management edits', () => { + const provider = createProvider() + .withGenerationKwargs({ + contextManagement: { + edits: [{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }], + }, + }) + .withThinkingKeep('all'); + + expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ + contextManagement: { + edits: [ + { type: 'clear_thinking_20251015', keep: 'all' }, + { type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }, + ], + }, + }); + }); + + it('does not duplicate the context-management beta or clear_thinking edit', () => { + const provider = createProvider().withThinkingKeep('all').withThinkingKeep('all'); + const generationKwargs = Reflect.get(provider, '_generationKwargs') as { + readonly betaFeatures?: readonly string[]; + readonly contextManagement?: { readonly edits: readonly unknown[] }; + }; + + expect(generationKwargs.betaFeatures?.filter((beta) => beta === 'context-management-2025-06-27')).toHaveLength(1); + expect(generationKwargs.contextManagement?.edits).toEqual([ + { type: 'clear_thinking_20251015', keep: 'all' }, + ]); + }); +}); diff --git a/packages/agent-core-v2/test/llmProtocol/errors.test.ts b/packages/agent-core-v2/test/llmProtocol/errors.test.ts new file mode 100644 index 000000000..34e132f1f --- /dev/null +++ b/packages/agent-core-v2/test/llmProtocol/errors.test.ts @@ -0,0 +1,457 @@ +import { + APIConnectionError, + APIContextOverflowError, + APIEmptyResponseError, + APIProviderRateLimitError, + APIStatusError, + APITimeoutError, + ChatProviderError, + isProviderRateLimitError, + isRecoverableRequestStructureError, + isRetryableGenerateError, + isToolExchangeAdjacencyError, + normalizeAPIStatusError, +} from '#/app/llmProtocol/errors'; +import { describe, expect, it } from 'vitest'; + +describe('ChatProviderError', () => { + it('is an instance of Error', () => { + const err = new ChatProviderError('base error'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err.message).toBe('base error'); + expect(err.name).toBe('ChatProviderError'); + }); +}); + +describe('APIConnectionError', () => { + it('extends ChatProviderError', () => { + const err = new APIConnectionError('connection refused'); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('APIConnectionError'); + expect(err.message).toBe('connection refused'); + }); +}); + +describe('APITimeoutError', () => { + it('extends ChatProviderError', () => { + const err = new APITimeoutError('request timed out after 30s'); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('APITimeoutError'); + expect(err.message).toBe('request timed out after 30s'); + }); +}); + +describe('APIStatusError', () => { + it('extends ChatProviderError and stores status code', () => { + const err = new APIStatusError(429, 'rate limited', 'req-abc'); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('APIStatusError'); + expect(err.message).toBe('rate limited'); + expect(err.statusCode).toBe(429); + expect(err.requestId).toBe('req-abc'); + }); + + it('accepts null requestId', () => { + const err = new APIStatusError(500, 'server error', null); + expect(err.statusCode).toBe(500); + expect(err.requestId).toBeNull(); + }); + + it('defaults requestId to null when omitted', () => { + const err = new APIStatusError(502, 'bad gateway'); + expect(err.statusCode).toBe(502); + expect(err.requestId).toBeNull(); + }); +}); + +describe('APIEmptyResponseError', () => { + it('extends ChatProviderError', () => { + const err = new APIEmptyResponseError('empty response'); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('APIEmptyResponseError'); + expect(err.message).toBe('empty response'); + expect(err.finishReason).toBeNull(); + expect(err.rawFinishReason).toBeNull(); + }); + + it('preserves provider finish reason details', () => { + const err = new APIEmptyResponseError('empty response', { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }); + + expect(err.finishReason).toBe('filtered'); + expect(err.rawFinishReason).toBe('content_filter'); + }); +}); + +describe('APIContextOverflowError', () => { + it('extends APIStatusError and preserves HTTP details', () => { + const err = new APIContextOverflowError(400, 'Context length exceeded', 'req-context'); + expect(err).toBeInstanceOf(APIStatusError); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err.name).toBe('APIContextOverflowError'); + expect(err.statusCode).toBe(400); + expect(err.requestId).toBe('req-context'); + }); +}); + +describe('APIProviderRateLimitError', () => { + it('extends APIStatusError and preserves HTTP details', () => { + const err = new APIProviderRateLimitError('Rate limited', 'req-rate'); + expect(err).toBeInstanceOf(APIStatusError); + expect(err).toBeInstanceOf(ChatProviderError); + expect(err.name).toBe('APIProviderRateLimitError'); + expect(err.statusCode).toBe(429); + expect(err.requestId).toBe('req-rate'); + }); +}); + +describe('isRetryableGenerateError', () => { + it('matches transient provider errors and empty generate responses', () => { + expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true); + expect(isRetryableGenerateError(new APITimeoutError('timeout'))).toBe(true); + expect(isRetryableGenerateError(new APIEmptyResponseError('empty'))).toBe(true); + }); + + it.each([429, 500, 502, 503, 504])('treats HTTP %i as retryable', (statusCode) => { + expect(isRetryableGenerateError(new APIStatusError(statusCode, 'retryable'))).toBe(true); + }); + + it.each([400, 401, 403, 404, 422])('treats HTTP %i as non-retryable', (statusCode) => { + expect(isRetryableGenerateError(new APIStatusError(statusCode, 'non-retryable'))).toBe(false); + }); + + it('does not retry context overflow or unknown errors', () => { + expect( + isRetryableGenerateError(new APIContextOverflowError(400, 'Context length exceeded')), + ).toBe(false); + expect(isRetryableGenerateError(new Error('boom'))).toBe(false); + expect(isRetryableGenerateError('boom')).toBe(false); + }); +}); + +describe('error hierarchy instanceof checks', () => { + it('all error types are instanceof ChatProviderError', () => { + const errors = [ + new APIConnectionError('conn'), + new APITimeoutError('timeout'), + new APIStatusError(400, 'status', null), + new APIContextOverflowError(400, 'context length exceeded'), + new APIEmptyResponseError('empty'), + ]; + + for (const err of errors) { + expect(err).toBeInstanceOf(ChatProviderError); + } + }); + + it('specific types are distinguishable', () => { + const connErr = new APIConnectionError('conn'); + const statusErr = new APIStatusError(400, 'status', null); + + expect(connErr).not.toBeInstanceOf(APIStatusError); + expect(statusErr).not.toBeInstanceOf(APIConnectionError); + }); + + it('can catch with ChatProviderError and inspect subtype', () => { + const err: ChatProviderError = new APIStatusError(404, 'not found', 'req-123'); + + if (err instanceof APIStatusError) { + expect(err.statusCode).toBe(404); + expect(err.requestId).toBe('req-123'); + } else { + expect.unreachable('Expected APIStatusError'); + } + }); +}); + +describe('normalizeAPIStatusError', () => { + it('normalizes HTTP 429 to APIProviderRateLimitError', () => { + const error = normalizeAPIStatusError(429, 'Too many requests', 'req-rate'); + expect(error).toBeInstanceOf(APIProviderRateLimitError); + expect(error.statusCode).toBe(429); + expect(error.requestId).toBe('req-rate'); + }); + + it.each([ + [400, 'Context length exceeded'], + [400, 'Exceeded max tokens'], + [413, 'Context length exceeded'], + [422, 'Maximum context window exceeded'], + [400, 'context_length_exceeded'], + [422, 'Too many tokens in prompt'], + [400, 'prompt is too long: 210000 tokens exceeds the maximum'], + [400, 'input token count 131072 exceeds the maximum number of tokens allowed'], + [400, 'Invalid request: Your request exceeded model token limit: 262144 (requested: 274613)'], + ])('normalizes %i "%s" to APIContextOverflowError', (statusCode, message) => { + const error = normalizeAPIStatusError(statusCode, message, 'req-context'); + expect(error).toBeInstanceOf(APIContextOverflowError); + expect(error.statusCode).toBe(statusCode); + expect(error.requestId).toBe('req-context'); + }); + + it.each([ + [401, 'Context length exceeded'], + [500, 'Context length exceeded'], + [400, 'Bad request'], + [422, 'Invalid tool schema'], + [400, 'max_tokens must be less than or equal to 4096'], + [422, 'max_output_tokens must not exceed 8192'], + [400, 'max tokens must not exceed the configured output limit'], + ])('keeps %i "%s" as APIStatusError', (statusCode, message) => { + const error = normalizeAPIStatusError(statusCode, message); + expect(error).toBeInstanceOf(APIStatusError); + expect(error).not.toBeInstanceOf(APIContextOverflowError); + }); +}); + +describe('isToolExchangeAdjacencyError', () => { + // The exact Anthropic message observed in the field when a tool_use was not + // immediately followed by its tool_result. + const ANTHROPIC_MISSING_RESULT = + 'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: ' + + 'toolu_01MWFhDRqdbB4nzCJNuWYiun. Each `tool_use` block must have a corresponding ' + + '`tool_result` block in the next message.'; + + it('matches the missing-tool_result 400', () => { + expect(isToolExchangeAdjacencyError(new APIStatusError(400, ANTHROPIC_MISSING_RESULT))).toBe( + true, + ); + }); + + it('matches the reverse unexpected-tool_result 400', () => { + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + 'messages.5: `tool_result` block(s) provided when previous message does not ' + + 'contain any `tool_use` blocks', + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError(new APIStatusError(400, 'unexpected `tool_result` block')), + ).toBe(true); + }); + + it('also matches a 422 with the same shape', () => { + expect(isToolExchangeAdjacencyError(new APIStatusError(422, ANTHROPIC_MISSING_RESULT))).toBe( + true, + ); + }); + + // The exact OpenAI-compatible (Moonshot / Kimi) message observed in the field + // when a `tool` message's `tool_call_id` has no matching `tool_calls` entry in + // the preceding assistant message. The doubled space is verbatim from the + // provider. + const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found'; + + it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { + expect( + isToolExchangeAdjacencyError(new APIStatusError(400, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), + ).toBe(true); + expect( + isToolExchangeAdjacencyError(new APIStatusError(400, "tool_call_id 'call_abc123' is not found")), + ).toBe(true); + }); + + it('also matches a 422 tool_call_id-not-found', () => { + expect( + isToolExchangeAdjacencyError(new APIStatusError(422, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), + ).toBe(true); + }); + + // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the + // orphan-`tool`-result case as a `role 'tool'` message that has no preceding + // assistant `tool_calls`. Observed verbatim in the field (see zed #41531, + // llama_index #13715). Quote style varies by provider (straight or backtick). + it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => { + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + 'Role `tool` must be a response to a preceding message with `tool_calls`', + ), + ), + ).toBe(true); + }); + + // The mirror-image OpenAI-compatible rejection: an assistant `tool_calls` + // message with no following `tool` results. OpenAI/Portkey (#6621, error + // 10067) spell it out; Qwen/DashScope (#454) uses double quotes; some + // providers emit the terse "(insufficient tool messages following ...)". + it('matches the assistant-tool_calls-without-response 400', () => { + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + + "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + 'An assistant message with "tool_calls" must be followed by tool messages responding to each ' + + '"tool_call_id". The following tool_call_ids did not have response messages: message[322].role', + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError(400, '(insufficient tool messages following tool_calls message)'), + ), + ).toBe(true); + }); + + it('does not match a context-overflow 400 or unrelated errors', () => { + expect( + isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')), + ).toBe(false); + expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false); + // A bare "not found" without a tool_call_id anchor must not match, so an + // unrelated 404-style body cannot trip the tool-exchange recovery. + expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false); + // A model-availability 400 (observed alongside this family in the field) is a + // config error, not a tool-exchange defect — strict resend must not fire. + expect( + isToolExchangeAdjacencyError( + new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'), + ), + ).toBe(false); + expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe( + false, + ); + expect(isToolExchangeAdjacencyError(new Error(ANTHROPIC_MISSING_RESULT))).toBe(false); + expect(isToolExchangeAdjacencyError('boom')).toBe(false); + }); +}); + +describe('isRecoverableRequestStructureError', () => { + it('matches the whole tool_use/tool_result adjacency family', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError(400, '`tool_use` ids were found without `tool_result` blocks'), + ), + ).toBe(true); + }); + + it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { + expect( + isRecoverableRequestStructureError(new APIStatusError(400, '400 tool_call_id is not found')), + ).toBe(true); + }); + + it('matches the OpenAI-compatible role-tool / assistant-tool_calls pairing 400s', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError( + 400, + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", + ), + ), + ).toBe(true); + expect( + isRecoverableRequestStructureError( + new APIStatusError( + 400, + "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + + "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", + ), + ), + ).toBe(true); + }); + + it('matches the Anthropic duplicate tool_use id rejection', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError(400, 'messages: `tool_use` ids must be unique'), + ), + ).toBe(true); + }); + + it('matches empty / whitespace-only text content rejections', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError(400, 'messages: text content blocks must be non-empty'), + ), + ).toBe(true); + expect( + isRecoverableRequestStructureError( + new APIStatusError(400, 'text content blocks must contain non-whitespace text'), + ), + ).toBe(true); + }); + + it('matches first-message-must-be-user and role-alternation rejections', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError(400, 'messages: first message must use the "user" role'), + ), + ).toBe(true); + expect( + isRecoverableRequestStructureError( + new APIStatusError( + 400, + 'messages: roles must alternate between "user" and "assistant", but found multiple "user" roles in a row', + ), + ), + ).toBe(true); + }); + + it('does not match context overflow, auth, or non-status errors', () => { + expect( + isRecoverableRequestStructureError(new APIContextOverflowError(400, 'context length exceeded')), + ).toBe(false); + expect(isRecoverableRequestStructureError(new APIStatusError(401, 'unauthorized'))).toBe(false); + expect(isRecoverableRequestStructureError(new APIStatusError(400, 'Bad request'))).toBe(false); + expect(isRecoverableRequestStructureError(new Error('roles must alternate'))).toBe(false); + }); +}); + +describe('isProviderRateLimitError', () => { + it('matches explicit HTTP 429 status errors', () => { + expect(isProviderRateLimitError(new APIProviderRateLimitError('rate limited'))).toBe(true); + expect(isProviderRateLimitError(new APIStatusError(429, 'rate limited'))).toBe(true); + expect(isProviderRateLimitError({ response: { status: 429 } })).toBe(true); + expect(isProviderRateLimitError({ statusCode: 503, message: 'rate limit' })).toBe(false); + }); + + it('matches wrapped provider rate-limit messages without status metadata', () => { + expect( + isProviderRateLimitError( + new Error( + 'APIStatusError: 429 request id: req-429, request reached user+model max RPM: 50', + ), + ), + ).toBe(true); + expect( + isProviderRateLimitError( + "[provider.api_error] We're receiving too many requests at the moment. Please wait.", + ), + ).toBe(true); + expect(isProviderRateLimitError(new Error('[provider.rate_limit] slow down'))).toBe(true); + }); + + it('does not match non-rate-limit provider errors', () => { + expect(isProviderRateLimitError(new APIStatusError(401, 'unauthorized'))).toBe(false); + expect(isProviderRateLimitError('APIStatusError: 401 unauthorized')).toBe(false); + expect(isProviderRateLimitError(new Error('context length exceeded'))).toBe(false); + }); +}); diff --git a/packages/agent-core-v2/test/llmProtocol/select-tools.test.ts b/packages/agent-core-v2/test/llmProtocol/select-tools.test.ts new file mode 100644 index 000000000..4877aab22 --- /dev/null +++ b/packages/agent-core-v2/test/llmProtocol/select-tools.test.ts @@ -0,0 +1,353 @@ +/** + * select_tools progressive disclosure — kosong-side contract tests. + * + * Covers the three primitives this package contributes: + * - `Message.tools` serialization on the Kimi wire (`messages[].tools`, + * `{type:'function', function:{...}}` wrapping, no `content`, schema + * normalization and the `$` builtin branch shared with top-level tools); + * - `Tool.deferred` stripping in `generate()` (single strip point for every + * provider call — the marker itself must never reach the wire); + * - the `select_tools` capability bit (unknown/default-off semantics). + */ + +import { UNKNOWN_CAPABILITY, isUnknownCapability } from '#/app/llmProtocol/capability'; +import { catalogModelToCapability } from '#/app/llmProtocol/catalog'; +import { generate } from '#/app/llmProtocol/generate'; +import { isToolDeclarationOnlyMessage } from '#/app/llmProtocol/message'; +import type { Message, StreamedMessagePart } from '#/app/llmProtocol/message'; +import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; +import { messagesToGoogleGenAIContents } from '#/app/llmProtocol/providers/google-genai'; +import { KimiChatProvider } from '#/app/llmProtocol/providers/kimi'; +import { OpenAILegacyChatProvider } from '#/app/llmProtocol/providers/openai-legacy'; +import { OpenAIResponsesChatProvider } from '#/app/llmProtocol/providers/openai-responses'; +import type { ChatProvider, StreamedMessage, ThinkingEffort } from '#/app/llmProtocol/provider'; +import type { Tool } from '#/app/llmProtocol/tool'; +import { describe, expect, it, vi } from 'vitest'; + +const ADD_TOOL: Tool = { + name: 'add', + description: 'Add two integers.', + parameters: { + type: 'object', + properties: { + a: { type: 'integer', description: 'First number' }, + b: { type: 'integer', description: 'Second number' }, + }, + required: ['a', 'b'], + }, +}; + +const BUILTIN_TOOL: Tool = { + name: '$web_search', + description: 'Search the web', + parameters: { type: 'object', properties: {} }, +}; + +function makeChatCompletionResponse() { + return { + id: 'chatcmpl-test123', + object: 'chat.completion', + created: 1234567890, + model: 'kimi-test', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; +} + +async function captureRequestBody( + tools: Tool[], + history: Message[], +): Promise> { + const provider = new KimiChatProvider({ + model: 'kimi-test', + apiKey: 'test-key', + stream: false, + }); + let capturedBody: Record | undefined; + (provider as any)._client.chat.completions.create = vi + .fn() + .mockImplementation((params: unknown) => { + capturedBody = params as Record; + return Promise.resolve(makeChatCompletionResponse()); + }); + const stream = await provider.generate('system prompt', tools, history); + for await (const part of stream) { + void part; + } + if (capturedBody === undefined) { + throw new Error('Expected provider.generate() to call chat.completions.create'); + } + return capturedBody; +} + +describe('Kimi messages[].tools serialization', () => { + it('serializes a system message carrying tools with function wrapping and no content', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'system', content: [], toolCalls: [], tools: [ADD_TOOL] }, + ]; + const body = await captureRequestBody([], history); + const messages = body['messages'] as Array>; + // [system prompt, user, system+tools] + expect(messages).toHaveLength(3); + const toolsMessage = messages[2]!; + expect(toolsMessage['role']).toBe('system'); + expect('content' in toolsMessage).toBe(false); + expect(toolsMessage['tools']).toEqual([ + { + type: 'function', + function: { + name: 'add', + description: 'Add two integers.', + parameters: ADD_TOOL.parameters, + }, + }, + ]); + }); + + it('routes $-prefixed names through the builtin_function branch, same as top-level tools', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { role: 'system', content: [], toolCalls: [], tools: [BUILTIN_TOOL] }, + ]; + const body = await captureRequestBody([], history); + const messages = body['messages'] as Array>; + expect(messages[2]!['tools']).toEqual([ + { type: 'builtin_function', function: { name: '$web_search' } }, + ]); + }); + + it('leaves messages without tools untouched (no tools key)', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ]; + const body = await captureRequestBody([ADD_TOOL], history); + const messages = body['messages'] as Array>; + for (const message of messages) { + expect('tools' in message).toBe(false); + } + // Top-level tools[] unchanged by the feature. + expect(body['tools']).toEqual([ + { + type: 'function', + function: { + name: 'add', + description: 'Add two integers.', + parameters: ADD_TOOL.parameters, + }, + }, + ]); + }); + + it('does not serialize the deferred marker even if a marked tool reaches convertMessage', async () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + { + role: 'system', + content: [], + toolCalls: [], + tools: [{ ...ADD_TOOL, deferred: true }], + }, + ]; + const body = await captureRequestBody([], history); + const messages = body['messages'] as Array>; + const serialized = JSON.stringify(messages[2]!['tools']); + expect(serialized).not.toContain('deferred'); + }); +}); + +describe('generate() deferred tool stripping', () => { + function createCapturingProvider(): { provider: ChatProvider; seenTools: () => Tool[] } { + let captured: Tool[] = []; + const stream: StreamedMessage = { + id: null, + usage: null, + finishReason: 'completed', + rawFinishReason: 'stop', + async *[Symbol.asyncIterator](): AsyncIterator { + yield { type: 'text', text: 'ok' }; + }, + }; + const provider: ChatProvider = { + name: 'mock', + modelName: 'mock-model', + thinkingEffort: null as ThinkingEffort | null, + generate: async (_systemPrompt, tools, _history) => { + captured = tools; + return stream; + }, + withThinking(_effort: ThinkingEffort): ChatProvider { + return this; + }, + }; + return { provider, seenTools: () => captured }; + } + + it('strips deferred tools before the provider builds the request', async () => { + const { provider, seenTools } = createCapturingProvider(); + await generate(provider, 'sys', [ADD_TOOL, { ...BUILTIN_TOOL, deferred: true }], [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ]); + expect(seenTools()).toEqual([ADD_TOOL]); + }); + + it('passes the identical array through when nothing is deferred', async () => { + const { provider, seenTools } = createCapturingProvider(); + const tools = [ADD_TOOL, BUILTIN_TOOL]; + await generate(provider, 'sys', tools, [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ]); + expect(seenTools()).toBe(tools); + }); +}); + +describe('providers without message-level tool declarations', () => { + const TOOLS_ONLY_MESSAGE: Message = { + role: 'system', + content: [], + toolCalls: [], + tools: [ADD_TOOL], + }; + const HISTORY: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + TOOLS_ONLY_MESSAGE, + ]; + + it('classifies tool-declaration-only messages', () => { + expect(isToolDeclarationOnlyMessage(TOOLS_ONLY_MESSAGE)).toBe(true); + expect(isToolDeclarationOnlyMessage(HISTORY[0]!)).toBe(false); + // A message that also carries content is NOT skipped wholesale (only the + // tools field stays off the wire via explicit field construction). + expect( + isToolDeclarationOnlyMessage({ + ...TOOLS_ONLY_MESSAGE, + content: [{ type: 'text', text: 'x' }], + }), + ).toBe(false); + }); + + it('anthropic skips the message instead of emitting a husk', async () => { + const provider = new AnthropicChatProvider({ model: 'k25', apiKey: 'test-key', stream: false }); + let captured: Record | undefined; + (provider as any)._client.messages.create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + id: 'msg_test_123', + type: 'message', + role: 'assistant', + model: 'k25', + content: [{ type: 'text', text: 'Hello' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + const stream = await provider.generate('sys', [], HISTORY); + for await (const part of stream) void part; + expect(JSON.stringify(captured!['messages'])).not.toContain(''); + expect(captured!['messages'] as unknown[]).toHaveLength(1); + }); + + it('openai chat completions skips the message instead of sending a content-free system entry', async () => { + const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', apiKey: 'test-key', stream: false }); + let captured: Record | undefined; + (provider as any)._client.chat.completions.create = vi + .fn() + .mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + id: 'chatcmpl-test123', + object: 'chat.completion', + created: 1234567890, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + }); + const stream = await provider.generate('sys', [], HISTORY); + for await (const part of stream) void part; + const messages = captured!['messages'] as Array>; + // [system prompt, user] — no content-free leftover entry. + expect(messages).toHaveLength(2); + for (const message of messages) { + expect(message['content']).toBeDefined(); + } + }); + + it('openai responses skips the message', async () => { + const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'test-key' }); + (provider as any)._stream = false; + let captured: Record | undefined; + ((provider as any)._client.responses as Record)['create'] = vi + .fn() + .mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + id: 'resp_test123', + object: 'response', + created_at: 1234567890, + status: 'completed', + model: 'gpt-4.1', + output: [ + { + type: 'message', + id: 'msg_test', + role: 'assistant', + content: [{ type: 'output_text', text: 'Hello', annotations: [] }], + }, + ], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }); + }); + const stream = await provider.generate('sys', [], HISTORY); + for await (const part of stream) void part; + // The tools-only message contributes no input item at all. + expect(captured!['input'] as unknown[]).toHaveLength(1); + expect(JSON.stringify(captured!['input'])).not.toContain('"tools"'); + }); + + it('google genai skips the message explicitly (not just via the empty-text coincidence)', () => { + const contents = messagesToGoogleGenAIContents(HISTORY); + expect(contents).toHaveLength(1); + expect(JSON.stringify(contents)).not.toContain(''); + }); +}); + +describe('select_tools capability bit', () => { + it('defaults to false on UNKNOWN_CAPABILITY', () => { + expect(UNKNOWN_CAPABILITY.select_tools).toBe(false); + }); + + it('a capability that only has select_tools is not "unknown"', () => { + expect( + isUnknownCapability({ + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: false, + max_context_tokens: 0, + select_tools: true, + }), + ).toBe(false); + }); + + it('catalog entries map select_tools and default it to false', () => { + const base = { id: 'm', limit: { context: 1000 } }; + expect(catalogModelToCapability(base)?.capability.select_tools).toBe(false); + expect( + catalogModelToCapability({ ...base, select_tools: true })?.capability.select_tools, + ).toBe(true); + }); +}); diff --git a/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts new file mode 100644 index 000000000..0cb671c6f --- /dev/null +++ b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts @@ -0,0 +1,167 @@ +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextMemoryService } from '#/agent/contextMemory'; +import { IAgentContextProjectorService } from '#/agent/contextProjector'; +import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService'; +import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; +import { IAgentContextSizeService } from '#/agent/contextSize'; +import { IAgentProfileService } from '#/agent/profile'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry'; +import { IAgentUsageService } from '#/agent/usage'; +import { IConfigService } from '#/app/config'; +import { APIStatusError, emptyUsage, type Message, type ModelCapability } from '#/app/llmProtocol'; +import type { Model } from '#/app/model'; +import { ITelemetryService } from '#/app/telemetry'; +import { ILogService } from '#/_base/log'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const capabilities: ModelCapability = { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: false, + max_context_tokens: 1000, +}; + +const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, +]; + +function createModel(calls: { value: number }): Model { + const build = (): Model => ({ + id: 'm', + name: 'wire-model', + aliases: [], + protocol: 'anthropic', + baseUrl: 'https://example.test', + headers: {}, + capabilities, + maxContextSize: 1000, + thinkingEffort: null, + alwaysThinking: false, + providerName: 'p', + authProvider: { getAuth: async () => undefined }, + withThinking: () => build(), + withMaxCompletionTokens: () => build(), + withGenerationKwargs: () => build(), + withProviderOptions: () => build(), + withThinkingKeep: () => build(), + request: async function* () { + calls.value += 1; + if (calls.value === 1) { + throw new APIStatusError(400, 'messages: `tool_use` ids must be unique'); + } + yield { + type: 'finish', + message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], toolCalls: [] }, + providerFinishReason: 'completed', + rawFinishReason: 'stop', + id: 'resp-1', + }; + }, + }); + return build(); +} + +let disposables: DisposableStore; + +beforeEach(() => { + disposables = new DisposableStore(); +}); + +afterEach(() => disposables.dispose()); + +function createService(model: Model, projector: { project: unknown; projectStrict: unknown }) { + const ix = disposables.add(new TestInstantiationService()); + const profile = { + resolveModelContext: () => ({ + modelAlias: 'm', + modelCapabilities: capabilities, + maxOutputSize: undefined, + alwaysThinking: undefined, + thinkingLevel: 'off', + reservedContextSize: undefined, + compactionTriggerRatio: undefined, + }), + getProvider: () => model, + getSystemPrompt: () => 'system', + data: () => ({ modelAlias: 'm' }), + isToolActive: () => true, + }; + const contextSize = { + getStatus: () => ({ contextTokens: 0 }), + measured: () => undefined, + }; + const usage = { record: () => undefined }; + const context = { get: () => history }; + const tools = { list: () => [] }; + const config = { get: () => undefined }; + const log = { info: () => undefined, warn: () => undefined }; + const telemetry = { track: () => undefined }; + + ix.stub(IAgentContextMemoryService, context); + ix.stub(IAgentContextProjectorService, projector); + ix.stub(IAgentContextSizeService, contextSize); + ix.stub(IAgentToolRegistryService, tools); + ix.stub(IAgentProfileService, profile); + ix.stub(IAgentUsageService, usage); + ix.stub(IConfigService, config); + ix.stub(ILogService, log); + ix.stub(ITelemetryService, telemetry); + ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService)); + + return ix.get(IAgentLLMRequesterService); +} + +describe('AgentLLMRequesterService strict resend', () => { + it('resends once with strict projection after a recoverable structural 400', async () => { + const calls = { value: 0 }; + let projectCalls = 0; + let strictCalls = 0; + const service = createService(createModel(calls), { + project: (messages: readonly Message[]) => { + projectCalls += 1; + return messages; + }, + projectStrict: (messages: readonly Message[]) => { + strictCalls += 1; + return messages; + }, + }); + + const result = await service.request({ retry: { maxAttempts: 1 } }); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(result.usage).toEqual(emptyUsage()); + expect(calls.value).toBe(2); + expect(projectCalls).toBe(1); + expect(strictCalls).toBe(1); + }); + + it('does not resend for non-recoverable errors', async () => { + const model = createModel({ value: 0 }); + Object.defineProperty(model, 'request', { + value: async function* () { + throw new APIStatusError(401, 'unauthorized'); + }, + }); + Object.defineProperty(model, 'withMaxCompletionTokens', { + value: () => model, + }); + let strictCalls = 0; + const service = createService(model, { + project: (messages: readonly Message[]) => messages, + projectStrict: (messages: readonly Message[]) => { + strictCalls += 1; + return messages; + }, + }); + + await expect(service.request({ retry: { maxAttempts: 1 } })).rejects.toMatchObject({ + statusCode: 401, + }); + expect(strictCalls).toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index 39dcffc25..beefba2dd 100644 --- a/packages/agent-core-v2/test/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -127,6 +127,18 @@ describe('ModelResolverService', () => { expect(auth).toEqual({ apiKey: 'sk-model' }); }); + it('forwards declared select_tools capability to the resolved model', () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' }; + models['m'] = { + provider: 'p', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['select_tools'], + }; + + expect(ix.get(IModelResolver).resolve('m').capabilities.select_tools).toBe(true); + }); + it('returns an OAuth access token as ProviderRequestAuth.apiKey', async () => { providers['p'] = { type: 'kimi', @@ -600,6 +612,7 @@ describe('ModelResolverService', () => { thinking: true, tool_use: false, max_context_tokens: 1000, + select_tools: false, }); }); @@ -614,6 +627,7 @@ describe('ModelResolverService', () => { thinking: false, tool_use: true, max_context_tokens: 128000, + select_tools: false, }); }); }); diff --git a/packages/agent-core-v2/test/profile/profile-wire.test.ts b/packages/agent-core-v2/test/profile/profile-wire.test.ts index 34616cb42..435c916d2 100644 --- a/packages/agent-core-v2/test/profile/profile-wire.test.ts +++ b/packages/agent-core-v2/test/profile/profile-wire.test.ts @@ -136,6 +136,7 @@ function createRecordingModel( thinkingEfforts: ThinkingEffort[], providerOptions: unknown[] = [], protocol: Model['protocol'] = 'kimi', + thinkingKeeps: string[] = [], ): Model { const build = (thinkingEffort: ThinkingEffort | null): Model => ({ id: 'kimi-code', @@ -170,6 +171,10 @@ function createRecordingModel( providerOptions.push(options); return build(thinkingEffort); }, + withThinkingKeep: (keep) => { + thinkingKeeps.push(keep); + return build(thinkingEffort); + }, request: async function* () { return; }, @@ -291,6 +296,103 @@ describe('AgentProfileService (wire-backed config.update)', () => { ]); }); + it('applies thinking.keep model override on the Anthropic path', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + const providerOptions: unknown[] = []; + const thinkingKeeps: string[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => + createRecordingModel( + generationKwargs, + thinkingEfforts, + providerOptions, + 'anthropic', + thinkingKeeps, + ), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep-anthropic'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' }; + + host.svc.update({ modelAlias: 'claude-code', thinkingLevel: 'high' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('high'); + expect(thinkingEfforts).toEqual(['high']); + expect(thinkingKeeps).toEqual(['all']); + expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]); + expect(generationKwargs).toEqual([{ temperature: 0.3 }]); + }); + + it('defaults thinking.keep to "all" when thinking is enabled on Kimi', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep-default'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + host.svc.resolveModel(); + + expect(generationKwargs).toEqual([ + { prompt_cache_key: 'session-test', extra_body: { thinking: { keep: 'all' } } }, + ]); + }); + + it('treats an off env thinking.keep override as disabled on Kimi', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep-env-off'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['modelOverrides'] = { thinkingKeep: 'off' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + host.svc.resolveModel(); + + expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]); + }); + + it('applies config thinking.keep on the Anthropic path', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + const providerOptions: unknown[] = []; + const thinkingKeeps: string[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => + createRecordingModel( + generationKwargs, + thinkingEfforts, + providerOptions, + 'anthropic', + thinkingKeeps, + ), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep-anthropic-config'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { keep: 'config-keep' }; + + host.svc.update({ modelAlias: 'claude-code', thinkingLevel: 'high' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('high'); + expect(thinkingKeeps).toEqual(['config-keep']); + expect(generationKwargs).toEqual([]); + }); + it('does not apply thinking.keep model override when thinking is off', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = []; @@ -325,7 +427,9 @@ describe('AgentProfileService (wire-backed config.update)', () => { host.svc.resolveModel(); expect(thinkingEfforts).toEqual(['high']); - expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]); + expect(generationKwargs).toEqual([ + { prompt_cache_key: 'session-test', extra_body: { thinking: { keep: 'all' } } }, + ]); }); it('does not apply the Kimi prompt cache hint to other protocols', () => { diff --git a/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts index fd8715a26..f5be6ed65 100644 --- a/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts @@ -94,4 +94,15 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_project')).toBe('my-project'); expect(Reflect.get(provider, '_location')).toBe('us-central1'); }); + + it('maps baseUrl into Google GenAI provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'google-genai', + baseUrl: 'https://generativelanguage.example.com', + modelName: 'gemini-1.5-pro', + apiKey: 'test-key', + }); + + expect(Reflect.get(provider, '_baseUrl')).toBe('https://generativelanguage.example.com'); + }); }); From 5590da391b2aa229c1e93f525d2c8246abcb9c5c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 7 Jul 2026 16:16:40 +0800 Subject: [PATCH 2/2] fix(agent-core-v2): align strict projection and kimi thinking --- .../contextProjectorService.ts | 18 +++++- .../src/agent/profile/profileService.ts | 24 +++++++- .../src/app/llmProtocol/capability.ts | 6 -- .../src/app/llmProtocol/catalog.ts | 1 - .../src/app/llmProtocol/errors.ts | 60 ------------------- .../src/app/llmProtocol/generate.ts | 5 -- .../src/app/llmProtocol/message.ts | 20 +------ .../src/app/llmProtocol/messageHelpers.ts | 1 - .../src/app/llmProtocol/provider.ts | 24 +------- .../app/llmProtocol/providers/anthropic.ts | 50 +--------------- .../app/llmProtocol/providers/google-genai.ts | 50 +++------------- .../src/app/llmProtocol/providers/kimi.ts | 26 +------- .../providers/merge-user-messages.ts | 49 ++------------- .../llmProtocol/providers/openai-common.ts | 3 - .../llmProtocol/providers/openai-legacy.ts | 3 - .../llmProtocol/providers/openai-responses.ts | 3 - .../agent-core-v2/src/app/llmProtocol/tool.ts | 8 --- .../src/app/model/modelInstance.ts | 1 - .../src/app/model/modelResolverService.ts | 3 - .../projector-tool-exchanges.test.ts | 18 ++++++ .../test/llmRequester/strict-resend.test.ts | 30 +++++++--- .../test/profile/profile-wire.test.ts | 23 +++++++ 22 files changed, 115 insertions(+), 311 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 9fd73fdc8..bee262ae9 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -35,7 +35,7 @@ function projectStrict(history: readonly ContextMessage[]): Message[] { function dedupeDuplicateToolCalls(messages: readonly Message[]): Message[] { const seenToolCallIds = new Set(); - const seenToolResultIds = new Set(); + const keptToolResultIndexes = new Map(); const out: Message[] = []; for (const message of messages) { if (message.role === 'assistant' && message.toolCalls.length > 0) { @@ -52,8 +52,14 @@ function dedupeDuplicateToolCalls(messages: readonly Message[]): Message[] { continue; } if (message.role === 'tool' && message.toolCallId !== undefined) { - if (seenToolResultIds.has(message.toolCallId)) continue; - seenToolResultIds.add(message.toolCallId); + const previousIndex = keptToolResultIndexes.get(message.toolCallId); + if (previousIndex !== undefined) { + if (isInterruptedToolResult(out[previousIndex]) && !isInterruptedToolResult(message)) { + out[previousIndex] = message; + } + continue; + } + keptToolResultIndexes.set(message.toolCallId, out.length); } out.push(message); } @@ -232,6 +238,12 @@ function createInterruptedToolResult(toolCallId: string): Message { }; } +function isInterruptedToolResult(message: Message | undefined): boolean { + if (message?.role !== 'tool') return false; + const [part] = message.content; + return part?.type === 'text' && part.text === TOOL_INTERRUPTED_TEXT; +} + function isBlankText(part: ContentPart): boolean { return part.type === 'text' && part.text.trim().length === 0; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 7eaf776a1..e261f1d3a 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -282,6 +282,11 @@ export class AgentProfileService implements IAgentProfileService { if (this.modelAlias === undefined) return undefined; let model: Model = this.modelFactory.resolve(this.modelAlias); const thinkingLevel = this.thinkingLevel; + const thinkingConfig = this.config.get(THINKING_SECTION); + const forcedKimiThinkingEffort = + model.protocol === 'kimi' && thinkingLevel !== 'off' + ? normalizeKimiThinkingEffort(thinkingConfig?.effort) + : undefined; const kwargs: GenerationKwargs = {}; if (model.protocol === 'kimi') { kwargs.prompt_cache_key = this.sessionContext.sessionId; @@ -297,18 +302,26 @@ export class AgentProfileService implements IAgentProfileService { } const keep = resolveThinkingKeep( overrides?.thinkingKeep, - this.config.get(THINKING_SECTION)?.keep, + thinkingConfig?.keep, thinkingLevel, ); if (keep !== undefined) { - if (model.protocol === 'kimi') { + if (model.protocol === 'kimi' && forcedKimiThinkingEffort === undefined) { kwargs.extra_body = { thinking: { keep } }; } else if (model.protocol === 'anthropic') { model = model.withThinkingKeep(keep); } } if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs); - model = model.withThinking(thinkingLevel); + model = model.withThinking(forcedKimiThinkingEffort ?? thinkingLevel); + if (forcedKimiThinkingEffort !== undefined) { + const thinking: { type: 'enabled'; effort: string; keep?: string } = { + type: 'enabled', + effort: forcedKimiThinkingEffort, + }; + if (keep !== undefined) thinking.keep = keep; + model = model.withGenerationKwargs({ extra_body: { thinking } }); + } return model; } @@ -516,6 +529,11 @@ function parseKeepValue(raw: string | undefined): KeepResolution { return { specified: true, value: trimmed }; } +function normalizeKimiThinkingEffort(raw: string | undefined): ThinkingEffort | undefined { + const trimmed = raw?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + function resolveThinkingKeep( envKeep: string | undefined, configKeep: string | undefined, diff --git a/packages/agent-core-v2/src/app/llmProtocol/capability.ts b/packages/agent-core-v2/src/app/llmProtocol/capability.ts index 27d004860..dd1807ffd 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/capability.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/capability.ts @@ -15,12 +15,6 @@ export interface ModelCapability { readonly thinking: boolean; readonly tool_use: boolean; readonly max_context_tokens: number; - /** - * Model accepts message-level tool declarations (`messages[].tools`), the - * primitive behind select_tools progressive disclosure. Absent means - * unsupported: only models explicitly catalogued or declared with this - * capability may ever receive a message carrying `tools`. - */ readonly select_tools?: boolean; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts index ff1fb9437..87972ac17 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/catalog.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/catalog.ts @@ -13,7 +13,6 @@ export interface CatalogModelEntry { readonly limit?: { readonly context?: number; readonly output?: number }; readonly tool_call?: boolean; readonly reasoning?: boolean; - /** Accepts message-level tool declarations (`messages[].tools`). Defaults to false. */ readonly select_tools?: boolean; readonly interleaved?: boolean | { readonly field?: string }; readonly modalities?: { diff --git a/packages/agent-core-v2/src/app/llmProtocol/errors.ts b/packages/agent-core-v2/src/app/llmProtocol/errors.ts index cd7076d83..1210bc6b0 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/errors.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/errors.ts @@ -98,23 +98,9 @@ export function isRetryableGenerateError(error: unknown): boolean { return error instanceof APIStatusError && [429, 500, 502, 503, 504].includes(error.statusCode); } -// `terminated` is the undici signature for an SSE/HTTP body stream that is -// dropped mid-flight (common with Node's native fetch on long reasoning -// streams). It surfaces as a raw `TypeError: terminated`, so it must be -// recognized here as a transport-layer connection failure. Shared by the -// Anthropic and OpenAI providers so a raw, non-SDK transport error classifies -// the same way regardless of which provider was streaming. const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; -/** - * Classify a raw (non-SDK) error message into the right transport-layer - * `ChatProviderError` subclass: a timeout becomes a retryable `APITimeoutError`, - * a dropped connection / undici `terminated` becomes a retryable - * `APIConnectionError`, and anything else stays a non-retryable base - * `ChatProviderError`. Timeout is checked first so "connection timed out" - * classifies as a timeout rather than a bare connection error. - */ export function classifyBaseApiError(message: string): ChatProviderError { if (TIMEOUT_RE.test(message)) { return new APITimeoutError(message); @@ -170,45 +156,11 @@ export function isContextOverflowStatusError(statusCode: number, message: string return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -// Strict providers reject a request whose assistant `tool_use`/`tool_calls` and -// `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing -// result, a stray result with no matching call, or a result that does not -// immediately follow its call. Anthropic phrases this in terms of -// `tool_use`/`tool_result`. OpenAI-compatible providers phrase it in terms of -// `tool_call_id` / `role 'tool'` / `tool_calls`: Moonshot / Kimi as a -// `tool_call_id` that "is not found", and OpenAI / DeepSeek / vLLM / Qwen as a -// `role 'tool'` message without a preceding `tool_calls`, or an assistant -// `tool_calls` not followed by its tool results. The validation runs before any -// generation, so the error is a non-retryable 4xx. A caller can react by -// resending a re-projected, strictly wire-compliant request rather than leaving -// the session permanently stuck. const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, /unexpected\s+`?tool_result/, - // OpenAI-compatible (Moonshot / Kimi): a `tool` message references a - // `tool_call_id` with no matching `tool_calls` entry in the preceding - // assistant message. Observed verbatim as `tool_call_id is not found` - // (doubled space). Anchored on `tool_call_id` so an unrelated "not found" - // (e.g. a 404-style body) cannot trip the recovery. /tool_call_id[\s\S]*not found/, - // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the - // same structural rejection in terms of `role 'tool'` / `tool_calls` instead - // of Anthropic's `tool_use` / `tool_result`, in two mirror-image shapes: - // - // - An orphan `tool` result whose preceding assistant carries no matching - // `tool_calls`: "messages with role 'tool' must be a response to a - // preceding message with 'tool_calls'". - // - An assistant `tool_calls` with no following `tool` results: "an - // assistant message with 'tool_calls' must be followed by tool messages - // responding to each 'tool_call_id'. the following tool_call_ids did not - // have response messages: ...", or the terse "(insufficient tool messages - // following tool_calls message)". - // - // Both are wire-structure defects the strict resend repairs (drop the orphan - // result / synthesize the missing one). Quote style around `tool`/`tool_calls` - // varies by provider (straight or backtick), so the anchors tolerate an - // optional surrounding quote char. /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, /tool_call_ids? did not have response messages/, @@ -223,24 +175,12 @@ export function isToolExchangeAdjacencyError(error: unknown): boolean { return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -// The broader family of structural request rejections a strict provider returns -// when the message array itself is malformed — tool_use/tool_result pairing, -// empty or whitespace-only text blocks, a non-user first message, or -// non-alternating roles. All are deterministic 4xx validation failures (no -// generation happened) on a history that is re-sent every turn, so the only -// recovery is to resend a re-projected, strictly wire-compliant request rather -// than leave the session permanently stuck. Context-overflow 400s are excluded — -// they are handled by compaction, not by re-projection. const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ /text content blocks must be non-empty/, /text content blocks must contain non-whitespace/, /first message must use the .*user.* role/, /roles must alternate/, /multiple .*(?:user|assistant).* roles in a row/, - // Anthropic rejects a request whose assistant messages carry two `tool_use` - // blocks with the same id: "messages: `tool_use` ids must be unique". Seen - // when a provider reused a call id (e.g. per-response counter ids) earlier - // in the session; the strict resend dedupes the ids. /tool_use[\s\S]*ids must be unique/, ] as const; diff --git a/packages/agent-core-v2/src/app/llmProtocol/generate.ts b/packages/agent-core-v2/src/app/llmProtocol/generate.ts index 5d6a90608..a1417eb71 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/generate.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/generate.ts @@ -12,7 +12,6 @@ import type { ChatProvider, FinishReason, GenerateOptions, StreamedMessage } fro import type { Tool } from './tool'; import type { TokenUsage } from './usage'; -/** Snapshot of a ToolCall excluding the internal `_streamIndex` routing field. */ type StoredToolCall = Omit; /** @@ -103,10 +102,6 @@ export async function generate( throwAbortError(); } - // Deferred tools are executable client-side but must not appear in the - // request's top-level `tools[]` (their schemas travel via message-level - // `tools` declarations; the top-level list stays byte-stable for prompt - // caching). This is the single strip point for every provider call. const wireTools = tools.some((tool) => tool.deferred === true) ? tools.filter((tool) => tool.deferred !== true) : tools; diff --git a/packages/agent-core-v2/src/app/llmProtocol/message.ts b/packages/agent-core-v2/src/app/llmProtocol/message.ts index 12db25c84..1670345db 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/message.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/message.ts @@ -102,16 +102,7 @@ export interface Message { readonly toolCallId?: string; /** When `true`, indicates the message was not fully received (e.g. stream interrupted). */ readonly partial?: boolean; - /** - * Full tool definitions carried by this message. Meaningful only on - * `role: 'system'` messages: it is the append-only primitive for loading a - * tool mid-conversation without touching the request's top-level `tools[]` - * (which must stay byte-stable to preserve the provider's prompt cache). - * Providers that support message-level tool declarations (Kimi - * `messages[].tools`) serialize it; callers must not send such a message to - * a provider without that capability. - */ - readonly tools?: readonly Tool[] | undefined; + readonly tools?: readonly Tool[]; } /** Check if a streamed part is a ContentPart (text, think, image_url, audio_url, video_url). */ @@ -122,15 +113,6 @@ export function isContentPart(part: StreamedMessagePart): part is ContentPart { ); } -/** - * True for a message whose only payload is `tools` — the dynamic tool-loading - * primitive (see {@link Message.tools}). Message-level tool declarations are a - * Kimi wire feature; every other provider must skip such a message entirely: - * their explicit field construction already keeps the `tools` field off the - * wire, but the leftover empty message would be rejected (OpenAI: system - * message without content) or serialized as a garbage `` - * turn (Anthropic/Google system-to-user wrapping). - */ export function isToolDeclarationOnlyMessage(message: Message): boolean { return ( message.tools !== undefined && diff --git a/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts b/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts index 02b9faa86..719250237 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/messageHelpers.ts @@ -3,7 +3,6 @@ * wire messages / content parts / tool calls. * * Constructors: `createAssistantMessage | createToolMessage | createUserMessage`. - * Predicates: `isContentPart | isToolCall | isToolCallPart | isToolDeclarationOnlyMessage`. * Utilities: `extractText | mergeInPlace` (in-place merge of streamed * tool-call argument deltas). * diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 4e44aac6b..bc9b0471c 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -2,29 +2,7 @@ import type { Message, StreamedMessagePart, VideoURLPart } from './message'; import type { Tool } from './tool'; import type { TokenUsage } from './usage'; -/** - * Thinking effort passed to {@link ChatProvider.withThinking}. - * - * `'off'` and `'on'` are the only reserved values: `'off'` disables thinking, - * and `'on'` is the on-signal for boolean models (models that do not declare - * `support_efforts`). Everything else is a model-declared effort (e.g. - * `"low"`, `"high"`, `"max"`) carried as an open string. The type collapses to - * `string` at runtime; it exists purely as a semantic marker that a value is - * expected to be `'off'`, `'on'`, or a model-declared effort. - * - * The model's `support_efforts` is the single source of truth for which - * efforts are valid — providers normalize any unrecognized effort by omitting - * the effort on the wire rather than rejecting it. - */ -export type ThinkingEffort = - | 'off' - | 'on' - | 'low' - | 'medium' - | 'high' - | 'xhigh' - | 'max' - | (string & {}); +export type ThinkingEffort = 'off' | 'on' | (string & {}); /** * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index c5be13d5d..cdc7abdfc 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -114,22 +114,13 @@ interface AnthropicGenerationKwargs { thinking?: MessageCreateParams['thinking'] | undefined; output_config?: MessageCreateParams['output_config'] | undefined; betaFeatures?: string[] | undefined; - contextManagement?: AnthropicContextManagement | undefined; + contextManagement?: AnthropicContextManagement; } -/** - * Anthropic beta context-management payload (`context-management-2025-06-27`). - * Only the `clear_thinking_20251015` edit is emitted today, with `keep` - * forwarded as a string (`"all"`); the `{ type, value }` turn-count form is - * not used because the shared `[thinking] keep` config is a string. - */ interface AnthropicContextManagement { edits: Array<{ type: string; keep?: unknown }>; } -// Anthropic's native effort values. `ThinkingEffort` is an open string, so after -// clamping (and ruling out 'off') we narrow to this concrete set before writing -// `output_config.effort` / computing a token budget. type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; @@ -361,9 +352,6 @@ function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): if (effort === 'max' && !adaptive) { return 'high'; } - // 'on' (boolean models) or any effort Anthropic does not recognize: fall - // back to 'high' so budgetTokensForEffort / output_config.effort never see - // an unsupported value. if ( effort !== 'low' && effort !== 'medium' && @@ -425,12 +413,6 @@ function injectCacheControlOnLastBlock(messages: MessageParam[]): void { } } -/** - * Whether a user MessageParam consists solely of `tool_result` blocks. Used to - * keep tool results bundled with each other (parallel-tool-use spec) while - * not merging a tool-result user message into an adjacent plain-text user - * message — the two carry different semantics and must stay separate. - */ function isToolResultOnly(message: MessageParam): boolean { if (message.role !== 'user') return false; const content = message.content; @@ -667,11 +649,6 @@ export function convertAnthropicError(error: unknown): ChatProviderError { if (error instanceof AnthropicError) { return new ChatProviderError(`Anthropic error: ${error.message}`); } - // Raw, non-SDK errors (e.g. undici's `TypeError: terminated` raised when a - // streaming response body is dropped mid-flight) are never wrapped by the - // Anthropic SDK during stream iteration. Route them through the shared - // transport-layer heuristic so genuine connection failures become retryable - // instead of fatal generic errors. if (error instanceof Error) { return classifyBaseApiError(error.message); } @@ -1033,22 +1010,8 @@ export class AnthropicChatProvider implements ChatProvider { ] : undefined; - // Convert messages, then merge consecutive user messages into one. Strict - // Anthropic-compatible backends reject consecutive user messages with HTTP - // 400 ("roles must alternate"), and api.anthropic.com concatenates them - // anyway — so merging is safe for native Anthropic and required for strict - // backends. Consecutive plain-text user messages arise naturally after - // compaction (kept user prompts + user-role summary + injected reminders) - // and from back-to-back system messages converted to user role above; a - // tool-result user turn followed by a text turn arises from steering after - // a tool result. The shared helper applies the asymmetric merge rule (see - // mergeConsecutiveUserMessages) so this provider and Gemini/Vertex stay in - // step. const messages = mergeConsecutiveUserMessages( normalizeToolCallIdsForProvider( - // Message-level tool declarations are a Kimi wire feature; here the - // whole message is skipped (an empty leftover would serialize as a - // garbage `` user turn). See isToolDeclarationOnlyMessage. history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), ANTHROPIC_TOOL_CALL_ID_POLICY, ).map((msg) => @@ -1310,9 +1273,6 @@ export class AnthropicChatProvider implements ChatProvider { const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA) ? current : [...current, CONTEXT_MANAGEMENT_BETA]; - // Preserve any existing context-management edits (e.g. clear_tool_uses) and - // keep clear_thinking first, as Anthropic requires when combining edits. Drop - // a previous clear_thinking edit so re-applying stays idempotent. const existingEdits = this._generationKwargs.contextManagement?.edits ?? []; const edits = [ { type: CLEAR_THINKING_EDIT, keep }, @@ -1322,14 +1282,6 @@ export class AnthropicChatProvider implements ChatProvider { contextManagement: { edits }, betaFeatures, }); - // clear_thinking_20251015 is honored only on the beta Messages API - // (client.beta.messages.create), so enabling keep forces the beta endpoint - // here even when the provider was constructed with betaApi: false. Setting - // `[thinking] keep` to an off-value (or KIMI_MODEL_THINKING_KEEP=off) is the - // escape hatch that disables keep and returns requests to the standard - // endpoint. This also routes adaptive models (whose withThinking would - // otherwise drop the interleaved-thinking beta and leave betaFeatures empty) - // onto the beta endpoint with a body `betas=[context-management-...]`. clone._betaApi = true; return clone; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index 607a470d9..da3b01618 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -76,14 +76,7 @@ function normalizeGoogleGenAIFinishReason(raw: unknown): { export interface GoogleGenAIOptions { apiKey?: string | undefined; model: string; - /** - * Override the endpoint the SDK talks to (forwarded as - * `httpOptions.baseUrl`). When unset, the SDK falls back to its default - * (`generativelanguage.googleapis.com` for Gemini, the regional - * `*-aiplatform.googleapis.com` for Vertex). Set this to route through a - * Gemini-compatible proxy/gateway. - */ - baseUrl?: string | undefined; + baseUrl?: string; vertexai?: boolean | undefined; project?: string | undefined; location?: string | undefined; @@ -93,11 +86,11 @@ export interface GoogleGenAIOptions { } export interface GoogleGenAIGenerationKwargs { - maxOutputTokens?: number | undefined; - temperature?: number | undefined; - topK?: number | undefined; - topP?: number | undefined; - thinkingConfig?: ThinkingConfig | undefined; + maxOutputTokens?: number; + temperature?: number; + topK?: number; + topP?: number; + thinkingConfig?: ThinkingConfig; [key: string]: unknown; } @@ -281,7 +274,6 @@ function messageToGoogleGenAI(message: Message): GoogleContent { }, }; - // Restore thoughtSignature if available if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) { functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string; } @@ -355,24 +347,12 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten const message = messages[i]; if (message === undefined) break; - // Message-level tool declarations are a Kimi wire feature. The system - // branch below would already drop the empty leftover via its text-length - // check, but skip explicitly so the behavior does not hinge on that - // coincidence (and covers a non-system carrier defensively). if (isToolDeclarationOnlyMessage(message)) { i += 1; continue; } if (message.role === 'system') { - // Google GenAI's `Content.role` only accepts "user" or "model", so a - // system message in the history (e.g. from session restore or - // cross-provider migration) would be rejected by the API. Preserve - // the content by wrapping it in a `` tag and attaching it as - // a user turn — mirrors the Anthropic provider's behavior. The - // dedicated top-level `systemPrompt` still flows into - // `systemInstruction` separately; only historical system messages - // come through here. const text = message.content .filter((p): p is { type: 'text'; text: string } => p.type === 'text') .map((p) => p.text) @@ -466,9 +446,6 @@ export function messagesToGoogleGenAIContents(messages: Message[]): GoogleConten i += 1; } - // Gemini/Vertex require strictly alternating user/model turns. Consecutive - // user Contents arise after compaction (`[prompts, summary, reminders]`) and - // when a user turn follows a tool result; collapse them into one user turn. return mergeConsecutiveUserMessages(contents, { isUser: (content) => content.role === 'user', isToolResultOnly: (content) => @@ -725,12 +702,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { } private _buildClient(apiKey: string | undefined): GenAIClient { - // The Google GenAI SDK reads the endpoint and headers from `httpOptions`, - // deep-merging them over its defaults: a `baseUrl` here overrides the - // default host (`generativelanguage.googleapis.com` / Vertex regional), - // and a `User-Agent` overrides the SDK default (`google-genai-sdk/ …`) - // while preserving the other default headers (`x-goog-api-client`, - // `Content-Type`). Build the object once so both can coexist. const httpOptions: { headers?: Record; baseUrl?: string } = {}; if (this._defaultHeaders !== undefined) { httpOptions.headers = this._defaultHeaders; @@ -747,7 +718,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { location: this._location, } : {}), - ...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}), + httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, }); } @@ -759,12 +730,9 @@ export class GoogleGenAIChatProvider implements ChatProvider { const thinkingConfig = this._generationKwargs.thinkingConfig; if (thinkingConfig === undefined) return null; - // For gemini-3 models that use thinkingLevel if (thinkingConfig.thinkingLevel !== undefined) { switch (thinkingConfig.thinkingLevel) { case 'MINIMAL': - // MINIMAL + suppressed thoughts is how 'off' is encoded for Gemini 3, - // which has no true "disabled" level. return thinkingConfig.includeThoughts === false ? 'off' : 'low'; case 'LOW': return 'low'; @@ -777,7 +745,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { } } - // For other models that use thinkingBudget if (thinkingConfig.thinkingBudget !== undefined) { if (thinkingConfig.thinkingBudget === 0) return 'off'; if (thinkingConfig.thinkingBudget <= 1024) return 'low'; @@ -879,9 +846,6 @@ export class GoogleGenAIChatProvider implements ChatProvider { const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (this._model.includes('gemini-3')) { - // Gemini 3 models use thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH). The SDK - // does not expose a "disabled" level, so 'off' maps to MINIMAL with - // thought output suppressed — the lowest thinking intensity available. switch (effort) { case 'off': thinkingConfig.thinkingLevel = 'MINIMAL'; diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index 819cefc63..3ba0c385f 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -46,10 +46,7 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; - /** Efforts the model advertises (e.g. ["low", "high", "max"]). When - * present and non-empty, withThinking sends the chosen effort on the wire; - * when absent/empty, only thinking.type is sent. */ - supportEfforts?: readonly string[] | undefined; + supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -70,7 +67,6 @@ export interface GenerationKwargs { presence_penalty?: number | undefined; frequency_penalty?: number | undefined; stop?: string | string[] | undefined; - reasoning_effort?: string | undefined; prompt_cache_key?: string | undefined; extra_body?: ExtraBody; } @@ -97,8 +93,7 @@ interface OpenAIMessage { tool_call_id?: string | undefined; name?: string | undefined; reasoning_content?: string | undefined; - /** Message-level tool declarations (`messages[].tools`), see convertMessage. */ - tools?: OpenAIToolParam[] | undefined; + tools?: OpenAIToolParam[]; } interface OpenAIToolCallOut { @@ -172,12 +167,6 @@ function convertMessage(message: Message): OpenAIMessage { result.reasoning_content = reasoningContent; } - // Message-level tool declarations: a system message carrying `tools` loads - // those definitions mid-conversation (`messages[].tools` in the Kimi - // contract; each entry is a full OpenAI-compatible tool param). Reusing - // convertTool keeps schema normalization and the `$` builtin_function - // branch identical to the top-level `tools[]` path. Such a message carries - // no `content` — the empty-content branch above already omits the field. if (message.tools !== undefined && message.tools.length > 0) { result.tools = message.tools.map((tool) => convertTool(tool)); } @@ -435,7 +424,6 @@ export class KimiChatProvider implements ChatProvider { const thinking = this._generationKwargs.extra_body?.thinking; if (thinking === undefined) return null; if (thinking.type === 'disabled') return 'off'; - // A model that enables thinking without an effort is treated as boolean ("on"). return thinking.effort ?? 'on'; } @@ -508,8 +496,6 @@ export class KimiChatProvider implements ChatProvider { try { const client = this._createClient(options?.auth); - // Use type assertion via unknown because we pass the Moonshot-proprietary - // `thinking` field (via extra_body) that doesn't exist in the OpenAI type definitions. options?.onRequestSent?.(); const response = (await client.chat.completions.create( createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, @@ -526,18 +512,10 @@ export class KimiChatProvider implements ChatProvider { if (effort === 'off') { thinking = { type: 'disabled' }; } else { - // Only efforts the model explicitly declares via `support_efforts` are - // sent on the wire. When `support_efforts` is absent/empty, or the - // requested effort is not declared, only thinking.type is sent. thinking = this._supportEfforts.includes(effort) ? { type: 'enabled', effort } : { type: 'enabled' }; } - // Replace extra_body.thinking wholesale so a stale `effort` from a previous - // withThinking call can never linger on a disabled or non-effort thinking - // object — but carry over a `keep` set earlier via withExtraBody (the - // KIMI_MODEL_THINKING_KEEP path applies keep after withThinking and merges - // on top, so it is unaffected either way). const oldExtra = this._generationKwargs.extra_body ?? {}; const keep = oldExtra.thinking?.keep; if (keep !== undefined) { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts index 95db1182e..6a3fa5861 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/merge-user-messages.ts @@ -1,45 +1,6 @@ -/** - * Collapses consecutive same-role "user" turns in a provider's already-converted - * wire message list into one turn. - * - * Strict providers (Anthropic, Gemini/Vertex) reject consecutive user turns with - * HTTP 400 ("roles must alternate"). Consecutive user turns arise naturally: - * - after compaction, whose shape is `[kept user prompts, user-role summary, - * injected reminders]` — all role 'user'; and - * - when a user turn (steer/injection) follows a tool result. - * - * Both only become visible once tool messages have been converted to user-role - * turns, which is why this runs at each provider's conversion boundary rather - * than in the provider-agnostic projector: the projector deliberately preserves - * message structure for lenient providers (OpenAI/Kimi) that accept — and read - * more clearly — distinct turns, while strict providers normalize for their own - * protocol here. Keeping the algorithm in one place stops a provider from - * silently omitting it (the original cause of the Gemini regression). - * - * The merge is asymmetric, keyed on whether the running turn is tool-result-only: - * - a tool-result-only running turn absorbs whatever follows — another - * tool-result-only turn (the parallel-tool-use spec requires all tool - * results answering parallel calls to share one user turn) or a following - * text turn, yielding a valid `[tool_result, …, text]` turn; - * - a text running turn absorbs only a following text turn, never a leading - * tool-result turn (a tool-result must answer the immediately preceding - * assistant tool_use, which a text turn is not — though in well-formed - * transcripts this ordering never arises). - * - * @typeParam T - the provider's wire message type (e.g. Anthropic `MessageParam` - * or Google `Content`). - * @param messages - the converted wire messages, in order. - * @param ops - provider-specific predicates and a content merger. - * @param ops.isUser - whether a wire message is a user-role turn. - * @param ops.isToolResultOnly - whether a user-role turn carries only tool - * results (no plain text/media). - * @param ops.merge - produces a new wire message combining `last` and `next` - * (must not mutate its arguments). - * @returns a new array with consecutive user turns merged. - */ export function mergeConsecutiveUserMessages( messages: readonly T[], - ops: { + mergePolicy: { readonly isUser: (message: T) => boolean; readonly isToolResultOnly: (message: T) => boolean; readonly merge: (last: T, next: T) => T; @@ -51,11 +12,11 @@ export function mergeConsecutiveUserMessages( const last = lastIndex >= 0 ? out[lastIndex] : undefined; if ( last !== undefined && - ops.isUser(last) && - ops.isUser(message) && - (ops.isToolResultOnly(last) || !ops.isToolResultOnly(message)) + mergePolicy.isUser(last) && + mergePolicy.isUser(message) && + (mergePolicy.isToolResultOnly(last) || !mergePolicy.isToolResultOnly(message)) ) { - out[lastIndex] = ops.merge(last, message); + out[lastIndex] = mergePolicy.merge(last, message); } else { out.push(message); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts index b00ddb792..25e18e52d 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-common.ts @@ -162,9 +162,6 @@ export function thinkingEffortToReasoningEffort(effort: ThinkingEffort): string case 'max': return 'xhigh'; default: - // 'on' (boolean models) or any model-declared effort OpenAI does not - // recognize: send no reasoning_effort and let the model use its own - // default, rather than throwing on a value the model itself advertised. return undefined; } } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index b9b326e31..a835fdaea 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -287,9 +287,6 @@ function convertHistoryMessages( const pendingToolResultMedia: OpenAIContentPart[] = []; for (const msg of history) { - // Message-level tool declarations are a Kimi wire feature; skipped here - // because the leftover `{role:"system"}` without content is rejected by - // the Chat Completions API. See isToolDeclarationOnlyMessage. if (isToolDeclarationOnlyMessage(msg)) continue; if (msg.role !== 'tool') { appendToolResultMediaMessage(messages, pendingToolResultMedia); diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 45e71ea1f..e38869a32 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -614,9 +614,6 @@ function convertHistoryMessages( }; for (const msg of history) { - // Message-level tool declarations are a Kimi wire feature; skipped here - // because the leftover content-free message item is rejected by the - // Responses API. See isToolDeclarationOnlyMessage. if (isToolDeclarationOnlyMessage(msg)) continue; if (msg.role !== 'tool') { flushPendingMedia(); diff --git a/packages/agent-core-v2/src/app/llmProtocol/tool.ts b/packages/agent-core-v2/src/app/llmProtocol/tool.ts index 8ebc77897..d45bfccaa 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/tool.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/tool.ts @@ -12,13 +12,5 @@ export interface Tool { description: string; /** JSON Schema describing the tool's parameters. */ parameters: Record; - /** - * Client-internal marker: the tool is executable but its schema must not be - * serialized into the request's top-level `tools[]` — it was (or will be) - * delivered through a message-level `tools` declaration instead, and the - * top-level list must stay byte-stable for prompt caching. `generate()` - * strips marked tools before the provider builds the request; the marker - * itself never reaches the wire. - */ deferred?: true; } diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index ae2fcf575..1f2deccbf 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -127,7 +127,6 @@ export interface Model { /** Return a new Model wrapper with additional protocol-constructor options applied. */ withProviderOptions(options: ProtocolProviderOptions): Model; - /** Return a new Model wrapper with preserved-thinking keep applied when the protocol supports it. */ withThinkingKeep(keep: string): Model; /** diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 323c53306..8550b11c1 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -362,9 +362,6 @@ function resolveModelCapabilities( thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking, tool_use: declared.has('tool_use') || detected.tool_use, max_context_tokens: maxContextSize, - // Message-level tool declarations (select_tools progressive disclosure). - // Every kosong capability bit must be forwarded explicitly here, or the - // agent layer can never gate progressive disclosure on it. select_tools: declared.has('select_tools') || detected.select_tools === true, }; } diff --git a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts index c6ce512bc..b0b84c4ce 100644 --- a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts @@ -237,6 +237,24 @@ describe('projector tool-exchange normalization', () => { expect(projected.filter((message) => message.role === 'tool')).toHaveLength(1); }); + it("strict mode reattaches a later duplicate's result when the first call has none", () => { + const projected = projectStrict([ + user('go'), + assistant('first attempt', ['dup']), + assistant('second attempt', ['dup']), + toolResult('dup', 'late result'), + user('next'), + ]); + + expect( + projected.map((message) => + message.role === 'tool' ? `tool:${message.toolCallId}` : message.role, + ), + ).toEqual(['user', 'assistant', 'tool:dup', 'assistant', 'user']); + expect(projected[1]?.toolCalls.map((call) => call.id)).toEqual(['dup']); + expect((projected[2]?.content[0] as { text: string }).text).toBe('late result'); + }); + it('strict mode drops leading non-user messages', () => { const projected = projectStrict([assistant('stale'), toolResult('ghost', 'orphaned'), user('hi')]); diff --git a/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts index 0cb671c6f..dd1120ec7 100644 --- a/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts +++ b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts @@ -2,6 +2,7 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService } from '#/agent/contextProjector'; import { AgentLLMRequesterService } from '#/agent/llmRequester/llmRequesterService'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; @@ -73,9 +74,12 @@ beforeEach(() => { afterEach(() => disposables.dispose()); -function createService(model: Model, projector: { project: unknown; projectStrict: unknown }) { +function createService( + model: Model, + projector: Pick, +) { const ix = disposables.add(new TestInstantiationService()); - const profile = { + const profile: Partial = { resolveModelContext: () => ({ modelAlias: 'm', modelCapabilities: capabilities, @@ -87,17 +91,25 @@ function createService(model: Model, projector: { project: unknown; projectStric }), getProvider: () => model, getSystemPrompt: () => 'system', - data: () => ({ modelAlias: 'm' }), + data: () => ({ + cwd: '', + modelAlias: 'm', + modelCapabilities: capabilities, + thinkingLevel: 'off', + systemPrompt: 'system', + }), isToolActive: () => true, }; const contextSize = { - getStatus: () => ({ contextTokens: 0 }), + get: () => ({ size: 0, measured: 0, estimated: 0 }), measured: () => undefined, }; const usage = { record: () => undefined }; const context = { get: () => history }; const tools = { list: () => [] }; - const config = { get: () => undefined }; + const config: Partial = { + get: (() => undefined) as IConfigService['get'], + }; const log = { info: () => undefined, warn: () => undefined }; const telemetry = { track: () => undefined }; @@ -121,11 +133,11 @@ describe('AgentLLMRequesterService strict resend', () => { let projectCalls = 0; let strictCalls = 0; const service = createService(createModel(calls), { - project: (messages: readonly Message[]) => { + project: (messages: readonly ContextMessage[]) => { projectCalls += 1; return messages; }, - projectStrict: (messages: readonly Message[]) => { + projectStrict: (messages: readonly ContextMessage[]) => { strictCalls += 1; return messages; }, @@ -152,8 +164,8 @@ describe('AgentLLMRequesterService strict resend', () => { }); let strictCalls = 0; const service = createService(model, { - project: (messages: readonly Message[]) => messages, - projectStrict: (messages: readonly Message[]) => { + project: (messages: readonly ContextMessage[]) => messages, + projectStrict: (messages: readonly ContextMessage[]) => { strictCalls += 1; return messages; }, diff --git a/packages/agent-core-v2/test/profile/profile-wire.test.ts b/packages/agent-core-v2/test/profile/profile-wire.test.ts index 435c916d2..60f7e39b5 100644 --- a/packages/agent-core-v2/test/profile/profile-wire.test.ts +++ b/packages/agent-core-v2/test/profile/profile-wire.test.ts @@ -296,6 +296,29 @@ describe('AgentProfileService (wire-backed config.update)', () => { ]); }); + it('forces configured Kimi thinking effort outside declared support_efforts', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-effort-force'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['thinking'] = { effort: ' max ' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'on' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('max'); + expect(thinkingEfforts).toEqual(['max']); + expect(generationKwargs).toEqual([ + { prompt_cache_key: 'session-test' }, + { extra_body: { thinking: { type: 'enabled', effort: 'max', keep: 'all' } } }, + ]); + }); + it('applies thinking.keep model override on the Anthropic path', () => { const generationKwargs: GenerationKwargs[] = []; const thinkingEfforts: ThinkingEffort[] = [];