mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-17 14:45:11 +08:00
测试DSML
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
26
internal/httpapi/openai/shared/assistant_toolcalls.go
Normal file
26
internal/httpapi/openai/shared/assistant_toolcalls.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>',
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<&>\">\n <parameter name=\"q\"><![CDATA[a < b && c > d]]></parameter>\n </invoke>\n</tool_calls>"
|
||||
want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"search<&>\">\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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
108
internal/toolcall/toolcalls_dsml.go
Normal file
108
internal/toolcall/toolcalls_dsml.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user