refactor: allow and preserve empty tool parameter values while updating sieve to release malformed XML as text

This commit is contained in:
CJACK
2026-05-10 01:05:18 +08:00
parent ddd42e532e
commit 740a78ad5a
13 changed files with 185 additions and 88 deletions

View File

@@ -111,7 +111,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
- OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `promptcompat.BuildOpenAIPromptForAdapter`。Go 主服务新增 `completionruntime` 启动层,统一执行 DeepSeek session/PoW/call输出侧新增 `assistantturn` 语义层:非流式 OpenAI Chat / Responses / Claude / Gemini 会把 DeepSeek SSE 收集结果先归一成同一份 assistant turn再分别渲染成各协议原生外形流式 OpenAI Chat / Responses / Claude / Gemini 继续保持各协议实时 SSE framing但最终收尾的 tool fallback、schema 归一、usage、empty-output / content-filter 错误语义同样由 `assistantturn` 判定。Claude / Gemini 的常规 Go 主路径不再依赖内部 `httptest` 转发到 OpenAI handler`translatorcliproxy` 仅保留用于 Vercel bridge、后端缺失 fallback 和回归测试,不作为主业务协议转换中心。
- Vercel Node 流式路径本轮不迁移,仍使用现有 Node bridge / stream-tool-sieve 实现;后续若变更 Node 流式语义,需要按 `assistantturn` 的 Go canonical 输出语义同步对齐。
- 客户端传入的 thinking / reasoning 开关会被归一到下游 `thinking_enabled`。Gemini `generationConfig.thinkingConfig.thinkingBudget` 会翻译成同一套 thinking 开关;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。若最终解析出的模型名带 `-nothinking` 后缀,则会无条件强制关闭 thinking优先级高于请求体中的 `thinking` / `reasoning` / `reasoning_effort`。未显式关闭时,各 surface 会按解析后的 DeepSeek 模型默认能力开启 thinking并用各自协议的原生形态暴露OpenAI Chat 为 `reasoning_content`OpenAI Responses 为 `response.reasoning.delta` / `reasoning` contentClaude 为 `thinking` block / `thinking_delta`Gemini 为 `thought: true` part。
- 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;真正的工具识别始终基于原始上游文本,而不是基于“已经做过可见输出清洗”的版本,因此即使最终可见层会剥离完整 leaked DSML / XML `tool_calls` wrapper、并抑制全空参数或无效 wrapper 块,也不会影响真实工具调用转成结构化 `tool_calls` / `function_call`。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning思维链只用于检测不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。
- 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;真正的工具识别始终基于原始上游文本,而不是基于“已经做过可见输出清洗”的版本,因此即使最终可见层会剥离完整 leaked DSML / XML `tool_calls` wrapper、并抑制无效 wrapper 块,也不会影响真实工具调用转成结构化 `tool_calls` / `function_call`。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning思维链只用于检测不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。
- OpenAI Chat / Responses、Claude Messages、Gemini generateContent 的空回复错误处理之前会默认做一次内部补偿重试:第一次上游完整结束后,如果最终可见正文为空、没有解析到工具调用、也没有已经向客户端流式发出工具调用,并且终止原因不是 `content_filter`,兼容层会复用同一个 `chat_session_id`、账号、token 与工具策略,把原始 completion `prompt` 追加固定后缀 `Previous reply had no visible output. Please regenerate the visible final answer or tool call now.` 后重新提交一次。Go 主路径的非流式重试由 `completionruntime.ExecuteNonStreamWithRetry` 统一处理;流式重试由 `completionruntime.ExecuteStreamWithRetry` 统一处理,各协议 runtime 只负责消费/渲染本协议 SSE framing。重试遵循 DeepSeek 多轮对话协议:从第一次上游 SSE 流中提取 `response_message_id`,并在重试 payload 中设置 `parent_message_id` 为该值,使重试成为同一会话的后续轮次而非断裂的根消息;同时重新获取一次 PoW若 PoW 获取失败则回退到原始 PoW。该同账号重试不会重新标准化消息、不会新建 session也不会向流式客户端插入重试标记第二次 thinking / reasoning 会按正常增量直接接到第一次之后,并继续使用 overlap trim 去重。若同账号补偿重试后即将返回 429 `upstream_empty_output`并且当前是托管账号模式Go 主路径会在返回 429 前切换到下一个可用账号,新建 `chat_session_id`,使用原始 completion payload 再做一次 fresh retry该切号重试不携带空回复 prompt 后缀,也不设置上一账号的 `parent_message_id`。如果没有可切换账号,或切号后的 fresh retry 仍没有可见正文或工具调用,则继续按原错误返回:无任何输出为 503 `upstream_unavailable`,有 reasoning 但没有可见正文或工具调用为 429 `upstream_empty_output`。若任一尝试触发空 `content_filter`,不做补偿重试并保持 `content_filter` 错误。JS Vercel 运行时同样设置 `parent_message_id`,但因无法直接调用 PoW API 而复用原始 PoW切号 fresh retry 目前由 Go 主路径提供。
- 非流式 OpenAI Chat / Responses、Claude Messages、Gemini generateContent 在最终可见正文渲染阶段,会把 DeepSeek 搜索返回中的 `[citation:N]` / `[reference:N]` 标记替换成对应 Markdown 链接。`citation` 标记按一基序号解析;`reference` 标记只有在同一段正文中出现 `[reference:0]`(允许冒号后有空格)时才按零基序号映射,并且不会影响同段正文里的 `citation` 标记。
@@ -175,6 +175,7 @@ Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行
工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtimeClaude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command``exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
工具提示词也会明确要求模型按本次调用实际需要填写参数,禁止输出 placeholder、空字符串或纯空白参数如果必填参数未知应先追问用户或正常文字回复而不是输出空工具壳。对 `Bash` / `execute_command` 这类 shell 工具,命令或脚本必须写入 `command` 参数。解析层仍会把空字符串参数结构化返回;是否拒绝空 `command` 由后续工具执行侧 / 客户端 schema 校验决定。
如果当前请求声明了 `Read` / `read_file` 这类读取工具,兼容层会额外注入一条 read-tool cache guard当读取结果只表示“文件未变更 / 已在历史中 / 请引用先前上下文 / 没有正文内容”时模型必须把它视为内容不可用不能反复调用同一个无正文读取应改为请求完整正文读取能力或向用户说明需要重新提供文件内容。这个约束只缓解客户端缓存返回空内容导致的死循环DS2API 不会也无法凭空恢复客户端本地文件正文。
OpenAI 路径实现:

