From 97a81c419186310b55ef31724d29929066a60ead Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 22 Mar 2026 20:07:12 +0800 Subject: [PATCH 1/3] Harden toolcall leak interception for function-style payloads --- .../adapter/claude/handler_stream_test.go | 34 +++++ internal/adapter/claude/standard_request.go | 3 + .../adapter/claude/stream_runtime_core.go | 10 -- .../adapter/claude/stream_runtime_finalize.go | 4 +- .../adapter/openai/handler_toolcall_format.go | 21 ++-- .../adapter/openai/handler_toolcall_test.go | 35 +++--- .../adapter/openai/responses_stream_test.go | 22 ++-- internal/adapter/openai/standard_request.go | 21 +++- .../adapter/openai/standard_request_test.go | 6 +- internal/adapter/openai/tool_sieve_core.go | 4 +- internal/js/chat-stream/toolcall_policy.js | 22 +--- .../js/helpers/stream-tool-sieve/parse.js | 50 +------- .../stream-tool-sieve/parse_payload.js | 19 ++- .../stream-tool-sieve/tool-keywords.js | 1 + internal/util/toolcalls_candidates.go | 8 +- internal/util/toolcalls_parse.go | 44 +------ internal/util/toolcalls_parse_markup.go | 110 ++++++++++++++++ internal/util/toolcalls_test.go | 118 ++++++++++++------ internal/util/util_edge_test.go | 4 +- tests/node/stream-tool-sieve.test.js | 22 ++-- 20 files changed, 336 insertions(+), 222 deletions(-) diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index 3d574fe..fccf287 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -358,6 +358,40 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing. } } +func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) { + h := &Handler{} + payload := "I'll call a tool now.\\nwrite_file{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}" + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/content","v":"`+payload+`"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + foundToolUse := false + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" && contentBlock["name"] == "write_file" { + foundToolUse = true + break + } + } + if !foundToolUse { + t.Fatalf("expected tool_use block with leading prose payload, body=%s", rec.Body.String()) + } + + for _, f := range findClaudeFrames(frames, "message_delta") { + delta, _ := f.Payload["delta"].(map[string]any) + if delta["stop_reason"] == "tool_use" { + return + } + } + t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String()) +} + func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) { h := &Handler{} resp := makeClaudeSSEHTTPResponse( diff --git a/internal/adapter/claude/standard_request.go b/internal/adapter/claude/standard_request.go index 23520c0..488bdf6 100644 --- a/internal/adapter/claude/standard_request.go +++ b/internal/adapter/claude/standard_request.go @@ -38,6 +38,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma } finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"])) toolNames := extractClaudeToolNames(toolsRequested) + if len(toolNames) == 0 && len(toolsRequested) > 0 { + toolNames = []string{"__any_tool__"} + } return claudeNormalizedRequest{ Standard: util.StandardRequest{ diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/adapter/claude/stream_runtime_core.go index fead90a..a3dd649 100644 --- a/internal/adapter/claude/stream_runtime_core.go +++ b/internal/adapter/claude/stream_runtime_core.go @@ -8,7 +8,6 @@ import ( "ds2api/internal/sse" streamengine "ds2api/internal/stream" - "ds2api/internal/util" ) type claudeStreamRuntime struct { @@ -120,15 +119,6 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse if hasUnclosedCodeFence(s.text.String()) { continue } - detected := util.ParseToolCalls(s.text.String(), s.toolNames) - if len(detected) > 0 { - s.finalize("tool_use") - return streamengine.ParsedDecision{ - ContentSeen: true, - Stop: true, - StopReason: streamengine.StopReason("tool_use_detected"), - } - } continue } s.closeThinkingBlock() diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index 18a9e2d..d7994b4 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -45,9 +45,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { finalText := s.text.String() if s.bufferToolContent { - detected := util.ParseToolCalls(finalText, s.toolNames) + detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) if len(detected) == 0 && finalText == "" && finalThinking != "" { - detected = util.ParseToolCalls(finalThinking, s.toolNames) + detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames) } if len(detected) > 0 { stopReason = "tool_use" diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go index 4c38454..76f16fd 100644 --- a/internal/adapter/openai/handler_toolcall_format.go +++ b/internal/adapter/openai/handler_toolcall_format.go @@ -111,28 +111,21 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam if len(deltas) == 0 { return nil } - allowed := namesToSet(allowedNames) - if len(allowed) == 0 { - for _, d := range deltas { - if d.Name != "" { - seenNames[d.Index] = "__blocked__" - } - } - return nil - } out := make([]toolCallDelta, 0, len(deltas)) for _, d := range deltas { if d.Name != "" { - if _, ok := allowed[d.Name]; !ok { - seenNames[d.Index] = "__blocked__" - continue + if seenNames != nil { + seenNames[d.Index] = d.Name } - seenNames[d.Index] = d.Name + out = append(out, d) + continue + } + if seenNames == nil { out = append(out, d) continue } name := strings.TrimSpace(seenNames[d.Index]) - if name == "" || name == "__blocked__" { + if name == "" { continue } out = append(out, d) diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index d3b849a..41b4c9f 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -182,7 +182,7 @@ func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) { } } -func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) { +func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( `data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`, @@ -198,16 +198,13 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) { out := decodeJSONBody(t, rec.Body.String()) choices, _ := out["choices"].([]any) choice, _ := choices[0].(map[string]any) - if choice["finish_reason"] != "stop" { - t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) + if choice["finish_reason"] != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"]) } msg, _ := choice["message"].(map[string]any) - if _, ok := msg["tool_calls"]; ok { - t.Fatalf("did not expect tool_calls for unknown schema name, got %#v", msg["tool_calls"]) - } - content, _ := msg["content"].(string) - if !strings.Contains(content, `"tool_calls"`) { - t.Fatalf("expected unknown tool json to pass through as text, got %#v", content) + toolCalls, _ := msg["tool_calls"].([]any) + if len(toolCalls) != 1 { + t.Fatalf("expected tool_calls for unknown schema name, got %#v", msg["tool_calls"]) } } @@ -413,7 +410,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing. } } -func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) { +func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( `data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`, @@ -428,18 +425,18 @@ func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) { if !done { t.Fatalf("expected [DONE], body=%s", rec.Body.String()) } - if streamHasToolCallsDelta(frames) { - t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String()) + if !streamHasToolCallsDelta(frames) { + t.Fatalf("expected tool_calls delta for unknown schema name, body=%s", rec.Body.String()) } if streamHasRawToolJSONContent(frames) { t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String()) } - if streamFinishReason(frames) != "stop" { - t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String()) + if streamFinishReason(frames) != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) } } -func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) { +func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( `data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`, @@ -454,14 +451,14 @@ func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) { if !done { t.Fatalf("expected [DONE], body=%s", rec.Body.String()) } - if streamHasToolCallsDelta(frames) { - t.Fatalf("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String()) + if !streamHasToolCallsDelta(frames) { + t.Fatalf("expected tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String()) } if streamHasRawToolJSONContent(frames) { t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String()) } - if streamFinishReason(frames) != "stop" { - t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String()) + if streamFinishReason(frames) != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String()) } } diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index 02d1f4b..fb11ca1 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -354,7 +354,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *te } } -func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { +func TestHandleResponsesStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) rec := httptest.NewRecorder() @@ -376,8 +376,8 @@ func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, policy, "") body := rec.Body.String() - if strings.Contains(body, "event: response.function_call_arguments.done") { - t.Fatalf("did not expect function_call events for tool_choice=none, body=%s", body) + if !strings.Contains(body, "event: response.function_call_arguments.done") { + t.Fatalf("expected function_call events for tool_choice=none, body=%s", body) } } @@ -518,7 +518,7 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) { } } -func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) { +func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) rec := httptest.NewRecorder() @@ -539,8 +539,8 @@ func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) { h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "") body := rec.Body.String() - if strings.Contains(body, "event: response.function_call_arguments.done") { - t.Fatalf("did not expect function_call events for unknown tool, body=%s", body) + if !strings.Contains(body, "event: response.function_call_arguments.done") { + t.Fatalf("expected function_call events for unknown tool, body=%s", body) } } @@ -597,7 +597,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t } } -func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { +func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) { h := &Handler{} rec := httptest.NewRecorder() resp := &http.Response{ @@ -611,16 +611,20 @@ func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, policy, "") if rec.Code != http.StatusOK { - t.Fatalf("expected 200 for tool_choice=none passthrough text, got %d body=%s", rec.Code, rec.Body.String()) + t.Fatalf("expected 200 for tool_choice=none handling, got %d body=%s", rec.Code, rec.Body.String()) } out := decodeJSONBody(t, rec.Body.String()) output, _ := out["output"].([]any) + foundFunctionCall := false for _, item := range output { m, _ := item.(map[string]any) if m != nil && m["type"] == "function_call" { - t.Fatalf("did not expect function_call output item for tool_choice=none, got %#v", output) + foundFunctionCall = true } } + if !foundFunctionCall { + t.Fatalf("expected function_call output item for tool_choice=none, got %#v", output) + } } func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) { diff --git a/internal/adapter/openai/standard_request.go b/internal/adapter/openai/standard_request.go index 1ba957c..af382cb 100644 --- a/internal/adapter/openai/standard_request.go +++ b/internal/adapter/openai/standard_request.go @@ -25,6 +25,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID } toolPolicy := util.DefaultToolChoicePolicy() finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy) + toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) passThrough := collectOpenAIChatPassThrough(req) return util.StandardRequest{ @@ -74,10 +75,8 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra return util.StandardRequest{}, err } finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy) - if toolPolicy.IsNone() { - toolNames = nil - toolPolicy.Allowed = nil - } else { + toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) + if !toolPolicy.IsNone() { toolPolicy.Allowed = namesToSet(toolNames) } passThrough := collectOpenAIChatPassThrough(req) @@ -98,6 +97,20 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra }, nil } +func ensureToolDetectionEnabled(toolNames []string, toolsRaw any) []string { + if len(toolNames) > 0 { + return toolNames + } + tools, _ := toolsRaw.([]any) + if len(tools) == 0 { + return toolNames + } + // Keep stream sieve/tool buffering enabled even when client tool schemas + // are malformed or lack explicit names; parsed tool payload names are no + // longer filtered by this list. + return []string{"__any_tool__"} +} + func collectOpenAIChatPassThrough(req map[string]any) map[string]any { out := map[string]any{} for _, k := range []string{ diff --git a/internal/adapter/openai/standard_request_test.go b/internal/adapter/openai/standard_request_test.go index e8d1225..45a3976 100644 --- a/internal/adapter/openai/standard_request_test.go +++ b/internal/adapter/openai/standard_request_test.go @@ -152,7 +152,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceForcedUndeclaredFails(t *testi } } -func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T) { +func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(t *testing.T) { store := newEmptyStoreForNormalizeTest(t) req := map[string]any{ "model": "gpt-4o", @@ -174,7 +174,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T if n.ToolChoice.Mode != util.ToolChoiceNone { t.Fatalf("expected tool choice mode none, got %q", n.ToolChoice.Mode) } - if len(n.ToolNames) != 0 { - t.Fatalf("expected no tool names when tool_choice=none, got %#v", n.ToolNames) + if len(n.ToolNames) == 0 { + t.Fatalf("expected tool detection sentinel when tool_choice=none, got %#v", n.ToolNames) } } diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 3ee9eda..ad2c231 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -167,7 +167,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -195,7 +195,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix } lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { diff --git a/internal/js/chat-stream/toolcall_policy.js b/internal/js/chat-stream/toolcall_policy.js index e881bab..4a3bbed 100644 --- a/internal/js/chat-stream/toolcall_policy.js +++ b/internal/js/chat-stream/toolcall_policy.js @@ -8,7 +8,10 @@ const { function resolveToolcallPolicy(prepBody, payloadTools) { const preparedToolNames = normalizePreparedToolNames(prepBody && prepBody.tool_names); - const toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools); + let toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools); + if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) { + toolNames = ['__any_tool__']; + } const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match); const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high); return { @@ -76,17 +79,6 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName return []; } const seen = seenNames instanceof Map ? seenNames : new Map(); - const allowed = new Set((allowedNames || []).filter((name) => asString(name) !== '')); - if (allowed.size === 0) { - for (const d of deltas) { - if (d && typeof d === 'object' && asString(d.name)) { - const index = Number.isInteger(d.index) ? d.index : 0; - seen.set(index, '__blocked__'); - } - } - return []; - } - const out = []; for (const d of deltas) { if (!d || typeof d !== 'object') { @@ -95,16 +87,12 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName const index = Number.isInteger(d.index) ? d.index : 0; const name = asString(d.name); if (name) { - if (!allowed.has(name)) { - seen.set(index, '__blocked__'); - continue; - } seen.set(index, name); out.push(d); continue; } const existing = asString(seen.get(index)); - if (!existing || existing === '__blocked__') { + if (!existing) { continue; } out.push(d); diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 586c45b..d1d3e89 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -140,63 +140,17 @@ function emptyParseResult() { } function filterToolCallsDetailed(parsed, toolNames) { - const sourceNames = Array.isArray(toolNames) ? toolNames : []; - const allowed = new Set(); - const allowedCanonical = new Map(); - for (const item of sourceNames) { - const name = toStringSafe(item); - if (!name) { - continue; - } - allowed.add(name); - const lower = name.toLowerCase(); - if (!allowedCanonical.has(lower)) { - allowedCanonical.set(lower, name); - } - } - - if (allowed.size === 0) { - const rejected = []; - const seen = new Set(); - for (const tc of parsed) { - if (!tc || !tc.name) { - continue; - } - if (seen.has(tc.name)) { - continue; - } - seen.add(tc.name); - rejected.push(tc.name); - } - return { calls: [], rejectedToolNames: rejected }; - } - const calls = []; - const rejected = []; - const seenRejected = new Set(); for (const tc of parsed) { if (!tc || !tc.name) { continue; } - let matchedName = ''; - if (allowed.has(tc.name)) { - matchedName = tc.name; - } else { - matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical); - } - if (!matchedName) { - if (!seenRejected.has(tc.name)) { - seenRejected.add(tc.name); - rejected.push(tc.name); - } - continue; - } calls.push({ - name: matchedName, + name: tc.name, input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {}, }); } - return { calls, rejectedToolNames: rejected }; + return { calls, rejectedToolNames: [] }; } function resolveAllowedToolName(name, allowed, allowedCanonical) { diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index dad52ab..eb27008 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -56,6 +56,11 @@ function buildToolCallCandidates(text) { if (first >= 0 && last > first) { candidates.push(toStringSafe(trimmed.slice(first, last + 1))); } + const firstArr = trimmed.indexOf('['); + const lastArr = trimmed.lastIndexOf(']'); + if (firstArr >= 0 && lastArr > firstArr) { + candidates.push(toStringSafe(trimmed.slice(firstArr, lastArr + 1))); + } const m = trimmed.match(TOOL_CALL_PATTERN); if (m && m[1]) { @@ -76,7 +81,17 @@ function extractToolCallObjects(text) { // eslint-disable-next-line no-constant-condition while (true) { - let idx = lower.indexOf('tool_calls', offset); + const idxToolCalls = lower.indexOf('tool_calls', offset); + const idxFunction = lower.indexOf('"function"', offset); + let idx = -1; + let matched = ''; + if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) { + idx = idxToolCalls; + matched = 'tool_calls'; + } else if (idxFunction >= 0) { + idx = idxFunction; + matched = '"function"'; + } if (idx < 0) { break; } @@ -92,7 +107,7 @@ function extractToolCallObjects(text) { start = raw.slice(0, start).lastIndexOf('{'); } if (idx >= 0) { - offset = idx + 'tool_calls'.length; + offset = idx + matched.length; } } diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js index 34cd226..76be42e 100644 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ b/internal/js/helpers/stream-tool-sieve/tool-keywords.js @@ -2,6 +2,7 @@ const TOOL_SEGMENT_KEYWORDS = [ 'tool_calls', + '"function"', 'function.name:', '[tool_call_history]', '[tool_result_history]', diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index 122ac7f..cbce3ef 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -30,6 +30,12 @@ func buildToolCallCandidates(text string) []string { if first >= 0 && last > first { candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1])) } + // best-effort array slice: from first '[' to last ']' + firstArr := strings.Index(trimmed, "[") + lastArr := strings.LastIndex(trimmed, "]") + if firstArr >= 0 && lastArr > firstArr { + candidates = append(candidates, strings.TrimSpace(trimmed[firstArr:lastArr+1])) + } // legacy regex extraction fallback if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 { @@ -58,7 +64,7 @@ func extractToolCallObjects(text string) []string { lower := strings.ToLower(text) out := []string{} offset := 0 - keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]"} for { bestIdx := -1 matchedKeyword := "" diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 7aad445..ff5ed19 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -16,6 +16,7 @@ type ToolCallParseResult struct { RejectedByPolicy bool RejectedToolNames []string } + func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { return ParseToolCallsDetailed(text, availableToolNames).Calls } @@ -119,56 +120,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) } func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) { - allowed := map[string]struct{}{} - allowedCanonical := map[string]string{} - for _, name := range availableToolNames { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - continue - } - allowed[trimmed] = struct{}{} - lower := strings.ToLower(trimmed) - if _, exists := allowedCanonical[lower]; !exists { - allowedCanonical[lower] = trimmed - } - } - if len(allowed) == 0 { - rejectedSet := map[string]struct{}{} - rejected := make([]string, 0, len(parsed)) - for _, tc := range parsed { - if tc.Name == "" { - continue - } - if _, ok := rejectedSet[tc.Name]; ok { - continue - } - rejectedSet[tc.Name] = struct{}{} - rejected = append(rejected, tc.Name) - } - return nil, rejected - } out := make([]ParsedToolCall, 0, len(parsed)) - rejectedSet := map[string]struct{}{} - rejected := make([]string, 0) for _, tc := range parsed { if tc.Name == "" { continue } - matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical) - if matchedName == "" { - if _, ok := rejectedSet[tc.Name]; !ok { - rejectedSet[tc.Name] = struct{}{} - rejected = append(rejected, tc.Name) - } - continue - } - tc.Name = matchedName if tc.Input == nil { tc.Input = map[string]any{} } out = append(out, tc) } - return out, rejected + return out, nil } func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string { @@ -228,8 +190,10 @@ func isLikelyChatMessageEnvelope(v map[string]any) bool { func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) return strings.Contains(lower, "tool_calls") || + strings.Contains(lower, "\"function\"") || strings.Contains(lower, "(.*?)`) var invokeParamPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) var toolUseFunctionPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) +var toolUseNameParametersPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*\s*(.*?)\s*\s*`) +var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*\s*(.*?)\s*\s*`) +var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)\s*\s*([^<]+?)\s*\s*(.*?)\s*`) func parseXMLToolCalls(text string) []ParsedToolCall { matches := xmlToolCallPattern.FindAllString(text, -1) @@ -42,6 +45,15 @@ func parseXMLToolCalls(text string) []ParsedToolCall { if call, ok := parseToolUseFunctionStyle(text); ok { return []ParsedToolCall{call} } + if call, ok := parseToolUseNameParametersStyle(text); ok { + return []ParsedToolCall{call} + } + if call, ok := parseToolUseFunctionNameParametersStyle(text); ok { + return []ParsedToolCall{call} + } + if call, ok := parseToolUseToolNameBodyStyle(text); ok { + return []ParsedToolCall{call} + } return nil } @@ -257,6 +269,104 @@ func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) { return ParsedToolCall{Name: name, Input: input}, true } +func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) { + m := toolUseNameParametersPattern.FindStringSubmatch(text) + if len(m) < 3 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + raw := strings.TrimSpace(m[2]) + input := map[string]any{} + if raw != "" { + if parsed := parseToolCallInput(raw); len(parsed) > 0 { + input = parsed + } else if kv := parseMarkupKVObject(raw); len(kv) > 0 { + input = kv + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) { + m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text) + if len(m) < 3 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + raw := strings.TrimSpace(m[2]) + input := map[string]any{} + if raw != "" { + if parsed := parseToolCallInput(raw); len(parsed) > 0 { + input = parsed + } else if kv := parseMarkupKVObject(raw); len(kv) > 0 { + input = kv + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) { + m := toolUseToolNameBodyPattern.FindStringSubmatch(text) + if len(m) < 3 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + body := strings.TrimSpace(m[2]) + input := map[string]any{} + if body != "" { + if kv := parseXMLChildKV(body); len(kv) > 0 { + input = kv + } else if kv := parseMarkupKVObject(body); len(kv) > 0 { + input = kv + } else if parsed := parseToolCallInput(body); len(parsed) > 0 { + input = parsed + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func parseXMLChildKV(body string) map[string]any { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return nil + } + dec := xml.NewDecoder(strings.NewReader("" + trimmed + "")) + out := map[string]any{} + for { + tok, err := dec.Token() + if err != nil { + break + } + start, ok := tok.(xml.StartElement) + if !ok || strings.EqualFold(start.Name.Local, "root") { + continue + } + var v string + if err := dec.DecodeElement(&v, &start); err != nil { + continue + } + key := strings.TrimSpace(start.Name.Local) + val := strings.TrimSpace(v) + if key == "" || val == "" { + continue + } + out[key] = val + } + if len(out) == 0 { + return nil + } + return out +} + func asString(v any) string { s, _ := v.(string) return s diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 215d479..c5fbd52 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -41,50 +41,50 @@ func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) { } } -func TestParseToolCallsRejectsUnknownToolName(t *testing.T) { +func TestParseToolCallsKeepsUnknownToolName(t *testing.T) { text := `{"tool_calls":[{"name":"unknown","input":{}}]}` calls := ParseToolCalls(text, []string{"search"}) - if len(calls) != 0 { - t.Fatalf("expected unknown tool to be rejected, got %#v", calls) + if len(calls) != 1 || calls[0].Name != "unknown" { + t.Fatalf("expected unknown tool to be preserved, got %#v", calls) } } -func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) { +func TestParseToolCallsKeepsOriginalToolNameCase(t *testing.T) { text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } } -func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) { +func TestParseToolCallsDetailedDoesNotRejectByPolicy(t *testing.T) { text := `{"tool_calls":[{"name":"unknown","input":{}}]}` res := ParseToolCallsDetailed(text, []string{"search"}) if !res.SawToolCallSyntax { t.Fatalf("expected SawToolCallSyntax=true, got %#v", res) } - if !res.RejectedByPolicy { - t.Fatalf("expected RejectedByPolicy=true, got %#v", res) + if res.RejectedByPolicy { + t.Fatalf("expected RejectedByPolicy=false, got %#v", res) } - if len(res.Calls) != 0 { - t.Fatalf("expected no calls after policy rejection, got %#v", res.Calls) + if len(res.Calls) != 1 || res.Calls[0].Name != "unknown" { + t.Fatalf("expected call to be preserved, got %#v", res.Calls) } } -func TestParseToolCallsDetailedRejectsWhenAllowListEmpty(t *testing.T) { +func TestParseToolCallsDetailedAllowsWhenAllowListEmpty(t *testing.T) { text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` res := ParseToolCallsDetailed(text, nil) if !res.SawToolCallSyntax { t.Fatalf("expected SawToolCallSyntax=true, got %#v", res) } - if !res.RejectedByPolicy { - t.Fatalf("expected RejectedByPolicy=true, got %#v", res) + if res.RejectedByPolicy { + t.Fatalf("expected RejectedByPolicy=false, got %#v", res) } - if len(res.Calls) != 0 { - t.Fatalf("expected no calls when allow-list is empty, got %#v", res.Calls) + if len(res.Calls) != 1 || res.Calls[0].Name != "search" { + t.Fatalf("expected calls when allow-list is empty, got %#v", res.Calls) } } @@ -132,8 +132,8 @@ func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "search_web" { - t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name) + if calls[0].Name != "mcp.search_web" { + t.Fatalf("expected original tool name mcp.search_web, got %q", calls[0].Name) } } @@ -143,8 +143,8 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "read_file" { - t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name) + if calls[0].Name != "read-file" { + t.Fatalf("expected original tool name read-file, got %q", calls[0].Name) } } @@ -154,8 +154,8 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -179,8 +179,8 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -193,8 +193,8 @@ func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "ls -la" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -207,8 +207,8 @@ func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -221,8 +221,8 @@ func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -235,8 +235,8 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) { if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -257,14 +257,56 @@ func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) { } } +func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) { + text := `write_file{"path":"/tmp/a.txt","content":"abc"}` + calls := ParseToolCalls(text, []string{"write_file"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "write_file" { + t.Fatalf("expected tool name write_file, got %q", calls[0].Name) + } + if calls[0].Input["path"] != "/tmp/a.txt" { + t.Fatalf("expected path argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) { + text := `write_file{"path":"/tmp/b.txt","content":"xyz"}` + calls := ParseToolCalls(text, []string{"write_file"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "write_file" { + t.Fatalf("expected tool name write_file, got %q", calls[0].Name) + } + if calls[0].Input["content"] != "xyz" { + t.Fatalf("expected content argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) { + text := `write_file/tmp/c.txthello` + calls := ParseToolCalls(text, []string{"write_file"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "write_file" { + t.Fatalf("expected tool name write_file, got %q", calls[0].Name) + } + if calls[0].Input["path"] != "/tmp/c.txt" { + t.Fatalf("expected path argument, got %#v", calls[0].Input) + } +} + func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) { text := `pwdshow cwd` calls := ParseToolCalls(text, []string{"bash"}) if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -277,8 +319,8 @@ func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testin if len(calls) != 1 { t.Fatalf("expected 1 call, got %#v", calls) } - if calls[0].Name != "bash" { - t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + if calls[0].Name != "Bash" { + t.Fatalf("expected original tool name Bash, got %q", calls[0].Name) } if calls[0].Input["command"] != "pwd" { t.Fatalf("expected command argument, got %#v", calls[0].Input) @@ -291,8 +333,8 @@ func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) { if len(calls) != 2 { t.Fatalf("expected 2 calls, got %#v", calls) } - if calls[0].Name != "bash" || calls[1].Name != "read" { - t.Fatalf("expected canonical names [bash read], got %#v", calls) + if calls[0].Name != "Bash" || calls[1].Name != "Read" { + t.Fatalf("expected original names [Bash Read], got %#v", calls) } } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 8113709..5d024a9 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -364,8 +364,8 @@ func TestFormatOpenAIStreamToolCalls(t *testing.T) { func TestParseToolCallsNoToolNames(t *testing.T) { text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` calls := ParseToolCalls(text, nil) - if len(calls) != 0 { - t.Fatalf("expected 0 call with nil tool names, got %d", len(calls)) + if len(calls) != 1 { + t.Fatalf("expected 1 call with nil tool names, got %d", len(calls)) } } diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 23834ec..36ee798 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -55,33 +55,34 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', () ]); }); -test('parseToolCalls drops unknown schema names when toolNames is provided', () => { +test('parseToolCalls keeps unknown schema names when toolNames is provided', () => { const payload = JSON.stringify({ tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }], }); const calls = parseToolCalls(payload, ['search']); - assert.equal(calls.length, 0); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'not_in_schema'); }); -test('parseToolCalls matches tool name case-insensitively and canonicalizes', () => { +test('parseToolCalls keeps original tool name casing', () => { const payload = JSON.stringify({ tool_calls: [{ name: 'Read_File', input: { path: 'README.MD' } }], }); const calls = parseToolCalls(payload, ['read_file']); - assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.MD' } }]); + assert.deepEqual(calls, [{ name: 'Read_File', input: { path: 'README.MD' } }]); }); -test('parseToolCalls rejects all names when toolNames is empty (Go strict parity)', () => { +test('parseToolCalls accepts all names when toolNames is empty', () => { const payload = JSON.stringify({ tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }], }); const calls = parseToolCalls(payload, []); - assert.equal(calls.length, 0); + assert.equal(calls.length, 1); const detailed = parseToolCallsDetailed(payload, []); assert.equal(detailed.sawToolCallSyntax, true); - assert.equal(detailed.rejectedByPolicy, true); - assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']); + assert.equal(detailed.rejectedByPolicy, false); + assert.deepEqual(detailed.rejectedToolNames, []); }); test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => { @@ -287,7 +288,7 @@ test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => assert.equal(leakedText, 'Hello world'); }); -test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => { +test('sieve emits unknown tool payload (no args) as executable tool call', () => { const events = runSieve( ['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'], ['read_file'], @@ -295,8 +296,7 @@ test('sieve intercepts rejected unknown tool payload (no args) without raw leak' const leakedText = collectText(events); const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0); const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0); - assert.equal(hasToolCall, false); - assert.equal(hasToolDelta, false); + assert.equal(hasToolCall || hasToolDelta, true); assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); assert.equal(leakedText.includes('后置正文G。'), true); }); From 15891ddc255deac35770c7aa9e33a25d229699e6 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 22 Mar 2026 21:24:06 +0800 Subject: [PATCH 2/3] Fix quality-gate fixture drift for permissive tool-call policy --- .../expected/toolcalls_allowlist_empty.json | 17 +++++++++++------ .../toolcalls_case_insensitive_canonical.json | 4 ++-- .../expected/toolcalls_loose_normalize.json | 4 ++-- .../toolcalls_namespace_tail_normalize.json | 4 ++-- .../compat/expected/toolcalls_unknown_name.json | 17 +++++++++++------ tests/node/chat-stream.test.js | 9 ++++++--- 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/tests/compat/expected/toolcalls_allowlist_empty.json b/tests/compat/expected/toolcalls_allowlist_empty.json index 073bd0d..79829e5 100644 --- a/tests/compat/expected/toolcalls_allowlist_empty.json +++ b/tests/compat/expected/toolcalls_allowlist_empty.json @@ -1,8 +1,13 @@ { - "calls": [], + "calls": [ + { + "name": "unknown_tool", + "input": { + "x": 1 + } + } + ], "sawToolCallSyntax": true, - "rejectedByPolicy": true, - "rejectedToolNames": [ - "unknown_tool" - ] -} \ No newline at end of file + "rejectedByPolicy": false, + "rejectedToolNames": [] +} diff --git a/tests/compat/expected/toolcalls_case_insensitive_canonical.json b/tests/compat/expected/toolcalls_case_insensitive_canonical.json index 5bcd9ce..ffb2cec 100644 --- a/tests/compat/expected/toolcalls_case_insensitive_canonical.json +++ b/tests/compat/expected/toolcalls_case_insensitive_canonical.json @@ -1,7 +1,7 @@ { "calls": [ { - "name": "read_file", + "name": "Read_File", "input": { "path": "README.MD" } @@ -10,4 +10,4 @@ "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_loose_normalize.json b/tests/compat/expected/toolcalls_loose_normalize.json index 5bcd9ce..c969be4 100644 --- a/tests/compat/expected/toolcalls_loose_normalize.json +++ b/tests/compat/expected/toolcalls_loose_normalize.json @@ -1,7 +1,7 @@ { "calls": [ { - "name": "read_file", + "name": "read-file", "input": { "path": "README.MD" } @@ -10,4 +10,4 @@ "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_namespace_tail_normalize.json b/tests/compat/expected/toolcalls_namespace_tail_normalize.json index 5bcd9ce..7724b56 100644 --- a/tests/compat/expected/toolcalls_namespace_tail_normalize.json +++ b/tests/compat/expected/toolcalls_namespace_tail_normalize.json @@ -1,7 +1,7 @@ { "calls": [ { - "name": "read_file", + "name": "company.fs.read_file", "input": { "path": "README.MD" } @@ -10,4 +10,4 @@ "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +} diff --git a/tests/compat/expected/toolcalls_unknown_name.json b/tests/compat/expected/toolcalls_unknown_name.json index 073bd0d..79829e5 100644 --- a/tests/compat/expected/toolcalls_unknown_name.json +++ b/tests/compat/expected/toolcalls_unknown_name.json @@ -1,8 +1,13 @@ { - "calls": [], + "calls": [ + { + "name": "unknown_tool", + "input": { + "x": 1 + } + } + ], "sawToolCallSyntax": true, - "rejectedByPolicy": true, - "rejectedToolNames": [ - "unknown_tool" - ] -} \ No newline at end of file + "rejectedByPolicy": false, + "rejectedToolNames": [] +} diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 88419d2..3e91697 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -58,7 +58,7 @@ test('boolDefaultTrue keeps false only when explicitly false', () => { assert.equal(boolDefaultTrue(undefined), true); }); -test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up args', () => { +test('filterIncrementalToolCallDeltasByAllowed keeps unknown name and follow-up args', () => { const seen = new Map(); const filtered = filterIncrementalToolCallDeltasByAllowed( [ @@ -68,8 +68,11 @@ test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up ['read_file'], seen, ); - assert.deepEqual(filtered, []); - assert.equal(seen.get(0), '__blocked__'); + assert.deepEqual(filtered, [ + { index: 0, name: 'not_in_schema' }, + { index: 0, arguments: '{"x":1}' }, + ]); + assert.equal(seen.get(0), 'not_in_schema'); }); test('filterIncrementalToolCallDeltasByAllowed keeps allowed name and args', () => { From 7794006513932fe76c196c446f98de4c8091b852 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 22 Mar 2026 21:26:34 +0800 Subject: [PATCH 3/3] Update VERSION --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9183195..58073ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.0 \ No newline at end of file +2.4.1 \ No newline at end of file