测试DSML

This commit is contained in:
CJACK
2026-04-27 00:21:26 +08:00
parent 645fce41c8
commit 40d5e3ebb5
50 changed files with 1112 additions and 265 deletions

View File

@@ -8,14 +8,18 @@ import (
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, toolNames)
return BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText, detected.Calls)
}
func BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText string, detected []toolcall.ParsedToolCall) map[string]any {
finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" {
messageObj["reasoning_content"] = finalThinking
}
if len(detected.Calls) > 0 {
if len(detected) > 0 {
finishReason = "tool_calls"
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected.Calls)
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected)
messageObj["content"] = nil
}

View File

@@ -13,11 +13,15 @@ func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalTex
// Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls.
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, toolNames)
return BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, finalThinking, finalText, detected.Calls)
}
func BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, finalThinking, finalText string, detected []toolcall.ParsedToolCall) map[string]any {
exposedOutputText := finalText
output := make([]any, 0, 2)
if len(detected.Calls) > 0 {
if len(detected) > 0 {
exposedOutputText = ""
output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
output = append(output, toResponsesFunctionCallItems(detected)...)
} else {
content := make([]any, 0, 2)
if finalThinking != "" {

View File

@@ -93,10 +93,10 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "<tool_calls>") || !containsStr(content, `<invoke name="search_web">`) {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
if !containsStr(content, "<|DSML|tool_calls>") || !containsStr(content, `<|DSML|invoke name="search_web">`) {
t.Fatalf("expected assistant content to include DSML tool call history, got %q", content)
}
if !containsStr(content, `<parameter name="query"><![CDATA[latest]]></parameter>`) {
if !containsStr(content, `<|DSML|parameter name="query"><![CDATA[latest]]></|DSML|parameter>`) {
t.Fatalf("expected assistant content to include serialized parameters, got %q", content)
}
}
@@ -292,8 +292,8 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt")
if !containsStr(prompt, "<|DSML|tool_calls>") {
t.Fatalf("expected DSML tool_calls format in prompt")
}
if !containsStr(prompt, "TOOL CALL FORMAT") {
t.Fatalf("expected tool call format header in prompt")

View File

@@ -1,7 +1,6 @@
package chat
import (
"ds2api/internal/toolcall"
"encoding/json"
"net/http"
"strings"
@@ -33,11 +32,12 @@ type chatStreamRuntime struct {
toolCallsEmitted bool
toolCallsDoneEmitted bool
toolSieve toolstream.State
streamToolCallIDs map[int]string
streamToolNames map[int]string
thinking strings.Builder
text strings.Builder
toolSieve toolstream.State
streamToolCallIDs map[int]string
streamToolNames map[int]string
thinking strings.Builder
toolDetectionThinking strings.Builder
text strings.Builder
finalThinking string
finalText string
@@ -130,10 +130,11 @@ func (s *chatStreamRuntime) resetStreamToolCallState() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalToolDetectionThinking := s.toolDetectionThinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
s.finalThinking = finalThinking
s.finalText = finalText
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, s.toolNames)
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
delta := map[string]any{
@@ -238,6 +239,12 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
newChoices := make([]map[string]any, 0, len(parsed.Parts))
contentSeen := false
for _, p := range parsed.ToolDetectionThinkingParts {
trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text)
if trimmed != "" {
s.toolDetectionThinking.WriteString(trimmed)
}
}
for _, p := range parsed.Parts {
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
if s.searchEnabled && sse.IsCitation(cleanedText) {

View File

@@ -134,3 +134,7 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta,
func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any {
return shared.FormatFinalStreamToolCallsWithStableIDs(calls, ids)
}
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
}

View File

@@ -15,7 +15,6 @@ import (
"ds2api/internal/promptcompat"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/toolcall"
)
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
@@ -159,11 +158,12 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co
stripReferenceMarkers := h.compatStripReferenceMarkers()
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
finalToolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
if searchEnabled {
finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks)
}
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, toolNames)
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, toolNames)
if shouldWriteUpstreamEmptyOutputError(finalText) && len(detected.Calls) == 0 {
status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking)
if historySession != nil {
@@ -172,7 +172,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co
writeUpstreamEmptyOutputError(w, finalText, finalThinking, result.ContentFilter)
return
}
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
respBody := openaifmt.BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText, detected.Calls)
finishReason := "stop"
if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 {
if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" {

View File

@@ -173,6 +173,34 @@ func TestHandleNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testing.T) {
}
}
func TestHandleNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"search\"><|DSML|parameter name=\"q\">from-hidden-thinking</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, resp, "cid-hidden-thinking-tool", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if _, ok := message["reasoning_content"]; ok {
t.Fatalf("expected hidden thinking not to be exposed, got %#v", message)
}
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one hidden-thinking tool call, got %#v", message["tool_calls"])
}
if got := asString(choice["finish_reason"]); got != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
}
func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
@@ -282,6 +310,39 @@ func TestHandleStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstreamIntercep
}
}
func TestHandleStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"search\"><|DSML|parameter name=\"q\">from-hidden-thinking</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid-hidden-thinking-stream", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta from hidden thinking fallback, body=%s", rec.Body.String())
}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if asString(delta["reasoning_content"]) != "" {
t.Fatalf("did not expect hidden reasoning_content delta, body=%s", rec.Body.String())
}
}
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(

View File

@@ -76,7 +76,7 @@ func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) {
if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") {
t.Fatalf("expected reasoning block preserved, got %q", transcript)
}
if !strings.Contains(transcript, "<tool_calls>") {
if !strings.Contains(transcript, "<|DSML|tool_calls>") {
t.Fatalf("expected tool calls preserved, got %q", transcript)
}
if !strings.HasSuffix(transcript, "\n[file name]: IGNORE\n[file content begin]\n") {

View File

@@ -11,6 +11,7 @@ import (
"ds2api/internal/httpapi/openai/history"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
"ds2api/internal/toolcall"
"ds2api/internal/toolstream"
)
@@ -115,3 +116,7 @@ func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string,
func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta {
return shared.FilterIncrementalToolCallDeltasByAllowed(deltas, seenNames)
}
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
}

View File

@@ -131,11 +131,12 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
result := sse.CollectStream(resp, thinkingEnabled, true)
stripReferenceMarkers := h.compatStripReferenceMarkers()
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
toolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
if searchEnabled {
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
}
textParsed := toolcall.ParseAssistantToolCallsDetailed(sanitizedText, sanitizedThinking, toolNames)
textParsed := detectAssistantToolCalls(sanitizedText, sanitizedThinking, toolDetectionThinking, toolNames)
if len(textParsed.Calls) == 0 && writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) {
return
}
@@ -147,7 +148,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, toolNames)
responseObj := openaifmt.BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, textParsed.Calls)
h.getResponseStore().put(owner, responseID, responseObj)
writeJSON(w, http.StatusOK, responseObj)
}