View File

@@ -78,11 +78,14 @@
- `rejectedByPolicy`:当前固定为 `false`
- `rejectedToolNames`:当前固定为空数组
解析层不会因为参数值为空而丢弃工具调用。若模型输出了显式空字符串或纯空白参数,它们会按空字符串进入结构化 `tool_calls`;是否拒绝缺参或空命令应由后续工具执行侧 / 客户端 schema 校验决定。Prompt 层仍会要求模型不要主动输出空参数。
## 5) 落地建议
1. Prompt 里只示范 DSML 外壳语法。
2. 上游客户端应直接输出完整 DSML 外壳DS2API 兼容旧式 canonical XML并只对“closing tag 在、opening tag 漏掉”的常见失误做窄修复,不会泛化接受其他旧格式。
3. 不要依赖 parser 做安全控制;执行器侧仍应做工具名和参数校验
3. 模型只有在知道本次调用所需参数值时才应输出工具调用;不要输出 placeholder、空字符串或纯空白参数。对 `Bash` / `execute_command`,实际命令必须在 `command` 参数里
4. 不要依赖 parser 做安全控制;执行器侧仍应做工具名和参数校验。
## 6) 回归验证

View File

@@ -113,9 +113,10 @@ function filterToolCallsDetailed(parsed, toolNames) {
if (!tc || !tc.name) {
continue;
}
const input = tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {};
calls.push({
name: tc.name,
input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {},
input,
});
}
return { calls, rejectedToolNames: [] };

