diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index a180bc46635..1665d43d976 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1052,6 +1052,7 @@ export interface AgentStateSnapshot { type: 'think'; think: string; encrypted?: string; + detailsIndex?: number; } | /* ImageURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'image_url'; imageUrl: { @@ -1341,6 +1342,7 @@ export interface AgentStateSnapshot { type: 'think'; think: string; encrypted?: string; + detailsIndex?: number; } | /* ImageURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'image_url'; imageUrl: { diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 7751e8d5add..095e927558e 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -809,7 +809,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); return; case 'thinking': - this.accumulateMachinePart(turn, { type: 'think', think: delta.delta }); + this.accumulateMachinePart(turn, { + type: 'think', + think: delta.delta, + encrypted: delta.encrypted, + detailsIndex: delta.detailsIndex, + }); void this.dispatcher.dispatch( new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId: turn.id, delta: delta.delta }), ); @@ -1085,7 +1090,16 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private drainMachinePartials(turn: ActiveTurn, step: MachineStepState): void { - for (const part of turn.partials.splice(0).filter((entry) => !isVacuousContentPart(entry))) { + const drained = turn.partials.splice(0).filter((entry) => !isVacuousContentPart(entry)); + let lastCompleteThink = -1; + for (const [index, part] of drained.entries()) { + if (part.type === 'think' && part.encrypted !== undefined) { + lastCompleteThink = index; + } + } + for (const part of drained.filter( + (part, index) => part.type !== 'think' || index <= lastCompleteThink, + )) { this.context.appendLoopEvent({ type: 'content.part', uuid: randomUUID(), diff --git a/packages/agent-core-v2/src/agent/loop/machine/engine.ts b/packages/agent-core-v2/src/agent/loop/machine/engine.ts index 31011d0492a..7219f46b325 100644 --- a/packages/agent-core-v2/src/agent/loop/machine/engine.ts +++ b/packages/agent-core-v2/src/agent/loop/machine/engine.ts @@ -21,7 +21,12 @@ import { createMachineTools, type ToolResultExtras } from './tools'; export type MachineEngineDelta = | { readonly kind: 'assistant'; readonly delta: string } - | { readonly kind: 'thinking'; readonly delta: string } + | { + readonly kind: 'thinking'; + readonly delta: string; + readonly encrypted?: string; + readonly detailsIndex?: number; + } | { readonly kind: 'toolCall'; readonly toolCallId: string; @@ -151,7 +156,12 @@ function createDeltaSplitter(): (part: StreamedMessagePart) => MachineEngineDelt case 'text': return { kind: 'assistant', delta: part.text }; case 'think': - return { kind: 'thinking', delta: part.think }; + return { + kind: 'thinking', + delta: part.think, + encrypted: part.encrypted, + detailsIndex: part.detailsIndex, + }; case 'image_url': case 'audio_url': case 'video_url': diff --git a/packages/agent-core-v2/src/human/llm/message.ts b/packages/agent-core-v2/src/human/llm/message.ts index eb11bc446ba..9c3aa5600f5 100644 --- a/packages/agent-core-v2/src/human/llm/message.ts +++ b/packages/agent-core-v2/src/human/llm/message.ts @@ -16,6 +16,7 @@ export interface ThinkPart { type: 'think'; think: string; encrypted?: string; + detailsIndex?: number; } export interface ImageURLPart { @@ -103,6 +104,9 @@ export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessag if (target.encrypted !== undefined) { return false; } + if (target.detailsIndex !== source.detailsIndex) { + return false; + } target.think += source.think; if (source.encrypted !== undefined) { target.encrypted = source.encrypted; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts index 4eb25413dac..963958b2531 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts @@ -36,7 +36,12 @@ import type { TokenUsage } from '#/llm/usage'; import { lowerMessage, type OpenAIWireMessage } from './lower'; import { extractToolMedia } from './patterns'; -import { DEFAULT_REASONING_KEY, extractReasoning } from './reasoning-key'; +import { + convertReasoningDetails, + DEFAULT_REASONING_KEY, + extractReasoning, + extractReasoningDetails, +} from './reasoning-key'; function responseFormatToOpenAI(format: ResponseFormat): Record { if (format.type === 'json_object') { @@ -265,6 +270,7 @@ export const openAIFormat: ProtocolFormat(); function convertStreamToolCall(toolCall: RawStreamToolCallDelta): StreamedMessagePart[] { @@ -351,9 +357,17 @@ export const openAIFormat: ProtocolFormat 0) { sink.onDelta({ type: 'text', text: delta.content }); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts index 865db526b9b..4f72dff3109 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts @@ -2,6 +2,7 @@ import { extractText, type ContentPart, type Message } from '#/llm/message'; import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; import { TOOL_RESULT_MEDIA_PLACEHOLDER } from './patterns'; +import { DEFAULT_REASONING_KEY, REASONING_DETAILS_KEY } from './reasoning-key'; export type OpenAIContentPart = { type: 'text' | 'image_url' | 'audio_url' | 'video_url'; @@ -140,7 +141,20 @@ export function lowerMessage(message: Message, lower: OpenAILowerContext): OpenA } else { converted = { role: message.role, content: content ?? '' }; } - if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { + const reasoningDetails: Record[] = []; + for (const part of message.content) { + if (part.type !== 'think' || part.detailsIndex === undefined) continue; + if (part.think.length > 0) { + reasoningDetails.push({ type: 'summary', summary: part.think }); + } + if (part.encrypted !== undefined) { + reasoningDetails.push({ type: 'encrypted', encrypted: part.encrypted }); + } + } + if (reasoningDetails.length > 0) { + (converted as Record)[REASONING_DETAILS_KEY] = reasoningDetails; + (converted as Record)[DEFAULT_REASONING_KEY] = reasoningContent; + } else if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { (converted as Record)[reasoningKey] = reasoningContent; } const hooked = diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts index 064c4402cf9..bf05cafb7d0 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts @@ -1,3 +1,5 @@ +import type { StreamedMessagePart, ThinkPart } from '#/llm/message'; + export const KNOWN_REASONING_KEYS = [ 'reasoning_content', 'reasoning_details', @@ -40,3 +42,60 @@ export class ReasoningKeyDialect { return this._explicitKey ?? this._detected ?? DEFAULT_REASONING_KEY; } } + +export const REASONING_DETAILS_KEY = 'reasoning_details'; + +export interface ReasoningDetailsElement { + readonly type?: string; + readonly index: number; + readonly summary?: string; + readonly encrypted?: string; +} + +function toReasoningDetailsElement( + value: unknown, + position: number, +): ReasoningDetailsElement | undefined { + if (typeof value !== 'object' || value === null) return undefined; + const record = value as Record; + const type = typeof record['type'] === 'string' ? record['type'] : undefined; + if (type !== undefined && type !== 'summary' && type !== 'encrypted') return undefined; + const index = typeof record['index'] === 'number' ? record['index'] : position; + const summary = typeof record['summary'] === 'string' ? record['summary'] : undefined; + const encrypted = typeof record['encrypted'] === 'string' ? record['encrypted'] : undefined; + return { type, index, summary, encrypted }; +} + +export function extractReasoningDetails( + source: unknown, +): ReasoningDetailsElement[] | undefined { + if (typeof source !== 'object' || source === null) return undefined; + const value = (source as Record)[REASONING_DETAILS_KEY]; + if (!Array.isArray(value)) return undefined; + const elements: ReasoningDetailsElement[] = []; + for (const [position, item] of value.entries()) { + const element = toReasoningDetailsElement(item, position); + if (element !== undefined) elements.push(element); + } + return elements; +} + +export function convertReasoningDetails( + elements: readonly ReasoningDetailsElement[], +): StreamedMessagePart[] { + const parts: StreamedMessagePart[] = []; + for (const element of elements) { + if (element.type !== 'encrypted' && element.summary !== undefined && element.summary.length > 0) { + parts.push({ type: 'think', think: element.summary, detailsIndex: element.index } satisfies ThinkPart); + } + if (element.type !== 'summary' && element.encrypted !== undefined && element.encrypted.length > 0) { + parts.push({ + type: 'think', + think: '', + encrypted: element.encrypted, + detailsIndex: element.index, + } satisfies ThinkPart); + } + } + return parts; +} diff --git a/packages/agent-core-v2/src/human/test/llm/thinking.test.ts b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts index ad719f5cbf3..bd0e741bece 100644 --- a/packages/agent-core-v2/src/human/test/llm/thinking.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts @@ -335,7 +335,7 @@ describe('openai requester thinking', () => { expect(client.body()['reasoning_effort']).toBe('medium'); }); - it('echoes think parts under reasoning_content by default', async () => { + it('echoes think parts under reasoning_content by default and restores marked reasoning_details', async () => { const client = stubOpenAIClient(chatCompletionChunks()); const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); await requester.generate( @@ -351,6 +351,33 @@ describe('openai requester thinking', () => { const assistant = bodyMessages(client.body())[1]!; expect(assistant['reasoning_content']).toBe('abc'); expect(assistant['content']).toBe('hello'); + + const marked = stubOpenAIClient(chatCompletionChunks()); + const markedRequester = createOpenAIRequester(undefined, { + clientFactory: marked.clientFactory, + }); + await markedRequester.generate( + { model, thinking: { effort: 'off' } }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage([ + { type: 'think', think: '第一段续', detailsIndex: 0 }, + { type: 'think', think: '第二段', detailsIndex: 1 }, + { type: 'think', think: '', encrypted: 'cipher', detailsIndex: 2 }, + { type: 'text', text: 'ok' }, + ]), + ], + }, + { signal: new AbortController().signal }, + ); + const markedAssistant = bodyMessages(marked.body())[1]!; + expect(markedAssistant['reasoning_details']).toEqual([ + { type: 'summary', summary: '第一段续' }, + { type: 'summary', summary: '第二段' }, + { type: 'encrypted', encrypted: 'cipher' }, + ]); + expect(markedAssistant['reasoning_content']).toBe('第一段续第二段'); }); it('echoes an empty reasoning_content on think-less assistant messages only when keeping all', async () => { @@ -426,6 +453,34 @@ describe('openai requester thinking', () => { const detectedAssistant = bodyMessages(captured[1]!)[1]!; expect(detectedAssistant['reasoning']).toBe('abc'); expect('reasoning_content' in detectedAssistant).toBe(false); + + const explicit = stubOpenAIClient( + chatCompletionChunks([ + { + reasoning_details: [ + { index: 0, type: 'summary', summary: 'ignored' }, + { index: 1, type: 'encrypted', encrypted: 'cipher' }, + ], + }, + { content: 'ok' }, + ]), + ); + const explicitRequester = createOpenAIRequester( + { reasoningKey: () => 'reasoning' }, + { clientFactory: explicit.clientFactory }, + ); + const explicitParts: unknown[] = []; + await explicitRequester.generate( + { model }, + { messages }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.delta') explicitParts.push(event.part); + }, + }, + ); + expect(explicitParts).toEqual([{ type: 'text', text: 'ok' }]); }); it('parses reasoning from stream deltas', async () => { @@ -470,5 +525,44 @@ describe('openai requester thinking', () => { { type: 'text', text: 'hi' }, { type: 'think', think: '' }, ]); + await expect( + collect( + chatCompletionChunks([ + { + reasoning_content: '第一段', + reasoning_details: [{ index: 0, type: 'summary', summary: '第一段' }], + }, + { + reasoning_details: [ + { index: 0, summary: '续' }, + { index: 1, type: 'summary', summary: '第二段' }, + ], + }, + { reasoning_details: [{ index: 2, type: 'encrypted', encrypted: 'cipher' }] }, + { content: 'ok' }, + ]), + ), + ).resolves.toEqual([ + { type: 'think', think: '第一段续', detailsIndex: 0 }, + { type: 'think', think: '第二段', detailsIndex: 1 }, + { type: 'think', think: '', encrypted: 'cipher', detailsIndex: 2 }, + { type: 'text', text: 'ok' }, + ]); + await expect( + collect( + chatCompletionChunks([ + { + reasoning_details: [ + { index: 0, type: 'reasoning.text', text: 'foreign', format: 'unknown' }, + { index: 1, type: 'summary', summary: 'kept' }, + ], + }, + { content: 'ok' }, + ]), + ), + ).resolves.toEqual([ + { type: 'think', think: 'kept', detailsIndex: 1 }, + { type: 'text', text: 'ok' }, + ]); }); }); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index d7b931d4f5b..bd7d6bcda75 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1572,7 +1572,7 @@ describe('interruption reminder', () => { expect(interruptionReminders()).toHaveLength(0); }); - it('preserves partial thinking on user cancel', async () => { + it('drops unsigned thinking but keeps signed thinking on user cancel', async () => { ctx.mockNextResponse({ type: 'think', think: 'pondering' }, { type: 'text', text: 'answer' }); const subscription = ctx.get(IEventBus).subscribe(ThinkingDelta, () => { loop.cancel(); @@ -1581,13 +1581,33 @@ describe('interruption reminder', () => { await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); subscription.dispose(); + const thinkParts = ctx + .contextData() + .history.flatMap((message) => message.content) + .filter((part) => part.type === 'think'); + expect(thinkParts).toEqual([]); + expect(interruptionReminders()).toHaveLength(1); + + ctx.mockNextResponse( + { type: 'think', think: 'seg', encrypted: 'sig' }, + { type: 'text', text: 'partial answer' }, + ); + const second = ctx.get(IEventBus).subscribe(AssistantDelta, () => { + loop.cancel(); + }); + const secondTurn = submitTurn(loop, 'Again').turn; + await expect(secondTurn.result).resolves.toMatchObject({ type: 'cancelled' }); + second.dispose(); + expect(ctx.contextData().history).toContainEqual({ role: 'assistant', - content: [{ type: 'think', think: 'pondering' }], + content: [ + { type: 'think', think: 'seg', encrypted: 'sig' }, + { type: 'text', text: 'partial answer' }, + ], toolCalls: [], partial: true, }); - expect(interruptionReminders()).toHaveLength(1); }); it('records no partial content when the stream only produced whitespace', async () => {