View File

@@ -34,24 +34,25 @@ type responsesStreamRuntime struct {
toolCallsEmitted bool
toolCallsDoneEmitted bool
sieve toolstream.State
thinking strings.Builder
text strings.Builder
visibleText strings.Builder
streamToolCallIDs map[int]string
functionItemIDs map[int]string
functionOutputIDs map[int]int
functionArgs map[int]string
functionDone map[int]bool
functionAdded map[int]bool
functionNames map[int]string
messageItemID string
messageOutputID int
nextOutputID int
messageAdded bool
messagePartAdded bool
sequence int
failed bool
sieve toolstream.State
thinking strings.Builder
toolDetectionThinking strings.Builder
text strings.Builder
visibleText strings.Builder
streamToolCallIDs map[int]string
functionItemIDs map[int]string
functionOutputIDs map[int]int
functionArgs map[int]string
functionDone map[int]bool
functionAdded map[int]bool
functionNames map[int]string
messageItemID string
messageOutputID int
nextOutputID int
messageAdded bool
messagePartAdded bool
sequence int
failed bool
persistResponse func(obj map[string]any)
}
@@ -127,13 +128,14 @@ func (s *responsesStreamRuntime) failResponse(status int, message, code string)
func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalToolDetectionThinking := s.toolDetectionThinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
if s.bufferToolContent {
s.processToolStreamEvents(toolstream.Flush(&s.sieve, s.toolNames), true, true)
}
textParsed := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, s.toolNames)
textParsed := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
detected := textParsed.Calls
s.logToolPolicyRejections(textParsed)
@@ -191,6 +193,12 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
}
contentSeen := false
for _, p := range parsed.ToolDetectionThinkingParts {
trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text)
if trimmed != "" {
s.toolDetectionThinking.WriteString(trimmed)
}
}
for _, p := range parsed.Parts {
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
if cleanedText == "" {

View File

@@ -265,6 +265,43 @@ func TestHandleResponsesStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstrea
}
}
func TestHandleResponsesStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(path, value string) string {
b, _ := json.Marshal(map[string]any{
"p": path,
"v": value,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("response/thinking_content", `<|DSML|tool_calls><|DSML|invoke name="read_file"><|DSML|parameter name="path">README.MD</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>`) + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
policy := promptcompat.ToolChoicePolicy{
Mode: promptcompat.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, policy, "")
body := rec.Body.String()
if strings.Contains(body, "event: response.reasoning.delta") {
t.Fatalf("did not expect hidden reasoning delta in stream body, got %s", body)
}
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected hidden-thinking fallback function call event, got %s", body)
}
if strings.Contains(body, "event: response.failed") {
t.Fatalf("did not expect response.failed, body=%s", body)
}
}
func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
@@ -410,6 +447,39 @@ func TestHandleResponsesNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testi
}
}
func TestHandleResponsesNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"read_file\"><|DSML|parameter name=\"path\">README.MD</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}` + "\n" +
`data: [DONE]` + "\n",
)),
}
policy := promptcompat.ToolChoicePolicy{
Mode: promptcompat.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, policy, "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
output, _ := out["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", out["output"])
}
first, _ := output[0].(map[string]any)
if got := asString(first["type"]); got != "function_call" {
t.Fatalf("expected function_call output, got %#v", first["type"])
}
if strings.Contains(rec.Body.String(), "reasoning") {
t.Fatalf("did not expect hidden reasoning in response body, got %s", rec.Body.String())
}
}
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
scanner := bufio.NewScanner(strings.NewReader(body))
matched := false

View File

@@ -0,0 +1,26 @@
package shared
import (
"strings"
"ds2api/internal/toolcall"
)
func DetectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
textParsed := toolcall.ParseStandaloneToolCallsDetailed(text, toolNames)
if len(textParsed.Calls) > 0 {
return textParsed
}
if strings.TrimSpace(text) != "" {
return textParsed
}
thinking := detectionThinking
if strings.TrimSpace(thinking) == "" {
thinking = exposedThinking
}
thinkingParsed := toolcall.ParseStandaloneToolCallsDetailed(thinking, toolNames)
if len(thinkingParsed.Calls) > 0 {
return thinkingParsed
}
return textParsed
}

View File

