Files
goclaw/internal/providers/control_output_normalizer.go
T
2026-07-02 16:24:25 +07:00

399 lines
10 KiB
Go

package providers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
)
const (
kimiToolSectionBegin = "<|tool_calls_section_begin|>"
kimiToolSectionEnd = "<|tool_calls_section_end|>"
kimiToolCallBegin = "<|tool_call_begin|>"
kimiToolArgsBegin = "<|tool_call_argument_begin|>"
kimiToolCallEnd = "<|tool_call_end|>"
)
type controlOutput struct {
Content string
Thinking string
ToolCalls []ToolCall
}
type controlOutputNormalizer struct {
tools []ToolDefinition
pending string
}
func newControlOutputNormalizer(tools []ToolDefinition) *controlOutputNormalizer {
return &controlOutputNormalizer{tools: tools}
}
func (n *controlOutputNormalizer) Append(delta string) controlOutput {
n.pending += delta
return n.drain(false)
}
func (n *controlOutputNormalizer) Finish() controlOutput {
return n.drain(true)
}
func (n *controlOutputNormalizer) drain(final bool) controlOutput {
var out controlOutput
for n.pending != "" {
cut, wait := safeControlCut(n.pending, final)
if wait {
break
}
if cut == 0 {
break
}
part := n.pending[:cut]
n.pending = n.pending[cut:]
normalized := normalizeControlOutput(part, "", n.tools)
out.Content += normalized.Content
out.Thinking = joinThinking(out.Thinking, normalized.Thinking)
out.ToolCalls = append(out.ToolCalls, normalized.ToolCalls...)
}
return out
}
func normalizeControlOutput(content, thinking string, tools []ToolDefinition) controlOutput {
out := controlOutput{Content: content, Thinking: thinking}
out.Content, out.ToolCalls = extractKimiTextToolCalls(out.Content, tools)
var extracted string
out.Content, extracted = extractThinkingTags(out.Content)
out.Thinking = joinThinking(out.Thinking, extracted)
return out
}
func safeControlCut(text string, final bool) (int, bool) {
if final {
return len(text), false
}
start := firstControlStart(text)
if start >= 0 {
if start > 0 {
return start, false
}
if end := completeControlEnd(text); end > 0 {
return end, false
}
return 0, true
}
keep := controlPrefixSuffixLen(text)
if keep > 0 {
if keep == len(text) {
return 0, true
}
return len(text) - keep, false
}
return len(text), false
}
func firstControlStart(text string) int {
start := strings.Index(text, kimiToolSectionBegin)
lower := strings.ToLower(text)
for _, token := range thinkingStartTokens {
if idx := strings.Index(lower, token); idx >= 0 && (start < 0 || idx < start) {
start = idx
}
}
return start
}
func completeControlEnd(text string) int {
if strings.HasPrefix(text, kimiToolSectionBegin) {
if idx := strings.Index(text, kimiToolSectionEnd); idx >= 0 {
return idx + len(kimiToolSectionEnd)
}
return 0
}
lower := strings.ToLower(text)
for _, tag := range thinkingTags {
open := "<" + tag
if strings.HasPrefix(lower, open) {
closeStart := strings.Index(lower, "</"+tag)
if closeStart < 0 {
return 0
}
closeEnd := strings.Index(lower[closeStart:], ">")
if closeEnd < 0 {
return 0
}
return closeStart + closeEnd + 1
}
}
return 0
}
func controlPrefixSuffixLen(text string) int {
lower := strings.ToLower(text)
maxKeep := 0
for _, token := range controlStartTokens {
candidate := text
if strings.HasPrefix(token, "<|") {
candidate = text
} else {
candidate = lower
}
limit := len(token) - 1
if len(candidate) < limit {
limit = len(candidate)
}
for n := limit; n > maxKeep; n-- {
if strings.HasPrefix(token, candidate[len(candidate)-n:]) {
maxKeep = n
break
}
}
}
return maxKeep
}
var (
thinkingTags = []string{"redacted_thinking", "antthinking", "thinking", "thought", "think"}
thinkingStartTokens = []string{"<redacted_thinking", "<antthinking", "<thinking", "<thought", "<think"}
controlStartTokens = append([]string{kimiToolSectionBegin}, thinkingStartTokens...)
)
var (
kimiToolSectionRe = regexp.MustCompile(`(?s)<\|tool_calls_section_begin\|>(.*?)<\|tool_calls_section_end\|>`)
kimiToolCallRe = regexp.MustCompile(`(?s)<\|tool_call_begin\|>(.*?)<\|tool_call_argument_begin\|>(.*?)<\|tool_call_end\|>`)
thinkingTagRes = []*regexp.Regexp{
regexp.MustCompile(`(?is)<redacted_thinking\b[^>]*>(.*?)</redacted_thinking\s*>`),
regexp.MustCompile(`(?is)<antthinking\b[^>]*>(.*?)</antthinking\s*>`),
regexp.MustCompile(`(?is)<thinking\b[^>]*>(.*?)</thinking\s*>`),
regexp.MustCompile(`(?is)<thought\b[^>]*>(.*?)</thought\s*>`),
regexp.MustCompile(`(?is)<think\b[^>]*>(.*?)</think\s*>`),
}
)
func extractKimiTextToolCalls(content string, tools []ToolDefinition) (string, []ToolCall) {
if !strings.Contains(content, kimiToolSectionBegin) {
return content, nil
}
var calls []ToolCall
cleaned := kimiToolSectionRe.ReplaceAllStringFunc(content, func(section string) string {
matches := kimiToolCallRe.FindAllStringSubmatch(section, -1)
for _, match := range matches {
call, ok := parseKimiTextToolCall(match[1], match[2], tools, len(calls))
if ok {
calls = append(calls, call)
}
}
return ""
})
return cleaned, calls
}
func parseKimiTextToolCall(rawHeader, rawArgs string, tools []ToolDefinition, index int) (ToolCall, bool) {
args := make(map[string]any)
var parseErr string
trimmedArgs := strings.TrimSpace(rawArgs)
if err := json.Unmarshal([]byte(trimmedArgs), &args); err != nil && trimmedArgs != "" {
parseErr = fmt.Sprintf("malformed JSON (%d chars): %v", len(rawArgs), err)
}
name, ok := resolveTextToolName(rawHeader, args, parseErr == "", tools)
if !ok {
slog.Warn("provider_control: text tool call stripped without deterministic tool match",
"header", strings.TrimSpace(rawHeader), "tools", len(functionToolNames(tools)))
return ToolCall{}, false
}
if parseErr != "" {
slog.Warn("provider_control: failed to parse text tool call arguments",
"tool", name, "raw_len", len(rawArgs), "error", parseErr)
}
id := textToolCallID(rawHeader, index)
return ToolCall{
ID: truncateToolCallID(id),
Name: name,
Arguments: args,
ParseError: parseErr,
}, true
}
func resolveTextToolName(rawHeader string, args map[string]any, argsValid bool, tools []ToolDefinition) (string, bool) {
header := strings.TrimSpace(rawHeader)
names := functionToolNames(tools)
for _, name := range names {
if header == name {
return name, true
}
}
if len(names) == 1 {
return names[0], true
}
if argsValid {
matches := matchingToolNamesByArgs(args, tools)
if len(matches) == 1 {
return matches[0], true
}
}
return "", false
}
func functionToolNames(tools []ToolDefinition) []string {
names := make([]string, 0, len(tools))
for _, tool := range tools {
if tool.Type == "function" && tool.Function != nil && strings.TrimSpace(tool.Function.Name) != "" {
names = append(names, strings.TrimSpace(tool.Function.Name))
}
}
return names
}
func matchingToolNamesByArgs(args map[string]any, tools []ToolDefinition) []string {
if len(args) == 0 {
return nil
}
matches := make([]string, 0, 1)
for _, tool := range tools {
if tool.Type != "function" || tool.Function == nil || strings.TrimSpace(tool.Function.Name) == "" {
continue
}
if toolSchemaMatchesArgs(tool.Function.Parameters, args) {
matches = append(matches, strings.TrimSpace(tool.Function.Name))
}
}
return matches
}
func toolSchemaMatchesArgs(parameters map[string]any, args map[string]any) bool {
required := schemaStringList(parameters["required"])
if len(required) == 0 {
return false
}
properties := schemaProperties(parameters["properties"])
if len(properties) == 0 {
return false
}
for _, key := range required {
if _, ok := args[key]; !ok {
return false
}
}
for key := range args {
if _, ok := properties[key]; !ok {
return false
}
}
return true
}
func schemaStringList(value any) []string {
switch v := value.(type) {
case []string:
out := make([]string, 0, len(v))
for _, item := range v {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
}
return out
default:
return nil
}
}
func schemaProperties(value any) map[string]struct{} {
props := make(map[string]struct{})
switch v := value.(type) {
case map[string]any:
for key := range v {
if key = strings.TrimSpace(key); key != "" {
props[key] = struct{}{}
}
}
}
return props
}
func textToolCallID(rawHeader string, index int) string {
header := strings.TrimSpace(rawHeader)
if strings.HasPrefix(header, "call_") {
return header
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", header, index)))
return "call_" + hex.EncodeToString(sum[:])[:16]
}
func extractThinkingTags(content string) (string, string) {
lower := strings.ToLower(content)
if !strings.Contains(lower, "<think") &&
!strings.Contains(lower, "<thought") &&
!strings.Contains(lower, "<antthinking") &&
!strings.Contains(lower, "<redacted_thinking") {
return content, ""
}
var thinkingParts []string
cleaned := content
for _, re := range thinkingTagRes {
matches := re.FindAllStringSubmatch(cleaned, -1)
for _, match := range matches {
if len(match) > 1 && strings.TrimSpace(match[1]) != "" {
thinkingParts = append(thinkingParts, strings.TrimSpace(match[1]))
}
}
cleaned = re.ReplaceAllString(cleaned, "")
}
cleaned, dangling := extractDanglingThinkingTag(cleaned)
if dangling != "" {
thinkingParts = append(thinkingParts, dangling)
}
return cleaned, strings.Join(thinkingParts, "\n")
}
func extractDanglingThinkingTag(content string) (string, string) {
lower := strings.ToLower(content)
idx := -1
for _, token := range thinkingStartTokens {
if found := strings.Index(lower, token); found >= 0 && (idx < 0 || found < idx) {
idx = found
}
}
if idx < 0 {
return content, ""
}
return content[:idx], strings.TrimSpace(content[idx:])
}
func joinThinking(existing, added string) string {
existing = strings.TrimSpace(existing)
added = strings.TrimSpace(added)
switch {
case existing == "":
return added
case added == "":
return existing
default:
return existing + "\n" + added
}
}