From a1b26c2d948c39644c5676cadb299ffd4cff638b Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 4 Sep 2026 17:29:51 -0400 Subject: [PATCH 1/3] fix: parse leaked dsml tool calls from model content --- .changeset/fix-dsml-tool-calls.md | 5 + .../provider/bases/openai/dsml-tool-parser.ts | 308 ++++++++++++++++++ .../provider/bases/openai/openai-legacy.ts | 39 ++- .../session/expertTalk/expertTalkService.ts | 9 +- .../kosong/provider/dsml-tool-parser.test.ts | 298 +++++++++++++++++ .../expertTalk/expertTalkService.test.ts | 50 +++ .../kosong/src/providers/dsml-tool-parser.ts | 308 ++++++++++++++++++ .../kosong/src/providers/openai-legacy.ts | 39 ++- packages/kosong/src/providers/pythinker.ts | 39 ++- packages/kosong/test/dsml-tool-parser.test.ts | 188 +++++++++++ packages/kosong/test/openai-legacy.test.ts | 92 ++++++ 11 files changed, 1363 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-dsml-tool-calls.md create mode 100644 packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts create mode 100644 packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts create mode 100644 packages/kosong/src/providers/dsml-tool-parser.ts create mode 100644 packages/kosong/test/dsml-tool-parser.test.ts 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..531e3bdf2 --- /dev/null +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts @@ -0,0 +1,308 @@ +import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; + +const CANDIDATE_TARGETS = [ + 'dsml|tool_calls', + 'dsml|tool_calls', + 'dsmltool_calls', + 'dsml|invoke', + 'dsml|invoke', + 'dsmlinvoke', + 'tool_calls', + 'tool_call', + 'invoke', +]; + +const CONTAINER_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; +const CONTAINER_CLOSE_RE = /^<\/[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; +const INVOKE_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*invoke(?:\s+[^>]*)?>/i; +const INVOKE_CLOSE_RE = /<\/[||]?\s*(?:DSML[||]?)?\s*invoke\s*>/i; +const HERMES_OPEN_RE = /^/i; +const HERMES_CLOSE_RE = /<\/tool_call>/i; + +function unescapeXml(value: string): string { + return value + .replaceAll(/"/g, '"') + .replaceAll(/'/g, "'") + .replaceAll(/</g, '<') + .replaceAll(/>/g, '>') + .replaceAll(/&/g, '&'); +} + +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*(?:DSML[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/[||]?\s*(?:DSML[||]?)?\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*(?:DSML[||]?)?\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); + const innerContent = closeMatch + ? invokeBlock.slice(openMatch[0].length, closeMatch.index) + : invokeBlock.slice(openMatch[0].length); + + const args = parseInvokeBody(innerContent); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + name: toolName, + arguments: JSON.stringify(args), + }; +} + +function parseHermesToolCall(toolCallBlock: string): ToolCall | 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(/-/g, '').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(' target.startsWith(rest) || rest.startsWith(target)); +} + +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) { + const toolCall = parseInvokeTag(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 = textParts.join('').trim(); + 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..e582cacac 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); + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + if (extractedToolCalls.length > 0) { + 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..b37ecfe25 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 (/<[||]?\s*(?:DSML[||]?)?(?:tool_calls|invoke)\b/i.test(text)) { + throw new Error('Discussion stage output contains unparsed tool call markup'); + } return text; }; let text = await request(prompt, content); @@ -1895,10 +1898,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( 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..e63ee09fd --- /dev/null +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it, vi } 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"}', + }); + }); + }); +}); 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..4bd481c57 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,56 @@ 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('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..eb8b7a67b --- /dev/null +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -0,0 +1,308 @@ +import type { StreamedMessagePart, ToolCall } from '#/message'; + +const CANDIDATE_TARGETS = [ + 'dsml|tool_calls', + 'dsml|tool_calls', + 'dsmltool_calls', + 'dsml|invoke', + 'dsml|invoke', + 'dsmlinvoke', + 'tool_calls', + 'tool_call', + 'invoke', +]; + +const CONTAINER_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; +const CONTAINER_CLOSE_RE = /^<\/[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; +const INVOKE_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*invoke(?:\s+[^>]*)?>/i; +const INVOKE_CLOSE_RE = /<\/[||]?\s*(?:DSML[||]?)?\s*invoke\s*>/i; +const HERMES_OPEN_RE = /^/i; +const HERMES_CLOSE_RE = /<\/tool_call>/i; + +function unescapeXml(value: string): string { + return value + .replaceAll(/"/g, '"') + .replaceAll(/'/g, "'") + .replaceAll(/</g, '<') + .replaceAll(/>/g, '>') + .replaceAll(/&/g, '&'); +} + +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*(?:DSML[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/[||]?\s*(?:DSML[||]?)?\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*(?:DSML[||]?)?\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); + const innerContent = closeMatch + ? invokeBlock.slice(openMatch[0].length, closeMatch.index) + : invokeBlock.slice(openMatch[0].length); + + const args = parseInvokeBody(innerContent); + return { + type: 'function', + id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + name: toolName, + arguments: JSON.stringify(args), + }; +} + +function parseHermesToolCall(toolCallBlock: string): ToolCall | 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(/-/g, '').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(' target.startsWith(rest) || rest.startsWith(target)); +} + +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) { + const toolCall = parseInvokeTag(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 = textParts.join('').trim(); + return { cleanText, toolCalls }; +} diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 9117ec7f3..0cae884ba 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); + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + if (extractedToolCalls.length > 0) { + 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..971d868c2 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); + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; + if (extractedToolCalls.length > 0) { + 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..64531120f --- /dev/null +++ b/packages/kosong/test/dsml-tool-parser.test.ts @@ -0,0 +1,188 @@ +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); + }); + }); + + 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); + }); + }); +}); diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 55dfd8c36..f2b2662c1 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -2234,4 +2234,96 @@ 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"}', + }); + }); }); From 20784b08cb105dcebbaf0d3ac31741a262292cd8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 4 Sep 2026 18:05:14 -0400 Subject: [PATCH 2/3] fix: address review findings on DSML tool parsing and stage validation --- .../provider/bases/openai/dsml-tool-parser.ts | 64 ++++--- .../provider/bases/openai/openai-legacy.ts | 6 +- .../session/expertTalk/expertTalkService.ts | 7 +- .../kosong/provider/dsml-tool-parser.test.ts | 62 ++++++- .../expertTalk/expertTalkService.test.ts | 159 ++++++++++++++++++ .../kosong/src/providers/dsml-tool-parser.ts | 64 ++++--- .../kosong/src/providers/openai-legacy.ts | 6 +- packages/kosong/src/providers/pythinker.ts | 6 +- packages/kosong/test/dsml-tool-parser.test.ts | 21 +++ packages/kosong/test/openai-legacy.test.ts | 39 +++++ 10 files changed, 375 insertions(+), 59 deletions(-) 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 index 531e3bdf2..a09115995 100644 --- 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 @@ -12,20 +12,20 @@ const CANDIDATE_TARGETS = [ 'invoke', ]; -const CONTAINER_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; -const CONTAINER_CLOSE_RE = /^<\/[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; -const INVOKE_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*invoke(?:\s+[^>]*)?>/i; -const INVOKE_CLOSE_RE = /<\/[||]?\s*(?:DSML[||]?)?\s*invoke\s*>/i; +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(/"/g, '"') - .replaceAll(/'/g, "'") - .replaceAll(/</g, '<') - .replaceAll(/>/g, '>') - .replaceAll(/&/g, '&'); + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); } function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): unknown { @@ -60,7 +60,7 @@ function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): function parseInvokeBody(invokeContent: string): Record { const paramRegex = - /<[||]?\s*(?:DSML[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/[||]?\s*(?:DSML[||]?)?\s*parameter\s*>/gi; + /<\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; @@ -101,7 +101,7 @@ function parseInvokeBody(invokeContent: string): Record { } function parseInvokeTag(invokeBlock: string): ToolCall | null { - const openMatch = /^<[||]?\s*(?:DSML[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); if (!openMatch) return null; const attrStr = openMatch[1] ?? ''; @@ -117,7 +117,7 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { const args = parseInvokeBody(innerContent); return { type: 'function', - id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, name: toolName, arguments: JSON.stringify(args), }; @@ -137,7 +137,7 @@ function parseHermesToolCall(toolCallBlock: string): ToolCall | null { : JSON.stringify(parsed.arguments ?? {}); return { type: 'function', - id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, name: parsed.name, arguments: args, }; @@ -216,12 +216,15 @@ export class DsmlStreamParser { if (toolCall) { this._hasExtractedToolCalls = true; parts.push(toolCall); - } - this._buffer = this._buffer.slice(invokeEnd); - if (/^\s* 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 e582cacac..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 @@ -431,9 +431,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { let extractedToolCalls: ToolCall[] = []; if (text) { const parsed = extractDsmlToolCalls(text); - text = parsed.cleanText; - extractedToolCalls = parsed.toolCalls; - if (extractedToolCalls.length > 0) { + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; this._hasExtractedToolCalls = true; } } diff --git a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts index b37ecfe25..c46b47822 100644 --- a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts +++ b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts @@ -1152,7 +1152,7 @@ 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 (/<[||]?\s*(?:DSML[||]?)?(?:tool_calls|invoke)\b/i.test(text)) { + if (hasUnparsedToolCallMarkup(text)) { throw new Error('Discussion stage output contains unparsed tool call markup'); } return text; @@ -1220,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 ) { @@ -2070,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 index e63ee09fd..54b5ee8d0 100644 --- 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 @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { DsmlStreamParser, @@ -294,5 +294,65 @@ describe('agent-core-v2: DsmlStreamParser and extractDsmlToolCalls', () => { 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); + }); }); }); 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 4bd481c57..c02327f6f 100644 --- a/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts +++ b/packages/agent-core-v2/test/session/expertTalk/expertTalkService.test.ts @@ -501,6 +501,165 @@ describe('SessionExpertTalkService', () => { }); }); + 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 index eb8b7a67b..2f7ca2120 100644 --- a/packages/kosong/src/providers/dsml-tool-parser.ts +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -12,20 +12,20 @@ const CANDIDATE_TARGETS = [ 'invoke', ]; -const CONTAINER_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; -const CONTAINER_CLOSE_RE = /^<\/[||]?\s*(?:DSML[||]?)?\s*tool_calls\s*>/i; -const INVOKE_OPEN_RE = /^<[||]?\s*(?:DSML[||]?)?\s*invoke(?:\s+[^>]*)?>/i; -const INVOKE_CLOSE_RE = /<\/[||]?\s*(?:DSML[||]?)?\s*invoke\s*>/i; +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(/"/g, '"') - .replaceAll(/'/g, "'") - .replaceAll(/</g, '<') - .replaceAll(/>/g, '>') - .replaceAll(/&/g, '&'); + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); } function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): unknown { @@ -60,7 +60,7 @@ function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): function parseInvokeBody(invokeContent: string): Record { const paramRegex = - /<[||]?\s*(?:DSML[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/[||]?\s*(?:DSML[||]?)?\s*parameter\s*>/gi; + /<\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; @@ -101,7 +101,7 @@ function parseInvokeBody(invokeContent: string): Record { } function parseInvokeTag(invokeBlock: string): ToolCall | null { - const openMatch = /^<[||]?\s*(?:DSML[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); if (!openMatch) return null; const attrStr = openMatch[1] ?? ''; @@ -117,7 +117,7 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { const args = parseInvokeBody(innerContent); return { type: 'function', - id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, name: toolName, arguments: JSON.stringify(args), }; @@ -137,7 +137,7 @@ function parseHermesToolCall(toolCallBlock: string): ToolCall | null { : JSON.stringify(parsed.arguments ?? {}); return { type: 'function', - id: `call_${crypto.randomUUID().replaceAll(/-/g, '').slice(0, 24)}`, + id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, name: parsed.name, arguments: args, }; @@ -216,12 +216,15 @@ export class DsmlStreamParser { if (toolCall) { this._hasExtractedToolCalls = true; parts.push(toolCall); - } - this._buffer = this._buffer.slice(invokeEnd); - if (/^\s* 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 0cae884ba..ffbc6d5ab 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -411,9 +411,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { let extractedToolCalls: ToolCall[] = []; if (text) { const parsed = extractDsmlToolCalls(text); - text = parsed.cleanText; - extractedToolCalls = parsed.toolCalls; - if (extractedToolCalls.length > 0) { + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; this._hasExtractedToolCalls = true; } } diff --git a/packages/kosong/src/providers/pythinker.ts b/packages/kosong/src/providers/pythinker.ts index 971d868c2..9ce464109 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -341,9 +341,9 @@ class PythinkerStreamedMessage implements StreamedMessage { let extractedToolCalls: ToolCall[] = []; if (text) { const parsed = extractDsmlToolCalls(text); - text = parsed.cleanText; - extractedToolCalls = parsed.toolCalls; - if (extractedToolCalls.length > 0) { + if (parsed.toolCalls.length > 0) { + text = parsed.cleanText; + extractedToolCalls = parsed.toolCalls; this._hasExtractedToolCalls = true; } } diff --git a/packages/kosong/test/dsml-tool-parser.test.ts b/packages/kosong/test/dsml-tool-parser.test.ts index 64531120f..6cc789ef4 100644 --- a/packages/kosong/test/dsml-tool-parser.test.ts +++ b/packages/kosong/test/dsml-tool-parser.test.ts @@ -117,6 +117,27 @@ describe('DsmlStreamParser and extractDsmlToolCalls', () => { 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', () => { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index f2b2662c1..687e4020a 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -2326,4 +2326,43 @@ describe('OpenAILegacyChatProvider — non-indexed streaming tool_calls', () => 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, + }); + }); }); From 279fb393788e931643bf87148cc7ce74e6faafa1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 4 Sep 2026 18:17:12 -0400 Subject: [PATCH 3/3] fix: require closing tag before flush extraction and handle whitespace in tag prefixes --- .../provider/bases/openai/dsml-tool-parser.ts | 34 ++++++++----------- .../kosong/provider/dsml-tool-parser.test.ts | 33 ++++++++++++++++++ .../kosong/src/providers/dsml-tool-parser.ts | 34 ++++++++----------- packages/kosong/test/dsml-tool-parser.test.ts | 33 ++++++++++++++++++ 4 files changed, 96 insertions(+), 38 deletions(-) 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 index a09115995..998e3d0f8 100644 --- 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 @@ -1,17 +1,5 @@ import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; -const CANDIDATE_TARGETS = [ - 'dsml|tool_calls', - 'dsml|tool_calls', - 'dsmltool_calls', - 'dsml|invoke', - 'dsml|invoke', - 'dsmlinvoke', - 'tool_calls', - 'tool_call', - 'invoke', -]; - 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; @@ -110,9 +98,8 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { if (!toolName) return null; const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); - const innerContent = closeMatch - ? invokeBlock.slice(openMatch[0].length, closeMatch.index) - : invokeBlock.slice(openMatch[0].length); + if (!closeMatch) return null; + const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); const args = parseInvokeBody(innerContent); return { @@ -124,6 +111,7 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { } function parseHermesToolCall(toolCallBlock: string): ToolCall | null { + if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; const inner = toolCallBlock .replace(/^/i, '') .replace(/<\/tool_call>$/i, '') @@ -150,11 +138,19 @@ function isPotentialTagPrefix(s: string): boolean { if (!s.startsWith('<')) return false; const lower = s.toLowerCase(); let rest = lower.startsWith(' target.startsWith(rest) || rest.startsWith(target)); + const targets = ['tool_calls', 'tool_call', 'invoke', 'parameter']; + return targets.some((t) => t.startsWith(rest) || rest.startsWith(t)); } export class DsmlStreamParser { @@ -276,7 +272,7 @@ export class DsmlStreamParser { const parts: StreamedMessagePart[] = []; if (this._buffer.length > 0) { const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen) { + if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { const toolCall = parseInvokeTag(this._buffer); if (toolCall) { this._hasExtractedToolCalls = true; @@ -286,7 +282,7 @@ export class DsmlStreamParser { } } const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); - if (hermesOpen) { + if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { const toolCall = parseHermesToolCall(this._buffer); if (toolCall) { this._hasExtractedToolCalls = true; 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 index 54b5ee8d0..fc66fe0b7 100644 --- 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 @@ -354,5 +354,38 @@ describe('agent-core-v2: DsmlStreamParser and extractDsmlToolCalls', () => { 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/kosong/src/providers/dsml-tool-parser.ts b/packages/kosong/src/providers/dsml-tool-parser.ts index 2f7ca2120..63bd8f69c 100644 --- a/packages/kosong/src/providers/dsml-tool-parser.ts +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -1,17 +1,5 @@ import type { StreamedMessagePart, ToolCall } from '#/message'; -const CANDIDATE_TARGETS = [ - 'dsml|tool_calls', - 'dsml|tool_calls', - 'dsmltool_calls', - 'dsml|invoke', - 'dsml|invoke', - 'dsmlinvoke', - 'tool_calls', - 'tool_call', - 'invoke', -]; - 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; @@ -110,9 +98,8 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { if (!toolName) return null; const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); - const innerContent = closeMatch - ? invokeBlock.slice(openMatch[0].length, closeMatch.index) - : invokeBlock.slice(openMatch[0].length); + if (!closeMatch) return null; + const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); const args = parseInvokeBody(innerContent); return { @@ -124,6 +111,7 @@ function parseInvokeTag(invokeBlock: string): ToolCall | null { } function parseHermesToolCall(toolCallBlock: string): ToolCall | null { + if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; const inner = toolCallBlock .replace(/^/i, '') .replace(/<\/tool_call>$/i, '') @@ -150,11 +138,19 @@ function isPotentialTagPrefix(s: string): boolean { if (!s.startsWith('<')) return false; const lower = s.toLowerCase(); let rest = lower.startsWith(' target.startsWith(rest) || rest.startsWith(target)); + const targets = ['tool_calls', 'tool_call', 'invoke', 'parameter']; + return targets.some((t) => t.startsWith(rest) || rest.startsWith(t)); } export class DsmlStreamParser { @@ -276,7 +272,7 @@ export class DsmlStreamParser { const parts: StreamedMessagePart[] = []; if (this._buffer.length > 0) { const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen) { + if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { const toolCall = parseInvokeTag(this._buffer); if (toolCall) { this._hasExtractedToolCalls = true; @@ -286,7 +282,7 @@ export class DsmlStreamParser { } } const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); - if (hermesOpen) { + if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { const toolCall = parseHermesToolCall(this._buffer); if (toolCall) { this._hasExtractedToolCalls = true; diff --git a/packages/kosong/test/dsml-tool-parser.test.ts b/packages/kosong/test/dsml-tool-parser.test.ts index 6cc789ef4..faa628a09 100644 --- a/packages/kosong/test/dsml-tool-parser.test.ts +++ b/packages/kosong/test/dsml-tool-parser.test.ts @@ -205,5 +205,38 @@ describe('DsmlStreamParser and extractDsmlToolCalls', () => { 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"}' }, + ]); + }); }); });