diff --git a/.changeset/fix-dsml-tool-calls.md b/.changeset/fix-dsml-tool-calls.md new file mode 100644 index 000000000..fbcc53097 --- /dev/null +++ b/.changeset/fix-dsml-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix unparsed DSML tool call markup leaked into model text responses. diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts new file mode 100644 index 000000000..998e3d0f8 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts @@ -0,0 +1,320 @@ +import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; + +const CONTAINER_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; +const CONTAINER_CLOSE_RE = /^<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; +const INVOKE_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke(?:\s+[^>]*)?>/i; +const INVOKE_CLOSE_RE = /<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s*>/i; +const HERMES_OPEN_RE = /^/i; +const HERMES_CLOSE_RE = /<\/tool_call>/i; + +function unescapeXml(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): unknown { + const unescaped = unescapeXml(rawVal); + if (isStringAttr === true) { + return unescaped; + } + const trimmed = unescaped.trim(); + if (isStringAttr === false) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + if ( + trimmed === 'true' || + trimmed === 'false' || + trimmed === 'null' || + (trimmed.length > 0 && !Number.isNaN(Number(trimmed))) || + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + return JSON.parse(trimmed); + } catch { + return unescaped; + } + } + return unescaped; +} + +function parseInvokeBody(invokeContent: string): Record { + const paramRegex = + /<\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s*>/gi; + const args: Record = {}; + let paramFound = false; + let match: RegExpExecArray | null = null; + + while ((match = paramRegex.exec(invokeContent)) !== null) { + const attrStr = match[1] ?? ''; + const rawVal = match[2] ?? ''; + const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); + const paramName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + if (paramName) { + paramFound = true; + const stringAttrMatch = + /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i.exec(attrStr); + const stringAttrVal = stringAttrMatch + ? (stringAttrMatch[1] ?? stringAttrMatch[2] ?? stringAttrMatch[3]) + : undefined; + const isStringAttr = + stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; + args[paramName] = parseParameterValue(rawVal, isStringAttr); + } + } + + if (paramFound) { + return args; + } + + const trimmed = invokeContent.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch {} + } + + return {}; +} + +function parseInvokeTag(invokeBlock: string): ToolCall | null { + const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + if (!openMatch) return null; + + const attrStr = openMatch[1] ?? ''; + const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); + const toolName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + if (!toolName) return null; + + const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); + if (!closeMatch) return null; + const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); + + const args = parseInvokeBody(innerContent); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, + name: toolName, + arguments: JSON.stringify(args), + }; +} + +function parseHermesToolCall(toolCallBlock: string): ToolCall | null { + if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; + const inner = toolCallBlock + .replace(/^/i, '') + .replace(/<\/tool_call>$/i, '') + .trim(); + try { + const parsed = JSON.parse(inner); + if (parsed && typeof parsed.name === 'string') { + const args = + typeof parsed.arguments === 'string' + ? parsed.arguments + : JSON.stringify(parsed.arguments ?? {}); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, + name: parsed.name, + arguments: args, + }; + } + } catch {} + return null; +} + +function isPotentialTagPrefix(s: string): boolean { + if (!s.startsWith('<')) return false; + const lower = s.toLowerCase(); + let rest = lower.startsWith(' t.startsWith(rest) || rest.startsWith(t)); +} + +export class DsmlStreamParser { + private _buffer = ''; + private _hasExtractedToolCalls = false; + + get hasExtractedToolCalls(): boolean { + return this._hasExtractedToolCalls; + } + + feed(chunk: string): StreamedMessagePart[] { + this._buffer += chunk; + const parts: StreamedMessagePart[] = []; + + while (this._buffer.length > 0) { + const ltIdx = this._buffer.indexOf('<'); + if (ltIdx === -1) { + parts.push({ type: 'text', text: this._buffer }); + this._buffer = ''; + break; + } + + if (ltIdx > 0) { + parts.push({ type: 'text', text: this._buffer.slice(0, ltIdx) }); + this._buffer = this._buffer.slice(ltIdx); + } + + const openContainer = CONTAINER_OPEN_RE.exec(this._buffer); + if (openContainer) { + this._buffer = this._buffer.slice(openContainer[0].length); + if (/^\s* 0) { + const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); + if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { + const toolCall = parseInvokeTag(this._buffer); + if (toolCall) { + this._hasExtractedToolCalls = true; + parts.push(toolCall); + this._buffer = ''; + return parts; + } + } + const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); + if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { + const toolCall = parseHermesToolCall(this._buffer); + if (toolCall) { + this._hasExtractedToolCalls = true; + parts.push(toolCall); + this._buffer = ''; + return parts; + } + } + parts.push({ type: 'text', text: this._buffer }); + this._buffer = ''; + } + return parts; + } +} + +export function extractDsmlToolCalls(text: string): { + cleanText: string; + toolCalls: ToolCall[]; +} { + const parser = new DsmlStreamParser(); + const parts = [...parser.feed(text), ...parser.flush()]; + const toolCalls: ToolCall[] = []; + const textParts: string[] = []; + + for (const part of parts) { + if (part.type === 'function') { + toolCalls.push(part); + } else if (part.type === 'text') { + textParts.push(part.text); + } + } + + const cleanText = toolCalls.length > 0 ? textParts.join('').trim() : text; + return { cleanText, toolCalls }; +} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index f2b36111c..25ad3daf7 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -47,6 +47,7 @@ import { toolToOpenAI, } from './openai-common'; import { ReasoningKeyDialect } from './reasoning-key'; +import { DsmlStreamParser, extractDsmlToolCalls } from './dsml-tool-parser'; import { mergeRequestHeaders, requireProviderApiKey, @@ -338,6 +339,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { private _usage: TokenUsage | null = null; private _finishReason: FinishReason | null = null; private _rawFinishReason: string | null = null; + private _hasExtractedToolCalls = false; private readonly _iter: AsyncGenerator; constructor( @@ -374,6 +376,12 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } get finishReason(): FinishReason | null { + if ( + this._hasExtractedToolCalls && + (this._finishReason === 'completed' || this._finishReason === null) + ) { + return 'tool_calls'; + } return this._finishReason; } @@ -419,8 +427,19 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } - if (message.content) { - yield { type: 'text', text: message.content } satisfies StreamedMessagePart; + let text = message.content ?? null; + let extractedToolCalls: ToolCall[] = []; + if (text) { + const parsed = extractDsmlToolCalls(text); + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + this._hasExtractedToolCalls = true; + } + } + + if (text && text.length > 0) { + yield { type: 'text', text } satisfies StreamedMessagePart; } if (message.tool_calls) { @@ -434,6 +453,10 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } satisfies ToolCall; } } + + for (const toolCall of extractedToolCalls) { + yield toolCall satisfies ToolCall; + } } private async *_convertStreamResponse( @@ -441,6 +464,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { reasoningKeyDialect: ReasoningKeyDialect, ): AsyncGenerator { const bufferedToolCalls = new Map(); + const dsmlParser = new DsmlStreamParser(); try { for await (const chunk of response) { @@ -469,7 +493,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } if (delta.content) { - yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; + for (const part of dsmlParser.feed(delta.content)) { + yield part; + } } for (const toolCall of delta.tool_calls ?? []) { @@ -478,6 +504,13 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } } } + + for (const part of dsmlParser.flush()) { + yield part; + } + if (dsmlParser.hasExtractedToolCalls) { + this._hasExtractedToolCalls = true; + } } catch (error: unknown) { throw convertOpenAIError(error, this._convertErrorHook); } diff --git a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts index b0a2c4b8e..c46b47822 100644 --- a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts +++ b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts @@ -1152,6 +1152,9 @@ export class SessionExpertTalkService extends Disposable implements ISessionExpe } const text = completion.summary.trim(); if (text.length === 0) throw new Error('Discussion stage returned an empty answer'); + if (hasUnparsedToolCallMarkup(text)) { + throw new Error('Discussion stage output contains unparsed tool call markup'); + } return text; }; let text = await request(prompt, content); @@ -1217,6 +1220,7 @@ export class SessionExpertTalkService extends Disposable implements ISessionExpe if ( errorReason === 'STAGE_REQUEST_BUDGET_EXCEEDED' && partialText.length > 0 + && !hasUnparsedToolCallMarkup(partialText) && limits.acceptBudgetExhaustedOutput?.(partialText) === true && visibleOutputTokens <= limits.maxOutputTokens ) { @@ -1895,10 +1899,10 @@ function hasReviewSections(text: string): boolean { } function hasMarkdownSections(text: string, sections: readonly string[]): boolean { - const headings = text + const headings = new Set(text .split('\n') - .map((line) => line.trim().replace(/^#{1,6}\s+/, '').toLowerCase()); - return sections.every((section) => headings.includes(section)); + .map((line) => line.trim().replace(/^#{1,6}\s+/, '').toLowerCase())); + return sections.every((section) => headings.has(section)); } function usageDelta( @@ -2067,6 +2071,10 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function hasUnparsedToolCallMarkup(text: string): boolean { + return /<\s*[||]?\s*(?:DSML\s*[||]?)?\s*(?:tool_calls?|invoke)\b/i.test(text); +} + registerScopedService( LifecycleScope.Session, ISessionExpertTalkService, diff --git a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts new file mode 100644 index 000000000..fc66fe0b7 --- /dev/null +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from 'vitest'; + +import { + DsmlStreamParser, + extractDsmlToolCalls, +} from '#/kosong/provider/bases/openai/dsml-tool-parser'; +import { OpenAILegacyChatProvider } from '#/kosong/provider/bases/openai/openai-legacy'; + +describe('agent-core-v2: DsmlStreamParser and extractDsmlToolCalls', () => { + describe('extractDsmlToolCalls', () => { + it('extracts standard DeepSeek DSML tool calls with fullwidth bars', () => { + const input = `I will read the file. +<|DSML|tool_calls> +<|DSML|invoke name="Read"> +<|DSML|parameter name="filePath" string="true">src/index.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('I will read the file.'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/index.ts', + }); + expect(result.toolCalls[0]?.id).toMatch(/^call_/); + }); + + it('extracts DSML tool calls with standard ASCII pipes', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Glob"> +<|DSML|parameter name="pattern" string="true">**/*.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Glob'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + pattern: '**/*.ts', + }); + }); + + it('extracts multiple invokes with mixed typed parameters', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Search"> +<|DSML|parameter name="query" string="true">export function +<|DSML|parameter name="limit" string="false">25 +<|DSML|parameter name="caseSensitive" string="false">true +<|DSML|parameter name="filter" string="false">{"type": "code"} + +<|DSML|invoke name="Read"> +<|DSML|parameter name="path">src/main.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls[0]?.name).toBe('Search'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + query: 'export function', + limit: 25, + caseSensitive: true, + filter: { type: 'code' }, + }); + expect(result.toolCalls[1]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[1]?.arguments ?? '{}')).toEqual({ + path: 'src/main.ts', + }); + }); + + it('extracts invoke without container tag', () => { + const input = `Checking directory: +<|DSML|invoke name="ListDir"> +<|DSML|parameter name="dir" string="true">packages +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('Checking directory:'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('ListDir'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + dir: 'packages', + }); + }); + + it('extracts Hermes tool_call JSON format', () => { + const input = ` +{"name": "Read", "arguments": {"filePath": "package.json"}} +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'package.json', + }); + }); + + it('decodes XML entities in parameter values', () => { + const input = `<|DSML|invoke name="Eval"> +<|DSML|parameter name="code" string="true">a && b < c +`; + + const result = extractDsmlToolCalls(input); + expect(result.toolCalls).toHaveLength(1); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + code: 'a && b < c', + }); + }); + + it('preserves regular non-tool tags and operators in text', () => { + const input = 'Check if 5 < 10 and 20 > 15, or use
Hello
and vector.'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + }); + + describe('DsmlStreamParser', () => { + it('streams normal text without modification', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('Hello world! '), + ...parser.feed('How are you today?'), + ...parser.flush(), + ]; + + expect(parts).toEqual([ + { type: 'text', text: 'Hello world! ' }, + { type: 'text', text: 'How are you today?' }, + ]); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + + it('handles stream split across DSML container and invoke chunks', () => { + const parser = new DsmlStreamParser(); + const chunks = [ + 'Looking into the code...\n\n', + '<', + '|DSML', + '|tool_calls>\n', + '<|DSML|invoke name="Read">\n', + '<|DSML|parameter name="filePath" ', + 'string="true">src/app.ts', + '\n', + '\n', + '\n', + 'Done reading.', + ]; + + const parts = []; + for (const chunk of chunks) { + parts.push(...parser.feed(chunk)); + } + parts.push(...parser.flush()); + + expect(parser.hasExtractedToolCalls).toBe(true); + + const textParts = parts.filter((p) => p.type === 'text'); + const toolParts = parts.filter((p) => p.type === 'function'); + + expect(textParts.map((p) => p.text).join('')).toBe( + 'Looking into the code...\n\nDone reading.', + ); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]?.name).toBe('Read'); + expect(JSON.parse(toolParts[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/app.ts', + }); + }); + + it('correctly flushes partial code comparisons that look like tags', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('if (x <'), + ...parser.feed(' 5 && y > 2)'), + ...parser.flush(), + ]; + + const fullText = parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); + expect(fullText).toBe('if (x < 5 && y > 2)'); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + }); + + describe('OpenAILegacyChatProvider DSML integration', () => { + it('parses streamed DSML tool calls from delta.content and sets finishReason to tool_calls', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: true, + }); + + async function* mockStream(chunks: unknown[]) { + for (const chunk of chunks) { + yield chunk; + } + } + + const chunks = [ + { + id: 'chatcmpl-v2-1', + choices: [ + { + index: 0, + delta: { + content: + 'Reading the code.\n\n<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">src/server.ts\n\n', + }, + finish_reason: 'stop', + }, + ], + }, + ]; + + (provider as unknown as { _client: unknown })._client = { + chat: { + completions: { + create: () => ({ + withResponse: async () => ({ + data: mockStream(chunks), + response: { headers: new Headers() }, + }), + }), + }, + }, + }; + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(stream.finishReason).toBe('tool_calls'); + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ + type: 'text', + text: 'Reading the code.\n\n', + }); + expect(parts[1]).toMatchObject({ + type: 'function', + name: 'Read', + arguments: '{"filePath":"src/server.ts"}', + }); + }); + + it('parses non-streamed DSML tool calls and sets finishReason to tool_calls', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: false, + }); + + const responseData = { + id: 'chatcmpl-v2-nonstream', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: + '<|DSML|tool_calls>\n<|DSML|invoke name="Glob">\n<|DSML|parameter name="pattern" string="true">*.json\n\n', + }, + finish_reason: 'stop', + }, + ], + }; + + (provider as unknown as { _client: unknown })._client = { + chat: { + completions: { + create: () => ({ + withResponse: async () => ({ + data: responseData, + response: { headers: new Headers() }, + }), + }), + }, + }, + }; + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(stream.finishReason).toBe('tool_calls'); + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ + type: 'function', + name: 'Glob', + arguments: '{"pattern":"*.json"}', + }); + }); + + it('preserves surrounding whitespace and markdown hard breaks in non-stream response', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: false, + }); + + const textWithWhitespace = ' Line 1 \nLine 2 '; + const responseData = { + id: 'chatcmpl-v2-whitespace', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: textWithWhitespace, + }, + finish_reason: 'stop', + }, + ], + }; + + (provider as unknown as { _client: unknown })._client = { + chat: { + completions: { + create: () => ({ + withResponse: async () => ({ + data: responseData, + response: { headers: new Headers() }, + }), + }), + }, + }, + }; + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ + type: 'text', + text: textWithWhitespace, + }); + }); + + it('preserves malformed invoke block as text without discarding content', () => { + const input = '<|DSML|invoke>malformed content without name'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + + it('preserves malformed Hermes block as text without discarding content', () => { + const input = 'not valid json'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + + it('handles stream split after whitespace in tag prefix', () => { + const parser = new DsmlStreamParser(); + const chunks = [ + '< ', + '| DSML ', + '| invoke name="Read">\n< | DSML | parameter name="filePath">src/app.ts\n', + ]; + + const parts = []; + for (const chunk of chunks) { + parts.push(...parser.feed(chunk)); + } + parts.push(...parser.flush()); + + expect(parser.hasExtractedToolCalls).toBe(true); + const toolParts = parts.filter((p) => p.type === 'function'); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]?.name).toBe('Read'); + }); + + it('preserves unclosed Hermes block at flush as text', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('{"name": "Read"}'), + ...parser.flush(), + ]; + + expect(parser.hasExtractedToolCalls).toBe(false); + expect(parts).toEqual([ + { type: 'text', text: '{"name": "Read"}' }, + ]); + }); + }); +}); diff --git a/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts b/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts index 1bbad45c6..c02327f6f 100644 --- a/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts +++ b/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts @@ -451,6 +451,215 @@ describe('SessionExpertTalkService', () => { }); }); + it('fails the stage when unparsed DSML markup leaks into text', async () => { + ctx = createDiscussionAgent(async (chat) => { + if (chat.modelName === 'peer-model') { + return { + id: 'peer-leaked-dsml', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">foo.ts\n\n', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + } + return { + id: 'lead-answer', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '## Position\nUse the verified result.\n\n## Case\nEvidence supports it.\n\n## Decision criteria\nPrefer verified behavior.\n\n## Risks and uncertainty\nNone material.\n\n## Recommended answer\nUse the verified result.', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + }); + const { service, started } = await startDiscussion(ctx, 'Reject leaked DSML markup.'); + + await vi.waitFor(() => { + expect(TERMINAL_STATUSES.has(service.getRun(started.runId).status)).toBe(true); + }, { timeout: 5_000 }); + + expect(service.getRun(started.runId)).toMatchObject({ + status: 'FAILED_OPENING', + artifacts: { + leadOpening: { status: 'completed' }, + peerOpening: { + status: 'failed', + }, + }, + }); + }); + + it('fails the stage when unparsed Hermes markup leaks into text', async () => { + ctx = createDiscussionAgent(async (chat) => { + if (chat.modelName === 'peer-model') { + return { + id: 'peer-leaked-hermes', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '\n{"name": "Read", "arguments": {"filePath": "foo.ts"}}\n', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + } + return { + id: 'lead-answer', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '## Position\nUse the verified result.\n\n## Case\nEvidence supports it.\n\n## Decision criteria\nPrefer verified behavior.\n\n## Risks and uncertainty\nNone material.\n\n## Recommended answer\nUse the verified result.', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + }); + const { service, started } = await startDiscussion(ctx, 'Reject leaked Hermes markup.'); + + await vi.waitFor(() => { + expect(TERMINAL_STATUSES.has(service.getRun(started.runId).status)).toBe(true); + }, { timeout: 5_000 }); + + expect(service.getRun(started.runId)).toMatchObject({ + status: 'FAILED_OPENING', + artifacts: { + leadOpening: { status: 'completed' }, + peerOpening: { + status: 'failed', + }, + }, + }); + }); + + it('fails the stage when unparsed spaced DSML markup leaks into text', async () => { + ctx = createDiscussionAgent(async (chat) => { + if (chat.modelName === 'peer-model') { + return { + id: 'peer-leaked-spaced-dsml', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '< | DSML | invoke name="Read">\n< | DSML | parameter name="filePath">foo.ts\n', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + } + return { + id: 'lead-answer', + message: { + role: 'assistant' as const, + content: [{ + type: 'text' as const, + text: '## Position\nUse the verified result.\n\n## Case\nEvidence supports it.\n\n## Decision criteria\nPrefer verified behavior.\n\n## Risks and uncertainty\nNone material.\n\n## Recommended answer\nUse the verified result.', + }], + toolCalls: [], + }, + usage: emptyUsage(), + finishReason: 'completed' as const, + rawFinishReason: 'stop', + }; + }); + const { service, started } = await startDiscussion(ctx, 'Reject leaked spaced DSML markup.'); + + await vi.waitFor(() => { + expect(TERMINAL_STATUSES.has(service.getRun(started.runId).status)).toBe(true); + }, { timeout: 5_000 }); + + expect(service.getRun(started.runId)).toMatchObject({ + status: 'FAILED_OPENING', + artifacts: { + leadOpening: { status: 'completed' }, + peerOpening: { + status: 'failed', + }, + }, + }); + }); + + it('rejects budget-exhausted opening when partial text contains unparsed tool-call markup', async () => { + let callId = 0; + ctx = createDiscussionAgent(async (_chat, _systemPrompt, tools, history) => { + callId += 1; + const input = history + .flatMap((message) => message.content) + .map((part) => part.type === 'text' ? part.text : '') + .join('\n'); + if (tools.length > 0) { + return { + id: `research-${String(callId)}`, + message: { + role: 'assistant' as const, + content: [], + toolCalls: [{ + type: 'function' as const, + id: `read-${String(callId)}`, + name: 'Read', + arguments: JSON.stringify({ path: 'package.json', n_lines: 1 }), + }], + }, + usage: emptyUsage(), + finishReason: 'tool_calls' as const, + rawFinishReason: 'tool_calls', + }; + } + const openingWithMarkup = '## Position\nUse the verified result.\n\n\n{"name": "Read"}\n\n\n## Case\nEvidence.\n\n## Decision criteria\nPrefer verified behavior.\n\n## Risks and uncertainty\nNone.\n\n## Recommended answer\nUse the verified result.'; + return { + id: `answer-${String(callId)}`, + message: { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: openingWithMarkup }], + toolCalls: input.includes('EXPERT TALK OPENING CONTRACT') + ? [{ + type: 'function' as const, + id: `late-read-${String(callId)}`, + name: 'Read', + arguments: JSON.stringify({ path: 'package.json', n_lines: 1 }), + }] + : [], + }, + usage: emptyUsage(), + finishReason: input.includes('EXPERT TALK OPENING CONTRACT') + ? 'tool_calls' as const + : 'completed' as const, + rawFinishReason: input.includes('EXPERT TALK OPENING CONTRACT') ? 'tool_calls' : 'stop', + }; + }); + const { service, started } = await startDiscussion(ctx, 'Reject partial text with markup.'); + + await vi.waitFor(() => { + expect(TERMINAL_STATUSES.has(service.getRun(started.runId).status)).toBe(true); + }, { timeout: 5_000 }); + + expect(service.getRun(started.runId)).toMatchObject({ + status: 'FAILED_OPENING', + }); + }); + it('keeps a contract-complete opening when the final response reaches its request budget', async () => { let callId = 0; ctx = createDiscussionAgent(async (_chat, _systemPrompt, tools, history) => { diff --git a/packages/kosong/src/providers/dsml-tool-parser.ts b/packages/kosong/src/providers/dsml-tool-parser.ts new file mode 100644 index 000000000..63bd8f69c --- /dev/null +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -0,0 +1,320 @@ +import type { StreamedMessagePart, ToolCall } from '#/message'; + +const CONTAINER_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; +const CONTAINER_CLOSE_RE = /^<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; +const INVOKE_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke(?:\s+[^>]*)?>/i; +const INVOKE_CLOSE_RE = /<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s*>/i; +const HERMES_OPEN_RE = /^/i; +const HERMES_CLOSE_RE = /<\/tool_call>/i; + +function unescapeXml(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): unknown { + const unescaped = unescapeXml(rawVal); + if (isStringAttr === true) { + return unescaped; + } + const trimmed = unescaped.trim(); + if (isStringAttr === false) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + if ( + trimmed === 'true' || + trimmed === 'false' || + trimmed === 'null' || + (trimmed.length > 0 && !Number.isNaN(Number(trimmed))) || + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) { + try { + return JSON.parse(trimmed); + } catch { + return unescaped; + } + } + return unescaped; +} + +function parseInvokeBody(invokeContent: string): Record { + const paramRegex = + /<\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s*>/gi; + const args: Record = {}; + let paramFound = false; + let match: RegExpExecArray | null = null; + + while ((match = paramRegex.exec(invokeContent)) !== null) { + const attrStr = match[1] ?? ''; + const rawVal = match[2] ?? ''; + const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); + const paramName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + if (paramName) { + paramFound = true; + const stringAttrMatch = + /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i.exec(attrStr); + const stringAttrVal = stringAttrMatch + ? (stringAttrMatch[1] ?? stringAttrMatch[2] ?? stringAttrMatch[3]) + : undefined; + const isStringAttr = + stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; + args[paramName] = parseParameterValue(rawVal, isStringAttr); + } + } + + if (paramFound) { + return args; + } + + const trimmed = invokeContent.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch {} + } + + return {}; +} + +function parseInvokeTag(invokeBlock: string): ToolCall | null { + const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + if (!openMatch) return null; + + const attrStr = openMatch[1] ?? ''; + const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); + const toolName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + if (!toolName) return null; + + const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); + if (!closeMatch) return null; + const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); + + const args = parseInvokeBody(innerContent); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, + name: toolName, + arguments: JSON.stringify(args), + }; +} + +function parseHermesToolCall(toolCallBlock: string): ToolCall | null { + if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; + const inner = toolCallBlock + .replace(/^/i, '') + .replace(/<\/tool_call>$/i, '') + .trim(); + try { + const parsed = JSON.parse(inner); + if (parsed && typeof parsed.name === 'string') { + const args = + typeof parsed.arguments === 'string' + ? parsed.arguments + : JSON.stringify(parsed.arguments ?? {}); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, + name: parsed.name, + arguments: args, + }; + } + } catch {} + return null; +} + +function isPotentialTagPrefix(s: string): boolean { + if (!s.startsWith('<')) return false; + const lower = s.toLowerCase(); + let rest = lower.startsWith(' t.startsWith(rest) || rest.startsWith(t)); +} + +export class DsmlStreamParser { + private _buffer = ''; + private _hasExtractedToolCalls = false; + + get hasExtractedToolCalls(): boolean { + return this._hasExtractedToolCalls; + } + + feed(chunk: string): StreamedMessagePart[] { + this._buffer += chunk; + const parts: StreamedMessagePart[] = []; + + while (this._buffer.length > 0) { + const ltIdx = this._buffer.indexOf('<'); + if (ltIdx === -1) { + parts.push({ type: 'text', text: this._buffer }); + this._buffer = ''; + break; + } + + if (ltIdx > 0) { + parts.push({ type: 'text', text: this._buffer.slice(0, ltIdx) }); + this._buffer = this._buffer.slice(ltIdx); + } + + const openContainer = CONTAINER_OPEN_RE.exec(this._buffer); + if (openContainer) { + this._buffer = this._buffer.slice(openContainer[0].length); + if (/^\s* 0) { + const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); + if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { + const toolCall = parseInvokeTag(this._buffer); + if (toolCall) { + this._hasExtractedToolCalls = true; + parts.push(toolCall); + this._buffer = ''; + return parts; + } + } + const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); + if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { + const toolCall = parseHermesToolCall(this._buffer); + if (toolCall) { + this._hasExtractedToolCalls = true; + parts.push(toolCall); + this._buffer = ''; + return parts; + } + } + parts.push({ type: 'text', text: this._buffer }); + this._buffer = ''; + } + return parts; + } +} + +export function extractDsmlToolCalls(text: string): { + cleanText: string; + toolCalls: ToolCall[]; +} { + const parser = new DsmlStreamParser(); + const parts = [...parser.feed(text), ...parser.flush()]; + const toolCalls: ToolCall[] = []; + const textParts: string[] = []; + + for (const part of parts) { + if (part.type === 'function') { + toolCalls.push(part); + } else if (part.type === 'text') { + textParts.push(part.text); + } + } + + const cleanText = toolCalls.length > 0 ? textParts.join('').trim() : text; + return { cleanText, toolCalls }; +} diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 9117ec7f3..ffbc6d5ab 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -32,6 +32,7 @@ import { type BufferedChatCompletionToolCall, } from './chat-completions-stream'; import { ReasoningKeyDialect } from './reasoning-key'; +import { DsmlStreamParser, extractDsmlToolCalls } from './dsml-tool-parser'; import { mergeRequestHeaders, requireProviderApiKey, @@ -333,6 +334,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { private _usage: TokenUsage | null = null; private _finishReason: FinishReason | null = null; private _rawFinishReason: string | null = null; + private _hasExtractedToolCalls = false; private readonly _iter: AsyncGenerator; constructor( @@ -362,6 +364,12 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } get finishReason(): FinishReason | null { + if ( + this._hasExtractedToolCalls && + (this._finishReason === 'completed' || this._finishReason === null) + ) { + return 'tool_calls'; + } return this._finishReason; } @@ -399,8 +407,19 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } - if (message.content) { - yield { type: 'text', text: message.content } satisfies StreamedMessagePart; + let text = message.content ?? null; + let extractedToolCalls: ToolCall[] = []; + if (text) { + const parsed = extractDsmlToolCalls(text); + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + this._hasExtractedToolCalls = true; + } + } + + if (text && text.length > 0) { + yield { type: 'text', text } satisfies StreamedMessagePart; } if (message.tool_calls) { @@ -414,6 +433,10 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } satisfies ToolCall; } } + + for (const toolCall of extractedToolCalls) { + yield toolCall satisfies ToolCall; + } } private async *_convertStreamResponse( @@ -421,6 +444,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { reasoningKeyDialect: ReasoningKeyDialect, ): AsyncGenerator { const bufferedToolCalls = new Map(); + const dsmlParser = new DsmlStreamParser(); try { for await (const chunk of response) { @@ -456,7 +480,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // text content if (delta.content) { - yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; + for (const part of dsmlParser.feed(delta.content)) { + yield part; + } } // tool calls — preserve `index` on every yielded part so the generate @@ -467,6 +493,13 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } } } + + for (const part of dsmlParser.flush()) { + yield part; + } + if (dsmlParser.hasExtractedToolCalls) { + this._hasExtractedToolCalls = true; + } } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/kosong/src/providers/pythinker.ts b/packages/kosong/src/providers/pythinker.ts index 3718f6478..9ce464109 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -33,6 +33,7 @@ import { toolToOpenAI, } from './openai-common'; import { ReasoningKeyDialect, type ReasoningKey } from './reasoning-key'; +import { DsmlStreamParser, extractDsmlToolCalls } from './dsml-tool-parser'; import { mergeRequestHeaders, requireProviderApiKey, @@ -262,6 +263,7 @@ class PythinkerStreamedMessage implements StreamedMessage { private _usage: TokenUsage | null = null; private _finishReason: FinishReason | null = null; private _rawFinishReason: string | null = null; + private _hasExtractedToolCalls = false; private readonly _iter: AsyncGenerator; constructor( @@ -288,6 +290,12 @@ class PythinkerStreamedMessage implements StreamedMessage { } get finishReason(): FinishReason | null { + if ( + this._hasExtractedToolCalls && + (this._finishReason === 'completed' || this._finishReason === null) + ) { + return 'tool_calls'; + } return this._finishReason; } @@ -329,8 +337,19 @@ class PythinkerStreamedMessage implements StreamedMessage { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } - if (message.content) { - yield { type: 'text', text: message.content } satisfies StreamedMessagePart; + let text = message.content ?? null; + let extractedToolCalls: ToolCall[] = []; + if (text) { + const parsed = extractDsmlToolCalls(text); + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + this._hasExtractedToolCalls = true; + } + } + + if (text && text.length > 0) { + yield { type: 'text', text } satisfies StreamedMessagePart; } if (message.tool_calls) { @@ -344,12 +363,17 @@ class PythinkerStreamedMessage implements StreamedMessage { } satisfies ToolCall; } } + + for (const toolCall of extractedToolCalls) { + yield toolCall satisfies ToolCall; + } } private async *_convertStreamResponse( response: AsyncIterable, ): AsyncGenerator { const bufferedToolCalls = new Map(); + const dsmlParser = new DsmlStreamParser(); try { for await (const chunk of response) { @@ -389,7 +413,9 @@ class PythinkerStreamedMessage implements StreamedMessage { // text content if (delta.content) { - yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; + for (const part of dsmlParser.feed(delta.content)) { + yield part; + } } // tool calls — preserve `index` on every yielded part so the generate @@ -400,6 +426,13 @@ class PythinkerStreamedMessage implements StreamedMessage { } } } + + for (const part of dsmlParser.flush()) { + yield part; + } + if (dsmlParser.hasExtractedToolCalls) { + this._hasExtractedToolCalls = true; + } } catch (error: unknown) { throw convertOpenAIError(error, classifyPythinkerQuotaError); } diff --git a/packages/kosong/test/dsml-tool-parser.test.ts b/packages/kosong/test/dsml-tool-parser.test.ts new file mode 100644 index 000000000..faa628a09 --- /dev/null +++ b/packages/kosong/test/dsml-tool-parser.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; + +import { + DsmlStreamParser, + extractDsmlToolCalls, +} from '#/providers/dsml-tool-parser'; + +describe('DsmlStreamParser and extractDsmlToolCalls', () => { + describe('extractDsmlToolCalls', () => { + it('extracts standard DeepSeek DSML tool calls with fullwidth bars', () => { + const input = `I will read the file. +<|DSML|tool_calls> +<|DSML|invoke name="Read"> +<|DSML|parameter name="filePath" string="true">src/index.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('I will read the file.'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/index.ts', + }); + expect(result.toolCalls[0]?.id).toMatch(/^call_/); + }); + + it('extracts DSML tool calls with standard ASCII pipes', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Glob"> +<|DSML|parameter name="pattern" string="true">**/*.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Glob'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + pattern: '**/*.ts', + }); + }); + + it('extracts multiple invokes with mixed typed parameters', () => { + const input = `<|DSML|tool_calls> +<|DSML|invoke name="Search"> +<|DSML|parameter name="query" string="true">export function +<|DSML|parameter name="limit" string="false">25 +<|DSML|parameter name="caseSensitive" string="false">true +<|DSML|parameter name="filter" string="false">{"type": "code"} + +<|DSML|invoke name="Read"> +<|DSML|parameter name="path">src/main.ts + +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls[0]?.name).toBe('Search'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + query: 'export function', + limit: 25, + caseSensitive: true, + filter: { type: 'code' }, + }); + expect(result.toolCalls[1]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[1]?.arguments ?? '{}')).toEqual({ + path: 'src/main.ts', + }); + }); + + it('extracts invoke without container tag', () => { + const input = `Checking directory: +<|DSML|invoke name="ListDir"> +<|DSML|parameter name="dir" string="true">packages +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe('Checking directory:'); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('ListDir'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + dir: 'packages', + }); + }); + + it('extracts Hermes tool_call JSON format', () => { + const input = ` +{"name": "Read", "arguments": {"filePath": "package.json"}} +`; + + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(''); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + filePath: 'package.json', + }); + }); + + it('decodes XML entities in parameter values', () => { + const input = `<|DSML|invoke name="Eval"> +<|DSML|parameter name="code" string="true">a && b < c +`; + + const result = extractDsmlToolCalls(input); + expect(result.toolCalls).toHaveLength(1); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual({ + code: 'a && b < c', + }); + }); + + it('preserves regular non-tool tags and operators in text', () => { + const input = 'Check if 5 < 10 and 20 > 15, or use
Hello
and vector.'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + + it('preserves surrounding whitespace and markdown hard breaks in non-tool text', () => { + const input = ' Line 1 \nLine 2 '; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + + it('preserves malformed invoke block as text without discarding content', () => { + const input = '<|DSML|invoke>malformed content without name'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + + it('preserves malformed Hermes block as text without discarding content', () => { + const input = 'not valid json'; + const result = extractDsmlToolCalls(input); + expect(result.cleanText).toBe(input); + expect(result.toolCalls).toHaveLength(0); + }); + }); + + describe('DsmlStreamParser', () => { + it('streams normal text without modification', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('Hello world! '), + ...parser.feed('How are you today?'), + ...parser.flush(), + ]; + + expect(parts).toEqual([ + { type: 'text', text: 'Hello world! ' }, + { type: 'text', text: 'How are you today?' }, + ]); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + + it('handles stream split across DSML container and invoke chunks', () => { + const parser = new DsmlStreamParser(); + const chunks = [ + 'Looking into the code...\n\n', + '<', + '|DSML', + '|tool_calls>\n', + '<|DSML|invoke name="Read">\n', + '<|DSML|parameter name="filePath" ', + 'string="true">src/app.ts', + '\n', + '\n', + '\n', + 'Done reading.', + ]; + + const parts = []; + for (const chunk of chunks) { + parts.push(...parser.feed(chunk)); + } + parts.push(...parser.flush()); + + expect(parser.hasExtractedToolCalls).toBe(true); + + const textParts = parts.filter((p) => p.type === 'text'); + const toolParts = parts.filter((p) => p.type === 'function'); + + expect(textParts.map((p) => p.text).join('')).toBe( + 'Looking into the code...\n\nDone reading.', + ); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]?.name).toBe('Read'); + expect(JSON.parse(toolParts[0]?.arguments ?? '{}')).toEqual({ + filePath: 'src/app.ts', + }); + }); + + it('correctly flushes partial code comparisons that look like tags', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('if (x <'), + ...parser.feed(' 5 && y > 2)'), + ...parser.flush(), + ]; + + const fullText = parts.filter((p) => p.type === 'text').map((p) => p.text).join(''); + expect(fullText).toBe('if (x < 5 && y > 2)'); + expect(parser.hasExtractedToolCalls).toBe(false); + }); + + it('handles stream split after whitespace in tag prefix', () => { + const parser = new DsmlStreamParser(); + const chunks = [ + '< ', + '| DSML ', + '| invoke name="Read">\n< | DSML | parameter name="filePath">src/app.ts\n', + ]; + + const parts = []; + for (const chunk of chunks) { + parts.push(...parser.feed(chunk)); + } + parts.push(...parser.flush()); + + expect(parser.hasExtractedToolCalls).toBe(true); + const toolParts = parts.filter((p) => p.type === 'function'); + expect(toolParts).toHaveLength(1); + expect(toolParts[0]?.name).toBe('Read'); + }); + + it('preserves unclosed Hermes block at flush as text', () => { + const parser = new DsmlStreamParser(); + const parts = [ + ...parser.feed('{"name": "Read"}'), + ...parser.flush(), + ]; + + expect(parser.hasExtractedToolCalls).toBe(false); + expect(parts).toEqual([ + { type: 'text', text: '{"name": "Read"}' }, + ]); + }); + }); +}); diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 55dfd8c36..687e4020a 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -2234,4 +2234,135 @@ describe('OpenAILegacyChatProvider — non-indexed streaming tool_calls', () => expect(parts).toEqual([]); }); + + it('parses streamed DSML tool calls from delta.content and sets finishReason to tool_calls', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: true, + }); + + const chunks = [ + { + id: 'chatcmpl-dsml-1', + choices: [ + { + index: 0, + delta: { content: 'Inspecting repository files.\n\n<|DSML|tool_calls>\n<|DSML|invoke name="Read">' }, + }, + ], + }, + { + id: 'chatcmpl-dsml-1', + choices: [ + { + index: 0, + delta: { + content: + '<|DSML|parameter name="filePath" string="true">src/main.ts\n\n', + }, + finish_reason: 'stop', + }, + ], + }, + ]; + + ( + provider as unknown as { _client: { chat: { completions: { create: unknown } } } } + )._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks)); + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(stream.finishReason).toBe('tool_calls'); + expect(parts).toHaveLength(2); + expect(parts[0]).toEqual({ + type: 'text', + text: 'Inspecting repository files.\n\n', + }); + expect(parts[1]).toMatchObject({ + type: 'function', + name: 'Read', + arguments: '{"filePath":"src/main.ts"}', + }); + }); + + it('parses non-streamed DSML tool calls from message.content and sets finishReason to tool_calls', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: false, + }); + + const response = { + id: 'chatcmpl-dsml-nonstream', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: + '<|DSML|tool_calls>\n<|DSML|invoke name="Glob">\n<|DSML|parameter name="pattern" string="true">*.ts\n\n', + }, + finish_reason: 'stop', + }, + ], + }; + + ( + provider as unknown as { _client: { chat: { completions: { create: unknown } } } } + )._client.chat.completions.create = vi.fn().mockResolvedValue(response); + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(stream.finishReason).toBe('tool_calls'); + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ + type: 'function', + name: 'Glob', + arguments: '{"pattern":"*.ts"}', + }); + }); + + it('preserves surrounding whitespace and markdown hard breaks in non-stream response', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: false, + }); + + const textWithWhitespace = ' Line 1 \nLine 2 '; + const response = { + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: textWithWhitespace, + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }; + + ( + provider as unknown as { + _client: { chat: { completions: { create: unknown } } }; + } + )._client.chat.completions.create = vi.fn().mockResolvedValue(response); + + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + + expect(parts).toHaveLength(1); + expect(parts[0]).toEqual({ + type: 'text', + text: textWithWhitespace, + }); + }); });