mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
1
This commit is contained in:
@@ -148,6 +148,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
4. 把这整段内容并入 system prompt。
|
||||
|
||||
工具调用正例仍只示范 canonical XML:`<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`。
|
||||
提示词会额外强调:如果要调用工具,工具块的首个非空白字符必须就是 `<tool_calls>`,不能只输出 `</tool_calls>` 而漏掉 opening tag。
|
||||
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
|
||||
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
|
||||
|
||||
@@ -192,6 +193,7 @@ assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON,而会转成
|
||||
```
|
||||
|
||||
这也是当前项目里唯一受支持的 canonical tool-calling 形态;其他形态都会作为普通文本保留,不会作为可执行调用语法。
|
||||
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `<invoke ...>` ... `</tool_calls>`,但漏掉最前面的 opening `<tool_calls>`,解析阶段会补回 wrapper 后再尝试识别。
|
||||
|
||||
这件事很重要,因为它决定了:
|
||||
|
||||
|
||||
@@ -23,9 +23,14 @@
|
||||
- 工具名必须放在 `invoke` 的 `name` 属性
|
||||
- 参数必须使用 `<parameter name="...">...</parameter>`
|
||||
|
||||
兼容修复:
|
||||
|
||||
- 如果模型漏掉 opening `<tool_calls>`,但后面仍输出了一个或多个 `<invoke ...>` 并以 `</tool_calls>` 收尾,Go 解析链路会在解析前补回缺失的 opening wrapper。
|
||||
- 这是一个针对常见模型失误的窄修复,不改变推荐输出格式;prompt 仍要求模型直接输出完整 canonical XML。
|
||||
|
||||
## 2) 非 canonical 内容
|
||||
|
||||
任何不满足上述 canonical XML 形态的内容,都会保留为普通文本,不会执行。
|
||||
任何不满足上述 canonical XML 形态的内容,都会保留为普通文本,不会执行。一个例外是上一节提到的“缺失 opening `<tool_calls>`、但 closing `</tool_calls>` 仍存在”的窄修复场景。
|
||||
|
||||
当前 parser 不把 allow-list 当作硬安全边界:即使传入了已声明工具名列表,XML 里出现未声明工具名时也会尽量解析并交给上层协议输出;真正的执行侧仍必须自行校验工具名和参数。
|
||||
|
||||
@@ -33,7 +38,8 @@
|
||||
|
||||
在流式链路中(Go / Node 一致):
|
||||
|
||||
- 只有从 `<tool_calls` 开始的 canonical wrapper 才会进入结构化捕获
|
||||
- canonical `<tool_calls>` wrapper 会进入结构化捕获
|
||||
- 如果流里直接从 `<invoke ...>` 开始,但后面补上了 `</tool_calls>`,Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复
|
||||
- 已识别成功的工具调用不会再次回流到普通文本
|
||||
- 不符合新格式的块不会执行,并继续按原样文本透传
|
||||
- fenced code block 中的 XML 示例始终按普通文本处理
|
||||
@@ -43,14 +49,14 @@
|
||||
`ParseToolCallsDetailed` / `parseToolCallsDetailed` 返回:
|
||||
|
||||
- `calls`:解析出的工具调用列表(`name` + `input`)
|
||||
- `sawToolCallSyntax`:只有检测到 `<tool_calls` 时才会为 `true`
|
||||
- `sawToolCallSyntax`:检测到 canonical wrapper,或命中“缺失 opening wrapper 但可修复”的形态时会为 `true`
|
||||
- `rejectedByPolicy`:当前固定为 `false`
|
||||
- `rejectedToolNames`:当前固定为空数组
|
||||
|
||||
## 5) 落地建议
|
||||
|
||||
1. Prompt 里只示范 canonical XML 语法。
|
||||
2. 上游客户端需要直接输出 canonical XML;DS2API 不会把其他形态改写成工具调用。
|
||||
2. 上游客户端仍应直接输出 canonical XML;DS2API 只对“closing tag 在、opening tag 漏掉”的常见失误做窄修复,不会泛化接受其他旧格式。
|
||||
3. 不要依赖 parser 做安全控制;执行器侧仍应做工具名和参数校验。
|
||||
|
||||
## 6) 回归验证
|
||||
|
||||
@@ -71,15 +71,30 @@ func ConsumeSSE(cfg ConsumeConfig, hooks ConsumeHooks) {
|
||||
hooks.OnFinalize(reason, scannerErr)
|
||||
}
|
||||
}
|
||||
contextDone := func() bool {
|
||||
if cfg.Context.Err() == nil {
|
||||
return false
|
||||
}
|
||||
if hooks.OnContextDone != nil {
|
||||
hooks.OnContextDone()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for {
|
||||
if contextDone() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-cfg.Context.Done():
|
||||
if hooks.OnContextDone != nil {
|
||||
hooks.OnContextDone()
|
||||
if contextDone() {
|
||||
return
|
||||
}
|
||||
return
|
||||
case <-tickCh(ticker):
|
||||
if contextDone() {
|
||||
return
|
||||
}
|
||||
if !hasContent {
|
||||
keepaliveCount++
|
||||
if cfg.MaxKeepAliveNoInput > 0 && keepaliveCount >= cfg.MaxKeepAliveNoInput {
|
||||
@@ -95,6 +110,9 @@ func ConsumeSSE(cfg ConsumeConfig, hooks ConsumeHooks) {
|
||||
hooks.OnKeepAlive()
|
||||
}
|
||||
case parsed, ok := <-parsedLines:
|
||||
if contextDone() {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
finalize(StopReasonUpstreamCompleted, <-done)
|
||||
return
|
||||
|
||||
47
internal/stream/engine_test.go
Normal file
47
internal/stream/engine_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
func TestConsumeSSEPrefersContextCancellationOverReadyParsedLines(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
var finalized bool
|
||||
var contextDone bool
|
||||
var parsedCalled bool
|
||||
|
||||
ConsumeSSE(ConsumeConfig{
|
||||
Context: ctx,
|
||||
Body: strings.NewReader("data: {\"p\":\"response/content\",\"v\":\"hello\"}\n\ndata: [DONE]\n"),
|
||||
ThinkingEnabled: false,
|
||||
InitialType: "text",
|
||||
KeepAliveInterval: 0,
|
||||
}, ConsumeHooks{
|
||||
OnParsed: func(_ sse.LineResult) ParsedDecision {
|
||||
parsedCalled = true
|
||||
return ParsedDecision{}
|
||||
},
|
||||
OnFinalize: func(_ StopReason, _ error) {
|
||||
finalized = true
|
||||
},
|
||||
OnContextDone: func() {
|
||||
contextDone = true
|
||||
},
|
||||
})
|
||||
|
||||
if !contextDone {
|
||||
t.Fatal("expected OnContextDone to run for an already-cancelled context")
|
||||
}
|
||||
if finalized {
|
||||
t.Fatal("expected OnFinalize not to run after context cancellation wins")
|
||||
}
|
||||
if parsedCalled {
|
||||
t.Fatal("expected parsed lines not to be processed after context cancellation wins")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ RULES:
|
||||
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>.
|
||||
|
||||
PARAMETER SHAPES:
|
||||
- string => <parameter name="x"><![CDATA[value]]></parameter>
|
||||
@@ -42,6 +44,9 @@ Wrong 2 — Markdown code fences:
|
||||
` + "```xml" + `
|
||||
<tool_calls>...</tool_calls>
|
||||
` + "```" + `
|
||||
Wrong 3 — missing opening wrapper:
|
||||
<invoke name="TOOL_NAME">...</invoke>
|
||||
</tool_calls>
|
||||
|
||||
Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.
|
||||
|
||||
|
||||
@@ -109,6 +109,16 @@ 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") {
|
||||
t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Wrong 3 — missing opening wrapper") {
|
||||
t.Fatalf("expected missing-opening-wrapper negative example, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func findInvokeBlocks(text, name string) []string {
|
||||
open := `<invoke name="` + name + `">`
|
||||
remaining := text
|
||||
|
||||
@@ -11,9 +11,17 @@ var xmlToolCallsWrapperPattern = regexp.MustCompile(`(?is)<tool_calls\b[^>]*>\s*
|
||||
var xmlInvokePattern = regexp.MustCompile(`(?is)<invoke\b([^>]*)>\s*(.*?)\s*</invoke>`)
|
||||
var xmlParameterPattern = regexp.MustCompile(`(?is)<parameter\b([^>]*)>\s*(.*?)\s*</parameter>`)
|
||||
var xmlAttrPattern = regexp.MustCompile(`(?is)\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')`)
|
||||
var xmlToolCallsClosePattern = regexp.MustCompile(`(?is)</tool_calls>`)
|
||||
var xmlInvokeStartPattern = regexp.MustCompile(`(?is)<invoke\b[^>]*\bname\s*=\s*("([^"]*)"|'([^']*)')`)
|
||||
|
||||
func parseXMLToolCalls(text string) []ParsedToolCall {
|
||||
wrappers := xmlToolCallsWrapperPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(wrappers) == 0 {
|
||||
repaired := repairMissingXMLToolCallsOpeningWrapper(text)
|
||||
if repaired != text {
|
||||
wrappers = xmlToolCallsWrapperPattern.FindAllStringSubmatch(repaired, -1)
|
||||
}
|
||||
}
|
||||
if len(wrappers) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -36,6 +44,28 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
|
||||
return out
|
||||
}
|
||||
|
||||
func repairMissingXMLToolCallsOpeningWrapper(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, "<tool_calls") {
|
||||
return text
|
||||
}
|
||||
|
||||
closeMatches := xmlToolCallsClosePattern.FindAllStringIndex(text, -1)
|
||||
if len(closeMatches) == 0 {
|
||||
return text
|
||||
}
|
||||
invokeLoc := xmlInvokeStartPattern.FindStringIndex(text)
|
||||
if invokeLoc == nil {
|
||||
return text
|
||||
}
|
||||
closeLoc := closeMatches[len(closeMatches)-1]
|
||||
if invokeLoc[0] >= closeLoc[0] {
|
||||
return text
|
||||
}
|
||||
|
||||
return text[:invokeLoc[0]] + "<tool_calls>" + text[invokeLoc[0]:closeLoc[0]] + "</tool_calls>" + text[closeLoc[1]:]
|
||||
}
|
||||
|
||||
func parseSingleXMLToolCall(block []string) (ParsedToolCall, bool) {
|
||||
if len(block) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
|
||||
@@ -175,6 +175,26 @@ func TestParseToolCallsRejectsBareInvokeWithoutToolCallsWrapper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsRepairsMissingOpeningToolCallsWrapperWhenClosingTagExists(t *testing.T) {
|
||||
text := `Before tool call
|
||||
<invoke name="read_file"><parameter name="path">README.md</parameter></invoke>
|
||||
</tool_calls>
|
||||
after`
|
||||
res := ParseToolCallsDetailed(text, []string{"read_file"})
|
||||
if len(res.Calls) != 1 {
|
||||
t.Fatalf("expected repaired wrapper to parse exactly one call, got %#v", res)
|
||||
}
|
||||
if res.Calls[0].Name != "read_file" {
|
||||
t.Fatalf("expected repaired wrapper to preserve tool name, got %#v", res.Calls[0])
|
||||
}
|
||||
if got, _ := res.Calls[0].Input["path"].(string); got != "README.md" {
|
||||
t.Fatalf("expected repaired wrapper to preserve args, got %#v", res.Calls[0].Input)
|
||||
}
|
||||
if !res.SawToolCallSyntax {
|
||||
t.Fatalf("expected repaired wrapper to mark tool syntax seen, got %#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsRejectsLegacyCanonicalBody(t *testing.T) {
|
||||
text := `<tool_calls><invoke name="read_file"><tool_name>read_file</tool_name><param>{"path":"README.md"}</param></invoke></tool_calls>`
|
||||
calls := ParseToolCalls(text, []string{"read_file"})
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
|
||||
var xmlToolCallClosingTags = []string{"</tool_calls>"}
|
||||
var xmlToolCallOpeningTags = []string{"<tool_calls"}
|
||||
var xmlToolCallOpeningTags = []string{"<tool_calls", "<invoke"}
|
||||
|
||||
// xmlToolCallTagPairs maps each opening tag to its expected closing tag.
|
||||
// Order matters: longer/wrapper tags must be checked first.
|
||||
@@ -24,7 +24,7 @@ var xmlToolCallTagPairs = []struct{ open, close string }{
|
||||
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls\b[^>]*>\s*(?:.*?)\s*</tool_calls>)`)
|
||||
|
||||
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
|
||||
var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_calls "}
|
||||
var xmlToolTagsToDetect = []string{"<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) {
|
||||
@@ -55,6 +55,22 @@ 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 := strings.LastIndex(lower, "</tool_calls>")
|
||||
if invokeIdx >= 0 && closeIdx > invokeIdx {
|
||||
closeEnd := closeIdx + len("</tool_calls>")
|
||||
xmlBlock := "<tool_calls>" + captured[invokeIdx:closeIdx] + "</tool_calls>"
|
||||
prefixPart := captured[:invokeIdx]
|
||||
suffixPart := captured[closeEnd:]
|
||||
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
|
||||
if len(parsed) > 0 {
|
||||
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
|
||||
return prefixPart, parsed, suffixPart, true
|
||||
}
|
||||
return prefixPart + captured[invokeIdx:closeEnd], nil, suffixPart, true
|
||||
}
|
||||
}
|
||||
return "", nil, "", false
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,7 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{"tool_calls_tag", "some text <tool_calls>\n", 10},
|
||||
{"invoke_tag_missing_wrapper", "some text <invoke name=\"read_file\">\n", 10},
|
||||
{"bare_tool_call_text", "prefix <tool_call>\n", -1},
|
||||
{"xml_inside_code_fence", "```xml\n<tool_calls><invoke name=\"read_file\"></invoke></tool_calls>\n```", -1},
|
||||
{"no_xml", "just plain text", -1},
|
||||
@@ -310,6 +311,7 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{"partial_tool_calls", "Hello <tool_ca", 6},
|
||||
{"partial_invoke", "Hello <inv", 6},
|
||||
{"bare_tool_call_not_held", "Hello <tool_name", -1},
|
||||
{"partial_lt_only", "Text <", 5},
|
||||
{"complete_tag", "Text <tool_calls>done", -1},
|
||||
@@ -505,3 +507,32 @@ func TestProcessToolSievePassesThroughBareToolCallAsText(t *testing.T) {
|
||||
t.Fatalf("expected bare invoke to pass through unchanged, got %q", textContent.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessToolSieveRepairsMissingOpeningWrapperWithoutLeakingInvokeText(t *testing.T) {
|
||||
var state State
|
||||
chunks := []string{
|
||||
"<invoke name=\"read_file\">\n",
|
||||
" <parameter name=\"path\">README.md</parameter>\n",
|
||||
"</invoke>\n",
|
||||
"</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 strings.Builder
|
||||
toolCalls := 0
|
||||
for _, evt := range events {
|
||||
textContent.WriteString(evt.Content)
|
||||
toolCalls += len(evt.ToolCalls)
|
||||
}
|
||||
|
||||
if toolCalls != 1 {
|
||||
t.Fatalf("expected repaired missing-wrapper stream to emit one tool call, got %d events=%#v", toolCalls, events)
|
||||
}
|
||||
if strings.Contains(textContent.String(), "<invoke") || strings.Contains(textContent.String(), "</tool_calls>") {
|
||||
t.Fatalf("expected repaired missing-wrapper stream not to leak xml text, got %q", textContent.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user