mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-12 12:17:47 +08:00
feat: support PascalCase local-name drift in DSML tool markup parsing
Detect camelCase→PascalCase boundaries between arbitrary prefixes and fixed local names (tool_calls/invoke/parameter), so that fused forms like <DSmartToolCalls> are recognized without explicit separator characters. Also add the underscore-free alias "toolcalls" as a valid DSML local name. Includes lookalike rejection tests to ensure near-matches like <DSmartToolCallsExtra> are not falsely accepted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ const XML_ATTR_PATTERN = /\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
||||
const TOOL_MARKUP_NAMES = [
|
||||
{ raw: 'tool_calls', canonical: 'tool_calls' },
|
||||
{ raw: 'tool-calls', canonical: 'tool_calls', dsmlOnly: true },
|
||||
{ raw: 'toolcalls', canonical: 'tool_calls', dsmlOnly: true },
|
||||
{ raw: 'invoke', canonical: 'invoke' },
|
||||
{ raw: 'parameter', canonical: 'parameter' },
|
||||
];
|
||||
@@ -698,7 +699,7 @@ function matchToolMarkupNameAfterArbitraryPrefix(raw, start) {
|
||||
for (const name of TOOL_MARKUP_NAMES) {
|
||||
const matched = matchNormalizedASCII(raw, idx, name.raw);
|
||||
if (!matched.ok) continue;
|
||||
if (!toolMarkupPrefixAllowsLocalName(raw.slice(start, idx))) continue;
|
||||
if (!toolMarkupPrefixAllowsLocalNameAt(raw, start, idx)) continue;
|
||||
return { ok: true, name: name.canonical, start: idx, len: matched.len };
|
||||
}
|
||||
idx += 1;
|
||||
@@ -711,10 +712,10 @@ function hasPartialToolMarkupNameAfterArbitraryPrefix(raw, start) {
|
||||
if (isToolMarkupTagTerminator(raw, idx)) {
|
||||
return false;
|
||||
}
|
||||
if (toolMarkupPrefixAllowsLocalName(raw.slice(start, idx)) && hasToolMarkupNamePrefix(raw, idx)) {
|
||||
if (toolMarkupPrefixAllowsLocalNameAt(raw, start, idx) && hasToolMarkupNamePrefix(raw, idx)) {
|
||||
return true;
|
||||
}
|
||||
if (toolMarkupPrefixAllowsLocalName(raw.slice(start, idx)) && hasDSMLNamePrefixOrPartial(raw, idx)) {
|
||||
if (toolMarkupPrefixAllowsLocalNameAt(raw, start, idx) && hasDSMLNamePrefixOrPartial(raw, idx)) {
|
||||
return true;
|
||||
}
|
||||
idx += 1;
|
||||
@@ -741,6 +742,22 @@ function toolMarkupPrefixAllowsLocalName(prefix) {
|
||||
return !/^[A-Za-z0-9]$/.test(previous);
|
||||
}
|
||||
|
||||
function toolMarkupPrefixAllowsLocalNameAt(raw, start, localStart) {
|
||||
if (start < 0 || localStart <= start || localStart > raw.length) {
|
||||
return false;
|
||||
}
|
||||
const prefix = raw.slice(start, localStart);
|
||||
if (toolMarkupPrefixAllowsLocalName(prefix)) {
|
||||
return true;
|
||||
}
|
||||
if (/[="'"]/.test(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const previous = normalizeFullwidthASCIIChar(prefix[prefix.length - 1] || '');
|
||||
const next = normalizeFullwidthASCIIChar(raw[localStart] || '');
|
||||
return /^[A-Za-z0-9]$/.test(previous) && /^[A-Z]$/.test(next);
|
||||
}
|
||||
|
||||
function toolMarkupPrefixContainsSlash(prefix) {
|
||||
for (const ch of toStringSafe(prefix)) {
|
||||
if (normalizeFullwidthASCIIChar(ch) === '/') {
|
||||
|
||||
@@ -14,6 +14,7 @@ type toolMarkupNameAlias struct {
|
||||
var toolMarkupNames = []toolMarkupNameAlias{
|
||||
{raw: "tool_calls", canonical: "tool_calls"},
|
||||
{raw: "tool-calls", canonical: "tool_calls", dsmlOnly: true},
|
||||
{raw: "toolcalls", canonical: "tool_calls", dsmlOnly: true},
|
||||
{raw: "invoke", canonical: "invoke"},
|
||||
{raw: "parameter", canonical: "parameter"},
|
||||
}
|
||||
@@ -369,7 +370,7 @@ func matchToolMarkupNameAfterArbitraryPrefix(text string, start int) (string, in
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !toolMarkupPrefixAllowsLocalName(text[start:idx]) {
|
||||
if !toolMarkupPrefixAllowsLocalNameAt(text, start, idx) {
|
||||
continue
|
||||
}
|
||||
return name.canonical, idx, nameLen, true
|
||||
@@ -388,10 +389,10 @@ func hasPartialToolMarkupNameAfterArbitraryPrefix(text string, start int) bool {
|
||||
if isToolMarkupTagTerminator(text, idx) {
|
||||
return false
|
||||
}
|
||||
if toolMarkupPrefixAllowsLocalName(text[start:idx]) && hasToolMarkupNamePrefix(text, idx) {
|
||||
if toolMarkupPrefixAllowsLocalNameAt(text, start, idx) && hasToolMarkupNamePrefix(text, idx) {
|
||||
return true
|
||||
}
|
||||
if toolMarkupPrefixAllowsLocalName(text[start:idx]) && hasDSMLNamePrefixOrPartial(text, idx) {
|
||||
if toolMarkupPrefixAllowsLocalNameAt(text, start, idx) && hasDSMLNamePrefixOrPartial(text, idx) {
|
||||
return true
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(text[idx:])
|
||||
@@ -403,6 +404,25 @@ func hasPartialToolMarkupNameAfterArbitraryPrefix(text string, start int) bool {
|
||||
return toolMarkupPrefixAllowsLocalName(text[start:])
|
||||
}
|
||||
|
||||
func toolMarkupPrefixAllowsLocalNameAt(text string, start, localStart int) bool {
|
||||
if start < 0 || localStart <= start || localStart > len(text) {
|
||||
return false
|
||||
}
|
||||
prefix := text[start:localStart]
|
||||
if toolMarkupPrefixAllowsLocalName(prefix) {
|
||||
return true
|
||||
}
|
||||
if strings.ContainsAny(prefix, "=\"'") {
|
||||
return false
|
||||
}
|
||||
prev, prevSize := utf8.DecodeLastRuneInString(prefix)
|
||||
next, _ := utf8.DecodeRuneInString(text[localStart:])
|
||||
if prevSize <= 0 || next == utf8.RuneError {
|
||||
return false
|
||||
}
|
||||
return isASCIIAlphaNumeric(normalizeFullwidthASCII(prev)) && isASCIIUpper(normalizeFullwidthASCII(next))
|
||||
}
|
||||
|
||||
func hasDSMLNamePrefixOrPartial(text string, start int) bool {
|
||||
return hasASCIIPrefixFoldAt(text, start, "dsml") || hasASCIIPartialPrefixFoldAt(text, start, "dsml")
|
||||
}
|
||||
@@ -437,6 +457,14 @@ func normalizedASCIILowerString(text string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isASCIIAlphaNumeric(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
}
|
||||
|
||||
func isASCIIUpper(r rune) bool {
|
||||
return r >= 'A' && r <= 'Z'
|
||||
}
|
||||
|
||||
func isToolMarkupTagTerminator(text string, idx int) bool {
|
||||
if idx >= len(text) {
|
||||
return false
|
||||
|
||||
@@ -111,6 +111,25 @@ func TestParseToolCallsSupportsArbitraryPrefixedToolMarkup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsCamelPrefixedToolMarkup(t *testing.T) {
|
||||
text := `<DSmartToolCalls><DSmartInvoke name="Bash"><DSmartParameter name="command"><![CDATA[git push]]></DSmartParameter><DSmartParameter name="description"><![CDATA[Push dev branch to origin]]></DSmartParameter></DSmartInvoke></DSmartToolCalls>`
|
||||
calls := ParseToolCalls(text, []string{"Bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected one camel-prefixed tool call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" || calls[0].Input["command"] != "git push" || calls[0].Input["description"] != "Push dev branch to origin" {
|
||||
t.Fatalf("unexpected camel-prefixed tool call: %#v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsRejectsCamelPrefixedToolMarkupLookalike(t *testing.T) {
|
||||
text := `<DSmartToolCallsExtra><DSmartInvoke name="Bash"><DSmartParameter name="command">git push</DSmartParameter></DSmartInvoke></DSmartToolCallsExtra>`
|
||||
calls := ParseToolCalls(text, []string{"Bash"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected camel-prefixed lookalike to be ignored, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsFullwidthDSMLShell(t *testing.T) {
|
||||
text := `<dSML|tool_calls>
|
||||
<dSML|invoke name="Read">
|
||||
|
||||
Reference in New Issue
Block a user