mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-19 23:47:45 +08:00
Merge pull request #89 from CJackHwang/codex/review-changes-in-pull-request-#88
Support text-kv `function.name`/`function.arguments` fallback and looser name matching
This commit is contained in:
@@ -9,6 +9,7 @@ const {
|
|||||||
buildToolCallCandidates,
|
buildToolCallCandidates,
|
||||||
parseToolCallsPayload,
|
parseToolCallsPayload,
|
||||||
parseMarkupToolCalls,
|
parseMarkupToolCalls,
|
||||||
|
parseTextKVToolCalls,
|
||||||
} = require('./parse_payload');
|
} = require('./parse_payload');
|
||||||
|
|
||||||
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
|
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
|
||||||
@@ -53,13 +54,23 @@ function parseToolCallsDetailed(text, toolNames) {
|
|||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
parsed = parseMarkupToolCalls(c);
|
parsed = parseMarkupToolCalls(c);
|
||||||
}
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
parsed = parseTextKVToolCalls(c);
|
||||||
|
}
|
||||||
if (parsed.length > 0) {
|
if (parsed.length > 0) {
|
||||||
result.sawToolCallSyntax = true;
|
result.sawToolCallSyntax = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
return result;
|
parsed = parseMarkupToolCalls(sanitized);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
parsed = parseTextKVToolCalls(sanitized);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sawToolCallSyntax = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterToolCallsDetailed(parsed, toolNames);
|
const filtered = filterToolCallsDetailed(parsed, toolNames);
|
||||||
@@ -90,6 +101,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
|
|||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
parsed = parseMarkupToolCalls(trimmed);
|
parsed = parseMarkupToolCalls(trimmed);
|
||||||
}
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
parsed = parseTextKVToolCalls(trimmed);
|
||||||
|
}
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -207,7 +221,8 @@ function looksLikeToolCallSyntax(text) {
|
|||||||
return lower.includes('tool_calls')
|
return lower.includes('tool_calls')
|
||||||
|| lower.includes('<tool_call')
|
|| lower.includes('<tool_call')
|
||||||
|| lower.includes('<function_call')
|
|| lower.includes('<function_call')
|
||||||
|| lower.includes('<invoke');
|
|| lower.includes('<invoke')
|
||||||
|
|| lower.includes('function.name:');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const TOOL_CALL_MARKUP_ARGS_PATTERNS = [
|
|||||||
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
|
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
|
||||||
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
|
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
|
||||||
];
|
];
|
||||||
|
const TEXT_KV_NAME_PATTERN = /function\.name:\s*([a-zA-Z0-9_.-]+)/gi;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
toStringSafe,
|
toStringSafe,
|
||||||
@@ -141,6 +142,47 @@ function parseMarkupToolCalls(text) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTextKVToolCalls(text) {
|
||||||
|
const raw = toStringSafe(text);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const matches = [...raw.matchAll(TEXT_KV_NAME_PATTERN)];
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < matches.length; i += 1) {
|
||||||
|
const match = matches[i];
|
||||||
|
const name = toStringSafe(match[1]).trim();
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameEnd = match.index + toStringSafe(match[0]).length;
|
||||||
|
const searchEnd = i + 1 < matches.length ? matches[i + 1].index : raw.length;
|
||||||
|
const searchArea = raw.slice(nameEnd, searchEnd);
|
||||||
|
const argIdx = searchArea.indexOf('function.arguments:');
|
||||||
|
if (argIdx < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const argStart = nameEnd + argIdx + 'function.arguments:'.length;
|
||||||
|
const bracePos = raw.slice(argStart, searchEnd).indexOf('{');
|
||||||
|
if (bracePos < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const objStart = argStart + bracePos;
|
||||||
|
const obj = extractJSONObjectFrom(raw, objStart);
|
||||||
|
if (!obj.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
input: parseToolCallInput(raw.slice(objStart, obj.end)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function parseMarkupSingleToolCall(attrs, inner) {
|
function parseMarkupSingleToolCall(attrs, inner) {
|
||||||
const embedded = parseToolCallsPayload(inner);
|
const embedded = parseToolCallsPayload(inner);
|
||||||
if (embedded.length > 0) {
|
if (embedded.length > 0) {
|
||||||
@@ -317,4 +359,5 @@ module.exports = {
|
|||||||
buildToolCallCandidates,
|
buildToolCallCandidates,
|
||||||
parseToolCallsPayload,
|
parseToolCallsPayload,
|
||||||
parseMarkupToolCalls,
|
parseMarkupToolCalls,
|
||||||
|
parseTextKVToolCalls,
|
||||||
};
|
};
|
||||||
|
|||||||
33
internal/util/toolcalls_name_match.go
Normal file
33
internal/util/toolcalls_name_match.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||||
|
if _, ok := allowed[name]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if canonical, ok := allowedCanonical[lower]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
|
||||||
|
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
|
||||||
|
if loose == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for candidateLower, canonical := range allowedCanonical {
|
||||||
|
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -2,12 +2,9 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
|
||||||
|
|
||||||
type ParsedToolCall struct {
|
type ParsedToolCall struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Input map[string]any `json:"input"`
|
Input map[string]any `json:"input"`
|
||||||
@@ -168,28 +165,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||||
if _, ok := allowed[name]; ok {
|
return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical)
|
||||||
return name
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(name))
|
|
||||||
if canonical, ok := allowedCanonical[lower]; ok {
|
|
||||||
return canonical
|
|
||||||
}
|
|
||||||
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
|
|
||||||
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
|
|
||||||
return canonical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
|
|
||||||
if loose == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for candidateLower, canonical := range allowedCanonical {
|
|
||||||
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
|
|
||||||
return canonical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
||||||
|
|||||||
@@ -50,3 +50,14 @@ function.arguments: {"command": "ls"}
|
|||||||
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
|
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseTextKVToolCalls_Standalone(t *testing.T) {
|
||||||
|
text := "function.name: read_file\nfunction.arguments: {\"path\":\"README.md\"}"
|
||||||
|
calls := ParseStandaloneToolCalls(text, []string{"read_file"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].Name != "read_file" {
|
||||||
|
t.Fatalf("unexpected name: %s", calls[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,34 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
|
|||||||
assert.equal(calls.length, 0);
|
assert.equal(calls.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseToolCalls parses text-kv fallback payload', () => {
|
||||||
|
const text = [
|
||||||
|
'[TOOL_CALL_HISTORY]',
|
||||||
|
'function.name: execute_command',
|
||||||
|
'function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}',
|
||||||
|
'[/TOOL_CALL_HISTORY]',
|
||||||
|
'Some other text thinking...',
|
||||||
|
].join('\n');
|
||||||
|
const calls = parseToolCalls(text, ['execute_command']);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].name, 'execute_command');
|
||||||
|
assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseToolCalls parses multiple text-kv fallback payloads', () => {
|
||||||
|
const text = [
|
||||||
|
'function.name: read_file',
|
||||||
|
'function.arguments: {"path":"abc.txt"}',
|
||||||
|
'',
|
||||||
|
'function.name: bash',
|
||||||
|
'function.arguments: {"command":"ls"}',
|
||||||
|
].join('\n');
|
||||||
|
const calls = parseToolCalls(text, ['read_file', 'bash']);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].name, 'read_file');
|
||||||
|
assert.equal(calls[1].name, 'bash');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => {
|
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => {
|
||||||
const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。';
|
const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。';
|
||||||
const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';
|
const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';
|
||||||
|
|||||||
Reference in New Issue
Block a user