@@ -8,7 +8,7 @@ const {
stripFencedCodeBlocks,
} = require('./parse_payload');
const TOOL_MARKUP_PREFIXES = ['<tool_calls'];
const TOOL_MARKUP_PREFIXES = ['<tool_calls', '<|dsml|tool_calls'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {

View File

@@ -17,7 +17,11 @@ function stripFencedCodeBlocks(text) {
}
function parseMarkupToolCalls(text) {
const raw = toStringSafe(text).trim();
const normalized = normalizeDSMLToolCallMarkup(toStringSafe(text));
if (!normalized.ok) {
return [];
}
const raw = normalized.text.trim();
if (!raw) {
return [];
}
@@ -34,6 +38,103 @@ function parseMarkupToolCalls(text) {
return out;
}
function normalizeDSMLToolCallMarkup(text) {
const raw = toStringSafe(text);
if (!raw) {
return { text: '', ok: true };
}
const styles = toolMarkupStylesOutsideIgnored(raw);
if (styles.dsml && styles.canonical) {
return { text: raw, ok: false };
}
if (!styles.dsml) {
return { text: raw, ok: true };
}
return {
text: replaceDSMLToolMarkupOutsideIgnored(raw),
ok: true,
};
}
function containsDSMLToolMarkup(text) {
return toolMarkupStylesOutsideIgnored(text).dsml;
}
function containsCanonicalToolMarkup(text) {
return toolMarkupStylesOutsideIgnored(text).canonical;
}
const DSML_TOOL_MARKUP_ALIASES = [
{ from: '<|dsml|tool_calls', to: '<tool_calls' },
{ from: '</|dsml|tool_calls>', to: '</tool_calls>' },
{ from: '<|dsml|invoke', to: '<invoke' },
{ from: '</|dsml|invoke>', to: '</invoke>' },
{ from: '<|dsml|parameter', to: '<parameter' },
{ from: '</|dsml|parameter>', to: '</parameter>' },
];
const CANONICAL_TOOL_MARKUP_PREFIXES = [
'<tool_calls',
'</tool_calls>',
'<invoke',
'</invoke>',
'<parameter',
'</parameter>',
];
function toolMarkupStylesOutsideIgnored(text) {
const lower = toStringSafe(text).toLowerCase();
const styles = { dsml: false, canonical: false };
for (let i = 0; i < lower.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
return styles;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
if (CANONICAL_TOOL_MARKUP_PREFIXES.some(prefix => lower.startsWith(prefix, i))) {
styles.canonical = true;
}
if (DSML_TOOL_MARKUP_ALIASES.some(alias => lower.startsWith(alias.from, i))) {
styles.dsml = true;
}
if (styles.dsml && styles.canonical) {
return styles;
}
i += 1;
}
return styles;
}
function replaceDSMLToolMarkupOutsideIgnored(text) {
const raw = toStringSafe(text);
const lower = raw.toLowerCase();
let out = '';
for (let i = 0; i < raw.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
out += raw.slice(i);
break;
}
if (skipped.advanced) {
out += raw.slice(i, skipped.next);
i = skipped.next;
continue;
}
const alias = DSML_TOOL_MARKUP_ALIASES.find(item => lower.startsWith(item.from, i));
if (alias) {
out += alias.to;
i += alias.from.length;
continue;
}
out += raw[i];
i += 1;
}
return out;
}
function parseMarkupSingleToolCall(block) {
const attrs = parseTagAttributes(block.attrs);
const name = toStringSafe(attrs.name).trim();
@@ -403,4 +504,5 @@ function isOnlyRawValue(obj) {
module.exports = {
stripFencedCodeBlocks,
parseMarkupToolCalls,
normalizeDSMLToolCallMarkup,
};

View File

@@ -3,6 +3,7 @@ const { parseToolCalls } = require('./parse');
// XML wrapper tag pair used by the streaming sieve.
const XML_TOOL_TAG_PAIRS = [
{ open: '<|dsml|tool_calls', close: '</|dsml|tool_calls>' },
{ open: '<tool_calls', close: '</tool_calls>' },
];
@@ -41,6 +42,31 @@ function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
// If this block failed to become a tool call, pass it through as text.
return { ready: true, prefix: prefixPart + xmlBlock, calls: [], suffix: suffixPart };
}
if (!containsAnyToolCallWrapper(lower)) {
const found = firstInvokeIndex(lower);
if (found.index >= 0) {
const closeTag = found.dsml ? '</|dsml|tool_calls>' : '</tool_calls>';
const openWrapper = found.dsml ? '<|DSML|tool_calls>' : '<tool_calls>';
const closeIdx = findXMLCloseOutsideCDATA(captured, closeTag, found.index);
if (closeIdx > found.index) {
const closeEnd = closeIdx + closeTag.length;
const xmlBlock = openWrapper + captured.slice(found.index, closeIdx) + closeTag;
let prefixPart = captured.slice(0, found.index);
let suffixPart = captured.slice(closeEnd);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: trimmedFence.prefix,
calls: parsed,
suffix: trimmedFence.suffix,
};
}
return { ready: true, prefix: prefixPart + captured.slice(found.index, closeEnd), calls: [], suffix: suffixPart };
}
}
}
return { ready: false, prefix: '', calls: [], suffix: '' };
}
@@ -57,6 +83,25 @@ function hasOpenXMLToolTag(captured) {
return false;
}
function containsAnyToolCallWrapper(lower) {
return lower.includes('<tool_calls') || lower.includes('<|dsml|tool_calls');
}
function firstInvokeIndex(lower) {
const xmlIdx = lower.indexOf('<invoke');
const dsmlIdx = lower.indexOf('<|dsml|invoke');
if (xmlIdx < 0) {
return { index: dsmlIdx, dsml: dsmlIdx >= 0 };
}
if (dsmlIdx < 0) {
return { index: xmlIdx, dsml: false };
}
if (dsmlIdx < xmlIdx) {
return { index: dsmlIdx, dsml: true };
}
return { index: xmlIdx, dsml: false };
}
function findPartialXMLToolTagStart(s) {
const lastLT = s.lastIndexOf('<');
if (lastLT < 0) {

View File

@@ -1,14 +1,17 @@
'use strict';
const XML_TOOL_SEGMENT_TAGS = [
'<|dsml|tool_calls>', '<|dsml|tool_calls\n', '<|dsml|tool_calls ',
'<tool_calls>', '<tool_calls\n', '<tool_calls ',
];
const XML_TOOL_OPENING_TAGS = [
'<|dsml|tool_calls',
'<tool_calls',
];
const XML_TOOL_CLOSING_TAGS = [
'</|dsml|tool_calls>',
'</tool_calls>',
];

View File

@@ -38,7 +38,7 @@ func FormatToolCallsForPrompt(raw any) string {
if len(blocks) == 0 {
return ""
}
return "<tool_calls>\n" + strings.Join(blocks, "\n") + "\n</tool_calls>"
return "<|DSML|tool_calls>\n" + strings.Join(blocks, "\n") + "\n</|DSML|tool_calls>"
}
// StringifyToolCallArguments normalizes tool arguments into a compact string
@@ -94,12 +94,12 @@ func formatToolCallForPrompt(call map[string]any) string {
parameters := formatToolCallParametersForPrompt(argsRaw)
if parameters == "" {
return ` <invoke name="` + escapeXMLAttribute(name) + `"></invoke>`
return ` <|DSML|invoke name="` + escapeXMLAttribute(name) + `"></|DSML|invoke>`
}
return " <invoke name=\"" + escapeXMLAttribute(name) + "\">\n" +
return " <|DSML|invoke name=\"" + escapeXMLAttribute(name) + "\">\n" +
parameters + "\n" +
" </invoke>"
" </|DSML|invoke>"
}
func formatToolCallParametersForPrompt(raw any) string {
@@ -113,7 +113,7 @@ func formatToolCallParametersForPrompt(raw any) string {
if strings.TrimSpace(fallback) == "" {
return ""
}
return " <parameter name=\"content\">" + renderPromptXMLText(fallback) + "</parameter>"
return " <|DSML|parameter name=\"content\">" + renderPromptXMLText(fallback) + "</|DSML|parameter>"
}
func renderPromptToolParameters(value any, indent string) (string, bool) {
@@ -149,9 +149,9 @@ func renderPromptToolParameters(value any, indent string) (string, bool) {
}
return strings.Join(lines, "\n"), true
case string:
return indent + `<parameter name="content">` + renderPromptXMLText(v) + `</parameter>`, true
return indent + `<|DSML|parameter name="content">` + renderPromptXMLText(v) + `</|DSML|parameter>`, true
default:
return indent + `<parameter name="value">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
return indent + `<|DSML|parameter name="value">` + renderPromptXMLText(fmt.Sprint(v)) + `</|DSML|parameter>`, true
}
}
@@ -162,29 +162,29 @@ func renderPromptParameterNode(name string, value any, indent string) (string, b
}
switch v := value.(type) {
case nil:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
case map[string]any:
body, ok := renderPromptToolXMLBody(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</|DSML|parameter>`, true
case []any:
body, ok := renderPromptToolXMLArray(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</|DSML|parameter>`, true
case string:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(v) + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(v) + `</|DSML|parameter>`, true
default:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(fmt.Sprint(v)) + `</|DSML|parameter>`, true
}
}

View File

@@ -9,7 +9,7 @@ func TestStringifyToolCallArgumentsPreservesConcatenatedJSON(t *testing.T) {
}
}
func TestFormatToolCallsForPromptXML(t *testing.T) {
func TestFormatToolCallsForPromptDSML(t *testing.T) {
got := FormatToolCallsForPrompt([]any{
map[string]any{
"id": "call_1",
@@ -22,8 +22,8 @@ func TestFormatToolCallsForPromptXML(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty formatted tool calls")
}
if got != "<tool_calls>\n <invoke name=\"search_web\">\n <parameter name=\"query\"><![CDATA[latest]]></parameter>\n </invoke>\n</tool_calls>" {
t.Fatalf("unexpected formatted tool call XML: %q", got)
if got != "<|DSML|tool_calls>\n <|DSML|invoke name=\"search_web\">\n <|DSML|parameter name=\"query\"><![CDATA[latest]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>" {
t.Fatalf("unexpected formatted tool call DSML: %q", got)
}
}
@@ -34,7 +34,7 @@ func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) {
"arguments": `{"q":"a < b && c > d"}`,
},
})
want := "<tool_calls>\n <invoke name=\"search&lt;&amp;&gt;\">\n <parameter name=\"q\"><![CDATA[a < b && c > d]]></parameter>\n </invoke>\n</tool_calls>"
want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"search&lt;&amp;&gt;\">\n <|DSML|parameter name=\"q\"><![CDATA[a < b && c > d]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>"
if got != want {
t.Fatalf("unexpected escaped tool call XML: %q", got)
}
@@ -50,7 +50,7 @@ func TestFormatToolCallsForPromptUsesCDATAForMultilineContent(t *testing.T) {
},
},
})
want := "<tool_calls>\n <invoke name=\"write_file\">\n <parameter name=\"content\"><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></parameter>\n <parameter name=\"path\"><![CDATA[script.sh]]></parameter>\n </invoke>\n</tool_calls>"
want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"write_file\">\n <|DSML|parameter name=\"content\"><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></|DSML|parameter>\n <|DSML|parameter name=\"path\"><![CDATA[script.sh]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>"
if got != want {
t.Fatalf("unexpected multiline cdata tool call XML: %q", got)
}

View File

@@ -38,10 +38,10 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
if !strings.Contains(assistantContent, "<|DSML|tool_calls>") {
t.Fatalf("assistant tool history should be preserved in DSML form, got %q", assistantContent)
}
if !strings.Contains(assistantContent, `<invoke name="get_weather">`) {
if !strings.Contains(assistantContent, `<|DSML|invoke name="get_weather">`) {
t.Fatalf("expected tool name in preserved history, got %q", assistantContent)
}
if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) {
@@ -49,7 +49,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "<tool_calls>") {
if !strings.Contains(prompt, "<|DSML|tool_calls>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
@@ -177,10 +177,10 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "<invoke name=") != 2 {
if strings.Count(content, "<|DSML|invoke name=") != 2 {
t.Fatalf("expected two preserved tool call blocks, got %q", content)
}
if !strings.Contains(content, `<invoke name="search_web">`) || !strings.Contains(content, `<invoke name="eval_javascript">`) {
if !strings.Contains(content, `<|DSML|invoke name="search_web">`) || !strings.Contains(content, `<|DSML|invoke name="eval_javascript">`) {
t.Fatalf("expected both tool names in preserved history, got %q", content)
}
}
@@ -258,7 +258,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
if strings.Contains(content, "null") {
t.Fatalf("expected no null literal injection, got %q", content)
}
if !strings.Contains(content, "<tool_calls>") {
if !strings.Contains(content, "<|DSML|tool_calls>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}

View File

@@ -47,10 +47,10 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_calls>") {
if !strings.Contains(finalPrompt, "<|DSML|tool_calls>") {
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, `<invoke name="get_weather">`) {
if !strings.Contains(finalPrompt, `<|DSML|invoke name="get_weather">`) {
t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt)
}
}
@@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.") {
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {

View File

@@ -10,10 +10,11 @@ import (
// CollectResult holds the aggregated text and thinking content from a
// DeepSeek SSE stream, consumed to completion (non-streaming use case).
type CollectResult struct {
Text string
Thinking string
ContentFilter bool
CitationLinks map[int]string
Text string
Thinking string
ToolDetectionThinking string
ContentFilter bool
CitationLinks map[int]string
}
// CollectStream fully consumes a DeepSeek SSE response and separates
@@ -28,6 +29,7 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
}
text := strings.Builder{}
thinking := strings.Builder{}
toolDetectionThinking := strings.Builder{}
contentFilter := false
stopped := false
collector := newCitationLinkCollector()
@@ -70,12 +72,17 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
text.WriteString(trimmed)
}
}
for _, p := range result.ToolDetectionThinkingParts {
trimmed := TrimContinuationOverlap(toolDetectionThinking.String(), p.Text)
toolDetectionThinking.WriteString(trimmed)
}
return true
})
return CollectResult{
Text: text.String(),
Thinking: thinking.String(),
ContentFilter: contentFilter,
CitationLinks: collector.build(),
Text: text.String(),
Thinking: thinking.String(),
ToolDetectionThinking: toolDetectionThinking.String(),
ContentFilter: contentFilter,
CitationLinks: collector.build(),
}
}

View File

@@ -4,12 +4,13 @@ import "fmt"
// LineResult is the normalized parse result for one DeepSeek SSE line.
type LineResult struct {
Parsed bool
Stop bool
ContentFilter bool
ErrorMessage string
Parts []ContentPart
NextType string
Parsed bool
Stop bool
ContentFilter bool
ErrorMessage string
Parts []ContentPart
ToolDetectionThinkingParts []ContentPart
NextType string
}
// ParseDeepSeekContentLine centralizes one-line DeepSeek SSE parsing for both
@@ -46,12 +47,14 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
NextType: currentType,
}
}
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
parts, detectionThinkingParts, finished, nextType := ParseSSEChunkForContentDetailed(chunk, thinkingEnabled, currentType)
parts = filterLeakedContentFilterParts(parts)
detectionThinkingParts = filterLeakedContentFilterParts(detectionThinkingParts)
return LineResult{
Parsed: true,
Stop: finished,
Parts: parts,
NextType: nextType,
Parsed: true,
Stop: finished,
Parts: parts,
ToolDetectionThinkingParts: detectionThinkingParts,
NextType: nextType,
}
}

View File

@@ -69,20 +69,25 @@ func isFragmentStatusPath(path string) bool {
}
func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, bool, string) {
parts, _, finished, nextType := ParseSSEChunkForContentDetailed(chunk, thinkingEnabled, currentFragmentType)
return parts, finished, nextType
}
func ParseSSEChunkForContentDetailed(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, []ContentPart, bool, string) {
v, ok := chunk["v"]
if !ok {
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
path, _ := chunk["p"].(string)
if shouldSkipPath(path) {
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
if isStatusPath(path) {
if s, ok := v.(string); ok {
if strings.EqualFold(strings.TrimSpace(s), "FINISHED") {
return nil, true, currentFragmentType
return nil, nil, true, currentFragmentType
}
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
}
newType := currentFragmentType
@@ -92,18 +97,32 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
partType := resolvePartType(path, thinkingEnabled, newType)
finished := appendChunkValueContent(v, partType, &newType, &parts, path)
if finished {
return nil, true, newType
return nil, nil, true, newType
}
var transitioned bool
parts, transitioned = splitThinkingParts(parts)
if transitioned {
newType = "text"
}
detectionThinkingParts := selectThinkingParts(parts)
if !thinkingEnabled {
parts = dropThinkingParts(parts)
newType = "text"
}
return parts, false, newType
return parts, detectionThinkingParts, false, newType
}
func selectThinkingParts(parts []ContentPart) []ContentPart {
if len(parts) == 0 {
return nil
}
out := make([]ContentPart, 0, len(parts))
for _, p := range parts {
if p.Type == "thinking" {
out = append(out, p)
}
}
return out
}
func collectDirectFragments(path string, chunk map[string]any, v any, newType *string, parts *[]ContentPart) {

View File

@@ -11,44 +11,45 @@ import "strings"
func BuildToolCallInstructions(toolNames []string) string {
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
<tool_calls>
<invoke name="TOOL_NAME_HERE">
<parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></parameter>
</invoke>
</tool_calls>
<|DSML|tool_calls>
<|DSML|invoke name="TOOL_NAME_HERE">
<|DSML|parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>
RULES:
1) Use the <tool_calls> XML wrapper format only.
2) Put one or more <invoke> entries under a single <tool_calls> root.
3) Put the tool name in the invoke name attribute: <invoke name="TOOL_NAME">.
1) Use the <|DSML|tool_calls> wrapper format.
2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root.
3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">.
4) All string values must use <![CDATA[...]]>, even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries.
5) Every top-level argument must be a <parameter name="ARG_NAME">...</parameter> node.
5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">...</|DSML|parameter> node.
6) Objects use nested XML elements inside the parameter body. Arrays may repeat <item> children.
7) Numbers, booleans, and null stay plain text.
8) Use only the parameter names in the tool schema. Do not invent fields.
9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <tool_calls>.
11) Never omit the opening <tool_calls> tag, even if you already plan to close with </tool_calls>.
10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>.
11) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with </|DSML|tool_calls>.
12) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above.
PARAMETER SHAPES:
- string => <parameter name="x"><![CDATA[value]]></parameter>
- object => <parameter name="x"><field>...</field></parameter>
- array => <parameter name="x"><item>...</item><item>...</item></parameter>
- number/bool/null => <parameter name="x">plain_text</parameter>
- string => <|DSML|parameter name="x"><![CDATA[value]]></|DSML|parameter>
- object => <|DSML|parameter name="x"><field>...</field></|DSML|parameter>
- array => <|DSML|parameter name="x"><item>...</item><item>...</item></|DSML|parameter>
- number/bool/null => <|DSML|parameter name="x">plain_text</|DSML|parameter>
【WRONG — Do NOT do these】:
Wrong 1 — mixed text after XML:
<tool_calls>...</tool_calls> I hope this helps.
<|DSML|tool_calls>...</|DSML|tool_calls> I hope this helps.
Wrong 2 — Markdown code fences:
` + "```xml" + `
<tool_calls>...</tool_calls>
<|DSML|tool_calls>...</|DSML|tool_calls>
` + "```" + `
Wrong 3 — missing opening wrapper:
<invoke name="TOOL_NAME">...</invoke>
</tool_calls>
<|DSML|invoke name="TOOL_NAME">...</|DSML|invoke>
</|DSML|tool_calls>
Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.
Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.
` + buildCorrectToolExamples(toolNames)
}
@@ -140,21 +141,21 @@ func firstScriptExample(names []string) (promptToolExample, bool) {
func renderToolExampleBlock(calls []promptToolExample) string {
var b strings.Builder
b.WriteString("<tool_calls>\n")
b.WriteString("<|DSML|tool_calls>\n")
for _, call := range calls {
b.WriteString(` <invoke name="`)
b.WriteString(` <|DSML|invoke name="`)
b.WriteString(call.name)
b.WriteString("\">\n")
b.WriteString(`">` + "\n")
b.WriteString(indentPromptParameters(call.params, " "))
b.WriteString("\n </invoke>\n")
b.WriteString("\n </|DSML|invoke>\n")
}
b.WriteString("</tool_calls>")
b.WriteString("</|DSML|tool_calls>")
return b.String()
}
func indentPromptParameters(body, indent string) string {
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="content"></parameter>`
return indent + `<|DSML|parameter name="content"></|DSML|parameter>`
}
lines := strings.Split(body, "\n")
for i, line := range lines {
@@ -168,7 +169,7 @@ func indentPromptParameters(body, indent string) string {
}
func wrapParameter(name, inner string) string {
return `<parameter name="` + name + `">` + inner + `</parameter>`
return `<|DSML|parameter name="` + name + `">` + inner + `</|DSML|parameter>`
}
func exampleBasicParams(name string) (string, bool) {
@@ -194,7 +195,7 @@ func exampleBasicParams(name string) (string, bool) {
case "Edit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true
case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, true
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true
}
return "", false
}
@@ -202,11 +203,11 @@ func exampleBasicParams(name string) (string, bool) {
func exampleNestedParams(name string) (string, bool) {
switch strings.TrimSpace(name) {
case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, true
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true
case "Task":
return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true
case "ask_followup_question":
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></parameter>`, true
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></|DSML|parameter>`, true
}
return "", false
}

View File

@@ -7,20 +7,20 @@ import (
func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"})
if !strings.Contains(out, `<invoke name="exec_command">`) {
if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) {
t.Fatalf("expected exec_command in examples, got: %s", out)
}
if !strings.Contains(out, `<parameter name="cmd"><![CDATA[pwd]]></parameter>`) {
if !strings.Contains(out, `<|DSML|parameter name="cmd"><![CDATA[pwd]]></|DSML|parameter>`) {
t.Fatalf("expected cmd parameter example for exec_command, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"})
if !strings.Contains(out, `<invoke name="execute_command">`) {
if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) {
t.Fatalf("expected execute_command in examples, got: %s", out)
}
if !strings.Contains(out, `<parameter name="command"><![CDATA[pwd]]></parameter>`) {
if !strings.Contains(out, `<|DSML|parameter name="command"><![CDATA[pwd]]></|DSML|parameter>`) {
t.Fatalf("expected command parameter example for execute_command, got: %s", out)
}
}
@@ -34,20 +34,20 @@ func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *test
sawDescription := false
for _, block := range blocks {
if !strings.Contains(block, `<parameter name="command">`) {
if !strings.Contains(block, `<|DSML|parameter name="command">`) {
t.Fatalf("expected every Bash example to use command parameter, got: %s", block)
}
if strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block)
}
if strings.Contains(block, `<parameter name="description">`) {
if strings.Contains(block, `<|DSML|parameter name="description">`) {
sawDescription = true
}
}
if !sawDescription {
t.Fatalf("expected Bash long-script example to include description, got: %s", out)
}
if strings.Contains(out, `<invoke name="Read">`) {
if strings.Contains(out, `<|DSML|invoke name="Read">`) {
t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out)
}
}
@@ -60,10 +60,10 @@ func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testin
}
for _, block := range blocks {
if !strings.Contains(block, `<parameter name="command">`) {
if !strings.Contains(block, `<|DSML|parameter name="command">`) {
t.Fatalf("expected execute_command examples to use command parameter, got: %s", block)
}
if strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block)
}
}
@@ -80,10 +80,10 @@ func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) {
}
for _, block := range blocks {
if !strings.Contains(block, `<parameter name="cmd">`) {
if !strings.Contains(block, `<|DSML|parameter name="cmd">`) {
t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block)
}
if strings.Contains(block, `<parameter name="command">`) || strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block)
}
}
@@ -100,10 +100,10 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) {
}
for _, block := range blocks {
if !strings.Contains(block, `<parameter name="file_path">`) || !strings.Contains(block, `<parameter name="content">`) {
if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) {
t.Fatalf("expected Write examples to use file_path and content, got: %s", block)
}
if strings.Contains(block, `<parameter name="path">`) {
if strings.Contains(block, `<|DSML|parameter name="path">`) {
t.Fatalf("expected Write examples not to use path, got: %s", block)
}
}
@@ -111,7 +111,7 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) {
func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) {
out := BuildToolCallInstructions([]string{"read_file"})
if !strings.Contains(out, "Never omit the opening <tool_calls> tag") {
if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") {
t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out)
}
if !strings.Contains(out, "Wrong 3 — missing opening wrapper") {
@@ -120,7 +120,7 @@ func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *te
}
func findInvokeBlocks(text, name string) []string {
open := `<invoke name="` + name + `">`
open := `<|DSML|invoke name="` + name + `">`
remaining := text
blocks := []string{}
for {
@@ -129,11 +129,11 @@ func findInvokeBlocks(text, name string) []string {
return blocks
}
remaining = remaining[start:]
end := strings.Index(remaining, `</invoke>`)
end := strings.Index(remaining, `</|DSML|invoke>`)
if end < 0 {
return blocks
}
end += len(`</invoke>`)
end += len(`</|DSML|invoke>`)
blocks = append(blocks, remaining[:end])
remaining = remaining[end:]
}

View File

@@ -0,0 +1,108 @@
package toolcall
import "strings"
func normalizeDSMLToolCallMarkup(text string) (string, bool) {
if text == "" {
return "", true
}
hasDSML, hasCanonical := toolMarkupStylesOutsideIgnored(text)
if hasDSML && hasCanonical {
return text, false
}
if !hasDSML {
return text, true
}
return replaceDSMLToolMarkupOutsideIgnored(text), true
}
var dsmlToolMarkupAliases = []struct {
from string
to string
}{
{"<|dsml|tool_calls", "<tool_calls"},
{"</|dsml|tool_calls>", "</tool_calls>"},
{"<|dsml|invoke", "<invoke"},
{"</|dsml|invoke>", "</invoke>"},
{"<|dsml|parameter", "<parameter"},
{"</|dsml|parameter>", "</parameter>"},
}
var canonicalToolMarkupPrefixes = []string{
"<tool_calls",
"</tool_calls>",
"<invoke",
"</invoke>",
"<parameter",
"</parameter>",
}
func toolMarkupStylesOutsideIgnored(text string) (hasDSML, hasCanonical bool) {
lower := strings.ToLower(text)
for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(lower, i)
if blocked {
return hasDSML, hasCanonical
}
if advanced {
i = next
continue
}
if hasPrefixAt(lower, i, canonicalToolMarkupPrefixes) {
hasCanonical = true
}
for _, alias := range dsmlToolMarkupAliases {
if strings.HasPrefix(lower[i:], alias.from) {
hasDSML = true
break
}
}
if hasDSML && hasCanonical {
return true, true
}
i++
}
return hasDSML, hasCanonical
}
func replaceDSMLToolMarkupOutsideIgnored(text string) string {
lower := strings.ToLower(text)
var b strings.Builder
b.Grow(len(text))
for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(lower, i)
if blocked {
b.WriteString(text[i:])
break
}
if advanced {
b.WriteString(text[i:next])
i = next
continue
}
replaced := false
for _, alias := range dsmlToolMarkupAliases {
if strings.HasPrefix(lower[i:], alias.from) {
b.WriteString(alias.to)
i += len(alias.from)
replaced = true
break
}
}
if replaced {
continue
}
b.WriteByte(text[i])
i++
}
return b.String()
}
func hasPrefixAt(text string, idx int, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(text[idx:], prefix) {
return true
}
}
return false
}

View File

@@ -60,7 +60,11 @@ func parseToolCallsDetailedXMLOnly(text string) ToolCallParseResult {
return result
}
parsed := parseXMLToolCalls(trimmed)
normalized, ok := normalizeDSMLToolCallMarkup(trimmed)
if !ok {
return result
}
parsed := parseXMLToolCalls(normalized)
if len(parsed) == 0 {
return result
}

View File

@@ -30,6 +30,37 @@ func TestParseToolCallsSupportsToolCallsWrapper(t *testing.T) {
}
}
func TestParseToolCallsSupportsDSMLShell(t *testing.T) {
text := `<|DSML|tool_calls><|DSML|invoke name="Bash"><|DSML|parameter name="command"><![CDATA[pwd]]></|DSML|parameter></|DSML|invoke></|DSML|tool_calls>`
calls := ParseToolCalls(text, []string{"Bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 DSML call, got %#v", calls)
}
if calls[0].Name != "Bash" || calls[0].Input["command"] != "pwd" {
t.Fatalf("unexpected DSML parse result: %#v", calls[0])
}
}
func TestParseToolCallsSupportsDSMLShellWithCanonicalExampleInCDATA(t *testing.T) {
content := `<tool_calls><invoke name="demo"><parameter name="value">x</parameter></invoke></tool_calls>`
text := `<|DSML|tool_calls><|DSML|invoke name="Write"><|DSML|parameter name="file_path">notes.md</|DSML|parameter><|DSML|parameter name="content"><![CDATA[` + content + `]]></|DSML|parameter></|DSML|invoke></|DSML|tool_calls>`
calls := ParseToolCalls(text, []string{"Write"})
if len(calls) != 1 {
t.Fatalf("expected 1 DSML call with XML-looking CDATA, got %#v", calls)
}
if calls[0].Name != "Write" || calls[0].Input["content"] != content {
t.Fatalf("unexpected DSML CDATA parse result: %#v", calls[0])
}
}
func TestParseToolCallsRejectsMixedDSMLAndCanonicalToolTags(t *testing.T) {
text := `<|DSML|tool_calls><invoke name="Bash"><|DSML|parameter name="command">pwd</|DSML|parameter></invoke></|DSML|tool_calls>`
calls := ParseToolCalls(text, []string{"Bash"})
if len(calls) != 0 {
t.Fatalf("expected mixed DSML/XML tool tags to be rejected, got %#v", calls)
}
}
func TestParseToolCallsSupportsStandaloneToolWithMultilineCDATAAndRepeatedXMLTags(t *testing.T) {
text := `<tool_calls><invoke name="write_file"><parameter name="path">script.sh</parameter><parameter name="content"><![CDATA[#!/bin/bash
echo "hello"

View File

@@ -9,22 +9,27 @@ import (
// --- XML tool call support for the streaming sieve ---
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
var xmlToolCallClosingTags = []string{"</tool_calls>"}
var xmlToolCallOpeningTags = []string{"<tool_calls", "<invoke"}
var xmlToolCallClosingTags = []string{"</tool_calls>", "</|dsml|tool_calls>"}
var xmlToolCallOpeningTags = []string{"<tool_calls", "<invoke", "<|dsml|tool_calls", "<|dsml|invoke"}
// xmlToolCallTagPairs maps each opening tag to its expected closing tag.
// Order matters: longer/wrapper tags must be checked first.
var xmlToolCallTagPairs = []struct{ open, close string }{
{"<|dsml|tool_calls", "</|dsml|tool_calls>"},
{"<tool_calls", "</tool_calls>"},
}
// xmlToolCallBlockPattern matches a complete canonical XML tool call block.
//
//nolint:unused // reserved for future fast-path XML block detection.
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls\b[^>]*>\s*(?:.*?)\s*</tool_calls>)`)
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)((?:<tool_calls\b|<\|dsml\|tool_calls\b)[^>]*>\s*(?:.*?)\s*(?:</tool_calls>|</\|dsml\|tool_calls>))`)
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_calls ", "<invoke ", "<invoke\n", "<invoke\t", "<invoke\r"}
var xmlToolTagsToDetect = []string{
"<|dsml|tool_calls>", "<|dsml|tool_calls\n", "<|dsml|tool_calls ",
"<|dsml|invoke ", "<|dsml|invoke\n", "<|dsml|invoke\t", "<|dsml|invoke\r",
"<tool_calls>", "<tool_calls\n", "<tool_calls ", "<invoke ", "<invoke\n", "<invoke\t", "<invoke\r",
}
// consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text.
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
@@ -56,12 +61,18 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
// If this block failed to become a tool call, pass it through as text.
return prefixPart + xmlBlock, nil, suffixPart, true
}
if !strings.Contains(lower, "<tool_calls") {
invokeIdx := strings.Index(lower, "<invoke")
closeIdx := findXMLCloseOutsideCDATA(captured, "</tool_calls>", invokeIdx)
if !containsAnyToolCallWrapper(lower) {
invokeIdx, dsml := firstInvokeIndex(lower)
closeTag := "</tool_calls>"
openWrapper := "<tool_calls>"
if dsml {
closeTag = "</|dsml|tool_calls>"
openWrapper = "<|DSML|tool_calls>"
}
closeIdx := findXMLCloseOutsideCDATA(captured, closeTag, invokeIdx)
if invokeIdx >= 0 && closeIdx > invokeIdx {
closeEnd := closeIdx + len("</tool_calls>")
xmlBlock := "<tool_calls>" + captured[invokeIdx:closeIdx] + "</tool_calls>"
closeEnd := closeIdx + len(closeTag)
xmlBlock := openWrapper + captured[invokeIdx:closeIdx] + closeTag
prefixPart := captured[:invokeIdx]
suffixPart := captured[closeEnd:]
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
@@ -92,15 +103,25 @@ func hasOpenXMLToolTag(captured string) bool {
func shouldKeepBareInvokeCapture(captured string) bool {
lower := strings.ToLower(captured)
invokeIdx := strings.Index(lower, "<invoke")
if invokeIdx < 0 || strings.Contains(lower, "<tool_calls") {
invokeIdx, dsml := firstInvokeIndex(lower)
if invokeIdx < 0 || containsAnyToolCallWrapper(lower) {
return false
}
if findXMLCloseOutsideCDATA(captured, "</tool_calls>", invokeIdx) > invokeIdx {
wrapperClose := "</tool_calls>"
invokeOpenLen := len("<invoke")
invokeClose := "</invoke>"
parameterOpen := "<parameter"
if dsml {
wrapperClose = "</|dsml|tool_calls>"
invokeOpenLen = len("<|dsml|invoke")
invokeClose = "</|dsml|invoke>"
parameterOpen = "<|dsml|parameter"
}
if findXMLCloseOutsideCDATA(captured, wrapperClose, invokeIdx) > invokeIdx {
return true
}
startEnd := findXMLTagEnd(captured, invokeIdx+len("<invoke"))
startEnd := findXMLTagEnd(captured, invokeIdx+invokeOpenLen)
if startEnd < 0 {
return true
}
@@ -110,18 +131,37 @@ func shouldKeepBareInvokeCapture(captured string) bool {
return true
}
invokeCloseIdx := findXMLCloseOutsideCDATA(captured, "</invoke>", startEnd+1)
invokeCloseIdx := findXMLCloseOutsideCDATA(captured, invokeClose, startEnd+1)
if invokeCloseIdx >= 0 {
afterClose := captured[invokeCloseIdx+len("</invoke>"):]
afterClose := captured[invokeCloseIdx+len(invokeClose):]
return strings.TrimSpace(afterClose) == ""
}
trimmedLower := strings.ToLower(trimmedBody)
return strings.HasPrefix(trimmedLower, "<parameter") ||
return strings.HasPrefix(trimmedLower, parameterOpen) ||
strings.HasPrefix(trimmedLower, "{") ||
strings.HasPrefix(trimmedLower, "[")
}
func containsAnyToolCallWrapper(lower string) bool {
return strings.Contains(lower, "<tool_calls") || strings.Contains(lower, "<|dsml|tool_calls")
}
func firstInvokeIndex(lower string) (int, bool) {
xmlIdx := strings.Index(lower, "<invoke")
dsmlIdx := strings.Index(lower, "<|dsml|invoke")
switch {
case xmlIdx < 0:
return dsmlIdx, dsmlIdx >= 0
case dsmlIdx < 0:
return xmlIdx, false
case dsmlIdx < xmlIdx:
return dsmlIdx, true
default:
return xmlIdx, false
}
}
func findXMLCloseOutsideCDATA(s, closeTag string, start int) int {
if s == "" || closeTag == "" {
return -1

View File

@@ -41,6 +41,37 @@ func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
}
}
func TestProcessToolSieveInterceptsDSMLToolCallWithoutLeak(t *testing.T) {
var state State
chunks := []string{
"<|DSML|tool",
"_calls>\n",
` <|DSML|invoke name="read_file">` + "\n",
` <|DSML|parameter name="path">README.MD</|DSML|parameter>` + "\n",
" </|DSML|invoke>\n",
"</|DSML|tool_calls>",
}
var events []Event
for _, c := range chunks {
events = append(events, ProcessChunk(&state, c, []string{"read_file"})...)
}
events = append(events, Flush(&state, []string{"read_file"})...)
var textContent string
var toolCalls int
for _, evt := range events {
textContent += evt.Content
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(strings.ToLower(textContent), "dsml") || strings.Contains(textContent, "read_file") {
t.Fatalf("DSML tool call content leaked to text: %q", textContent)
}
if toolCalls != 1 {
t.Fatalf("expected one DSML tool call, got %d events=%#v", toolCalls, events)
}
}
func TestProcessToolSieveHandlesLongXMLToolCall(t *testing.T) {
var state State
const toolName = "write_to_file"