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
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/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', () => {
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);
});