View File

@@ -660,9 +660,17 @@ function hasPartialToolMarkupNameAfterArbitraryPrefix(raw, start) {
if (toolMarkupPrefixAllowsLocalName(raw.slice(start, idx)) && hasToolMarkupNamePrefix(raw, idx)) {
return true;
}
if (toolMarkupPrefixAllowsLocalName(raw.slice(start, idx)) && hasDSMLNamePrefixOrPartial(raw, idx)) {
return true;
}
idx += 1;
}
return false;
return toolMarkupPrefixAllowsLocalName(raw.slice(start));
}
function hasDSMLNamePrefixOrPartial(raw, start) {
const tail = normalizedASCIITailAt(raw, start);
return tail.startsWith('dsml') || 'dsml'.startsWith(tail);
}
function toolMarkupPrefixAllowsLocalName(prefix) {

View File

@@ -1,5 +1,5 @@
'use strict';
const { parseToolCalls } = require('./parse');
const { parseToolCallsDetailed } = require('./parse');
const {
findToolMarkupTagOutsideIgnored,
findMatchingToolMarkupClose,
@@ -27,19 +27,30 @@ function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const xmlBlock = captured.slice(openTag.start, closeTag.end + 1);
const prefixPart = captured.slice(0, openTag.start);
const suffixPart = captured.slice(closeTag.end + 1);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const parsed = parseToolCallsDetailed(xmlBlock, toolNames);
if (Array.isArray(parsed.calls) && parsed.calls.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
if (!best || openTag.start < best.start) {
best = {
start: openTag.start,
prefix: trimmedFence.prefix,
calls: parsed,
calls: parsed.calls,
suffix: trimmedFence.suffix,
};
}
break;
}
if (parsed.sawToolCallSyntax) {
if (!rejected || openTag.start < rejected.start) {
rejected = {
start: openTag.start,
prefix: prefixPart + xmlBlock,
suffix: suffixPart,
};
}
searchFrom = openTag.end + 1;
continue;
}
if (!rejected || openTag.start < rejected.start) {
rejected = {
start: openTag.start,
@@ -69,16 +80,19 @@ function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const xmlBlock = '<tool_calls>' + captured.slice(invokeTag.start, closeTag.end + 1);
const prefixPart = captured.slice(0, invokeTag.start);
const suffixPart = captured.slice(closeTag.end + 1);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const parsed = parseToolCallsDetailed(xmlBlock, toolNames);
if (Array.isArray(parsed.calls) && parsed.calls.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: trimmedFence.prefix,
calls: parsed,
calls: parsed.calls,
suffix: trimmedFence.suffix,
};
}
if (parsed.sawToolCallSyntax) {
return { ready: true, prefix: prefixPart + captured.slice(invokeTag.start, closeTag.end + 1), calls: [], suffix: suffixPart };
}
return { ready: true, prefix: prefixPart + captured.slice(invokeTag.start, closeTag.end + 1), calls: [], suffix: suffixPart };
}
}

View File

@@ -26,10 +26,13 @@ RULES:
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 <DSMLtool_calls>.
11) Never omit the opening <DSMLtool_calls> tag, even if you already plan to close with </DSMLtool_calls>.
12) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above.
9) Fill parameters with the actual values required for this call. Do not emit placeholder, blank, or whitespace-only parameters.
10) If a required parameter value is unknown, ask the user or answer normally instead of outputting an empty tool call.
11) For shell tools such as Bash / execute_command, the command/script must be inside the command parameter. Never call them with an empty command.
12) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
13) If you call a tool, the first non-whitespace characters of that tool block must be exactly <DSMLtool_calls>.
14) Never omit the opening <DSMLtool_calls> tag, even if you already plan to close with </DSMLtool_calls>.
15) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above.
PARAMETER SHAPES:
- string => <DSMLparameter name="x"><![CDATA[value]]></DSMLparameter>
@@ -48,6 +51,12 @@ Wrong 2 — Markdown code fences:
Wrong 3 — missing opening wrapper:
<DSMLinvoke name="TOOL_NAME">...</DSMLinvoke>
</DSMLtool_calls>
Wrong 4 — empty parameters:
<DSMLtool_calls>
<DSMLinvoke name="Bash">
<DSMLparameter name="command"></DSMLparameter>
</DSMLinvoke>
</DSMLtool_calls>
Remember: The ONLY valid way to use tools is the <DSMLtool_calls>...</DSMLtool_calls> block at the end of your response.
` + buildCorrectToolExamples(toolNames)

View File

@@ -119,6 +119,20 @@ func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *te
}
}
func TestBuildToolCallInstructions_RejectsEmptyParametersInPrompt(t *testing.T) {
out := BuildToolCallInstructions([]string{"Bash"})
for _, want := range []string{
"Do not emit placeholder, blank, or whitespace-only parameters.",
"If a required parameter value is unknown, ask the user or answer normally instead of outputting an empty tool call.",
"Never call them with an empty command.",
"Wrong 4 — empty parameters",
} {
if !strings.Contains(out, want) {
t.Fatalf("expected empty-parameter instruction %q, got: %s", want, out)
}
}
}
func findInvokeBlocks(text, name string) []string {
open := `<DSMLinvoke name="` + name + `">`
remaining := text

View File

@@ -92,45 +92,11 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin
if tc.Input == nil {
tc.Input = map[string]any{}
}
if len(tc.Input) > 0 && !toolCallInputHasMeaningfulValue(tc.Input) {
continue
}
out = append(out, tc)
}
return out, nil
}
func toolCallInputHasMeaningfulValue(v any) bool {
switch x := v.(type) {
case nil:
return false
case string:
return strings.TrimSpace(x) != ""
case map[string]any:
if len(x) == 0 {
return false
}
for _, child := range x {
if toolCallInputHasMeaningfulValue(child) {
return true
}
}
return false
case []any:
if len(x) == 0 {
return false
}
for _, child := range x {
if toolCallInputHasMeaningfulValue(child) {
return true
}
}
return false
default:
return true
}
}
func looksLikeToolCallSyntax(text string) bool {
hasDSML, hasCanonical := ContainsToolCallWrapperSyntaxOutsideIgnored(text)
return hasDSML || hasCanonical

View File

@@ -383,13 +383,20 @@ func hasPartialToolMarkupNameAfterArbitraryPrefix(text string, start int) bool {
if toolMarkupPrefixAllowsLocalName(text[start:idx]) && hasToolMarkupNamePrefix(text, idx) {
return true
}
if toolMarkupPrefixAllowsLocalName(text[start:idx]) && hasDSMLNamePrefixOrPartial(text, idx) {
return true
}
_, size := utf8.DecodeRuneInString(text[idx:])
if size <= 0 {
size = 1
}
idx += size
}
return false
return toolMarkupPrefixAllowsLocalName(text[start:])
}
func hasDSMLNamePrefixOrPartial(text string, start int) bool {
return hasASCIIPrefixFoldAt(text, start, "dsml") || hasASCIIPartialPrefixFoldAt(text, start, "dsml")
}
func toolMarkupPrefixAllowsLocalName(prefix string) bool {

View File

@@ -576,14 +576,17 @@ func TestParseToolCallsDetailedMarksToolCallsSyntax(t *testing.T) {
}
}
func TestParseToolCallsRejectsAllEmptyParameterPayload(t *testing.T) {
func TestParseToolCallsAllowsAllEmptyParameterPayload(t *testing.T) {
text := `<tool_calls><invoke name="Bash"><parameter name="command"></parameter><parameter name="description"> </parameter><parameter name="timeout"></parameter></invoke></tool_calls>`
res := ParseToolCallsDetailed(text, []string{"Bash"})
if !res.SawToolCallSyntax {
t.Fatalf("expected tool syntax to be detected, got %#v", res)
}
if len(res.Calls) != 0 {
t.Fatalf("expected all-empty payload to be rejected, got %#v", res.Calls)
if len(res.Calls) != 1 {
t.Fatalf("expected all-empty payload to be parsed, got %#v", res.Calls)
}
if res.Calls[0].Input["command"] != "" || res.Calls[0].Input["description"] != "" || res.Calls[0].Input["timeout"] != "" {
t.Fatalf("expected empty parameters to be preserved, got %#v", res.Calls[0].Input)
}
}

View File

@@ -54,7 +54,7 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
}
if parsed.SawToolCallSyntax {
if rejected == nil || tag.Start < rejected.start {
rejected = &rejectedBlock{start: tag.Start, prefix: prefixPart, suffix: suffixPart}
rejected = &rejectedBlock{start: tag.Start, prefix: prefixPart + xmlBlock, suffix: suffixPart}
}
searchFrom = tag.End + 1
continue
@@ -88,7 +88,7 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
return prefixPart, parsed.Calls, suffixPart, true
}
if parsed.SawToolCallSyntax {
return prefixPart, nil, suffixPart, true
return prefixPart + captured[invokeTag.Start:closeTag.End+1], nil, suffixPart, true
}
return prefixPart + captured[invokeTag.Start:closeTag.End+1], nil, suffixPart, true
}

View File

@@ -1,6 +1,7 @@
package toolstream
import (
"ds2api/internal/toolcall"
"strings"
"testing"
)
@@ -179,8 +180,7 @@ func TestProcessToolSieveInterceptsArbitraryPrefixedToolTagsWithoutLeak(t *testi
}
}
func TestProcessToolSieveSuppressesEmptyDSMLControlSeparatorBlockWithoutLeak(t *testing.T) {
var state State
func TestProcessToolSieveEmitsEmptyDSMLControlSeparatorBlockWithoutLeak(t *testing.T) {
sep := "␂"
chunks := []string{
"<DSML" + sep + "tool_calls>\n",
@@ -189,23 +189,12 @@ func TestProcessToolSieveSuppressesEmptyDSMLControlSeparatorBlockWithoutLeak(t *
" </DSML" + sep + "invoke>\n",
"</DSML" + sep + "tool_calls>",
}
var events []Event
for _, c := range chunks {
events = append(events, ProcessChunk(&state, c, []string{"Read"})...)
calls := collectToolCallsForChunks(t, chunks, []string{"Read"})
if len(calls) != 1 {
t.Fatalf("expected empty control-separator block to produce one call, got %#v", calls)
}
events = append(events, Flush(&state, []string{"Read"})...)
var textContent strings.Builder
toolCalls := 0
for _, evt := range events {
textContent.WriteString(evt.Content)
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 0 {
t.Fatalf("expected empty control-separator block not to produce calls, got %d events=%#v", toolCalls, events)
}
if text := textContent.String(); strings.Contains(strings.ToLower(text), "dsml") || strings.Contains(text, "Read") || strings.Contains(text, sep) {
t.Fatalf("expected empty control-separator block not to leak as text, got %q", text)
if calls[0].Name != "Read" || calls[0].Input["file_path"] != "" {
t.Fatalf("expected empty file_path parameter to be preserved, got %#v", calls)
}
}
@@ -595,7 +584,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
}
}
func TestProcessToolSieveSuppressesMalformedExecutableXMLBlock(t *testing.T) {
func TestProcessToolSieveReleasesMalformedExecutableXMLBlock(t *testing.T) {
var state State
chunk := `<tool_calls><invoke name="read_file"><param>{"path":"README.md"}</param></invoke></tool_calls>`
events := ProcessChunk(&state, chunk, []string{"read_file"})
@@ -611,13 +600,12 @@ func TestProcessToolSieveSuppressesMalformedExecutableXMLBlock(t *testing.T) {
if toolCalls != 0 {
t.Fatalf("expected malformed executable-looking XML not to become a tool call, got %d events=%#v", toolCalls, events)
}
if textContent.Len() != 0 {
t.Fatalf("expected malformed executable-looking XML to be suppressed, got %q", textContent.String())
if textContent.String() != chunk {
t.Fatalf("expected malformed executable-looking XML to be released as text, got %q", textContent.String())
}
}
func TestProcessToolSieveSuppressesAllEmptyDSMLToolBlock(t *testing.T) {
var state State
func TestProcessToolSieveEmitsAllEmptyDSMLToolBlock(t *testing.T) {
chunk := strings.Join([]string{
`<|DSML|tool_calls>`,
`<|DSML|invoke name="Bash">`,
@@ -627,22 +615,69 @@ func TestProcessToolSieveSuppressesAllEmptyDSMLToolBlock(t *testing.T) {
`</|DSML|invoke>`,
`</|DSML|tool_calls>`,
}, "\n")
events := ProcessChunk(&state, chunk, []string{"Bash"})
events = append(events, Flush(&state, []string{"Bash"})...)
calls := collectToolCallsForChunks(t, []string{chunk}, []string{"Bash"})
if len(calls) != 1 {
t.Fatalf("expected all-empty DSML block to produce one tool call, got %#v", calls)
}
if calls[0].Input["command"] != "" || calls[0].Input["description"] != "" || calls[0].Input["timeout"] != "" {
t.Fatalf("expected empty parameters to be preserved, got %#v", calls[0].Input)
}
}
func TestProcessToolSieveEmitsChunkedAllEmptyArbitraryPrefixedToolBlock(t *testing.T) {
chunk := strings.Join([]string{
`<TDSMLtool_calls>`,
` <TDSMLinvoke name="TaskOutput">`,
` <TDSMLparameter name="task_id"></TDSMLparameter>`,
` <TDSMLparameter name="block"></TDSMLparameter>`,
` <TDSMLparameter name="timeout"></TDSMLparameter>`,
` </TDSMLinvoke>`,
` </TDSMLtool_calls>`,
}, "\n")
calls := collectToolCallsForChunks(t, splitEveryNRBytes(chunk, 8), []string{"TaskOutput"})
if len(calls) != 1 {
t.Fatalf("expected chunked all-empty arbitrary-prefixed block to produce one tool call, got %#v", calls)
}
if calls[0].Name != "TaskOutput" || calls[0].Input["task_id"] != "" || calls[0].Input["block"] != "" || calls[0].Input["timeout"] != "" {
t.Fatalf("expected empty TaskOutput parameters to be preserved, got %#v", calls)
}
}
func collectToolCallsForChunks(t *testing.T, chunks []string, toolNames []string) []toolcall.ParsedToolCall {
t.Helper()
var state State
var events []Event
for _, chunk := range chunks {
events = append(events, ProcessChunk(&state, chunk, toolNames)...)
}
events = append(events, Flush(&state, toolNames)...)
var textContent strings.Builder
toolCalls := 0
var calls []toolcall.ParsedToolCall
for _, evt := range events {
textContent.WriteString(evt.Content)
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 0 {
t.Fatalf("expected all-empty DSML block not to produce tool calls, got %d events=%#v", toolCalls, events)
calls = append(calls, evt.ToolCalls...)
}
if textContent.Len() != 0 {
t.Fatalf("expected all-empty DSML block not to leak as text, got %q", textContent.String())
t.Fatalf("expected tool block not to leak as text, got %q", textContent.String())
}
return calls
}
func splitEveryNRBytes(s string, n int) []string {
if n <= 0 {
return []string{s}
}
out := make([]string, 0, len(s)/n+1)
for len(s) > 0 {
if len(s) <= n {
out = append(out, s)
break
}
out = append(out, s[:n])
s = s[n:]
}
return out
}
func TestProcessToolSievePassesThroughFencedXMLToolCallExamples(t *testing.T) {
@@ -776,6 +811,8 @@ func TestFindPartialXMLToolTagStart(t *testing.T) {
{"partial_tool_calls", "Hello <tool_ca", 6},
{"partial_dsml_trailing_pipe", "Hello <|DSML|tool_calls|", 6},
{"partial_dsml_extra_leading_less_than", "Hello <<|DSML|tool_calls", 6},
{"partial_arbitrary_prefix_before_dsml", "Hello <TDS", 6},
{"partial_arbitrary_prefix_after_dsml_pipe", "Hello <TDSML", 6},
{"partial_invoke", "Hello <inv", 6},
{"bare_tool_call_not_held", "Hello <tool_name", -1},
{"partial_lt_only", "Text <", 5},

View File

@@ -155,6 +155,20 @@ test('parseToolCalls parses arbitrary-prefixed tool tags', () => {
assert.deepEqual(calls[0].input, { file_path: '/tmp/input.txt' });
});
test('parseToolCalls allows all-empty parameter payloads', () => {
const payload = `<TDSMLtool_calls>
<TDSMLinvoke name="TaskOutput">
<TDSMLparameter name="task_id"></TDSMLparameter>
<TDSMLparameter name="block"></TDSMLparameter>
<TDSMLparameter name="timeout"></TDSMLparameter>
</TDSMLinvoke>
</TDSMLtool_calls>`;
const calls = parseToolCalls(payload, ['TaskOutput']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'TaskOutput');
assert.deepEqual(calls[0].input, { task_id: '', block: '', timeout: '' });
});
test('parseToolCalls ignores bare hyphenated tool_calls lookalike', () => {
const payload = '<tool-calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></tool-calls>';
const calls = parseToolCalls(payload, ['Bash']);
@@ -509,6 +523,26 @@ test('sieve emits tool_calls for arbitrary-prefixed tool tags', () => {
assert.equal(text.includes('💥'), false);
});
test('sieve emits all-empty arbitrary-prefixed tool tags without leaking text', () => {
const payload = [
'<TDSMLtool_calls>\n',
' <TDSMLinvoke name="TaskOutput">\n',
' <TDSMLparameter name="task_id"></TDSMLparameter>\n',
' <TDSMLparameter name="block"></TDSMLparameter>\n',
' <TDSMLparameter name="timeout"></TDSMLparameter>\n',
' </TDSMLinvoke>\n',
'</TDSMLtool_calls>',
].join('');
for (const chunks of [[payload], payload.match(/.{1,8}/gs)]) {
const events = runSieve(chunks, ['TaskOutput']);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
assert.equal(finalCalls.length, 1);
assert.equal(finalCalls[0].name, 'TaskOutput');
assert.deepEqual(finalCalls[0].input, { task_id: '', block: '', timeout: '' });
assert.equal(collectText(events), '');
}
});
test('sieve emits tool_calls for extra leading less-than DSML tags without leaking prefix', () => {
const events = runSieve([
'<<|DSML|tool_calls>\n',
@@ -847,7 +881,7 @@ test('sieve keeps embedded invalid tool-like json as normal text to avoid stream
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
});
test('sieve passes malformed executable-looking XML through as text', () => {
test('sieve releases malformed executable-looking XML wrappers as text', () => {
const chunk = '<tool_calls><invoke name="read_file"><param>{"path":"README.MD"}</param></invoke></tool_calls>';
const events = runSieve([chunk], ['read_file']);
const leakedText = collectText(events);