Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
842 lines
22 KiB
Go
842 lines
22 KiB
Go
package canonical
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var ErrToolCallPayloadTooLarge = errors.New("canonical tool call payload exceeds byte budget")
|
|
|
|
// ToolCallPayloadBudgetError reports a canonical payload that cannot fit even
|
|
// after every truncatable output string has been reduced to its marker.
|
|
type ToolCallPayloadBudgetError struct {
|
|
EncodedBytes int
|
|
MaxBytes int
|
|
}
|
|
|
|
func (e *ToolCallPayloadBudgetError) Error() string {
|
|
return fmt.Sprintf("%v: encoded bytes=%d max bytes=%d", ErrToolCallPayloadTooLarge, e.EncodedBytes, e.MaxBytes)
|
|
}
|
|
|
|
func (*ToolCallPayloadBudgetError) Unwrap() error { return ErrToolCallPayloadTooLarge }
|
|
|
|
func IsToolCallPayloadTooLarge(err error) bool {
|
|
return errors.Is(err, ErrToolCallPayloadTooLarge)
|
|
}
|
|
|
|
var canonicalToolPayloadKeys = map[string]struct{}{
|
|
"activityKind": {},
|
|
"approvalPurpose": {},
|
|
"callId": {},
|
|
"callType": {},
|
|
"changes": {},
|
|
"command": {},
|
|
"cwd": {},
|
|
"description": {},
|
|
"detailedContent": {},
|
|
"error": {},
|
|
"exitCode": {},
|
|
"fileChangeKind": {},
|
|
"fileChanges": {},
|
|
"filePath": {},
|
|
"input": {},
|
|
"locations": {},
|
|
"metadata": {},
|
|
"name": {},
|
|
"output": {},
|
|
"ownerCallId": {},
|
|
"ownerThreadId": {},
|
|
"options": {},
|
|
"parentCallId": {},
|
|
"paths": {},
|
|
"plan": {},
|
|
"provider": {},
|
|
"requestId": {},
|
|
"rootCallId": {},
|
|
"seq": {},
|
|
"sessionID": {},
|
|
"source": {},
|
|
"status": {},
|
|
"steps": {},
|
|
"structuredPatch": {},
|
|
"summary": {},
|
|
"title": {},
|
|
"toolName": {},
|
|
}
|
|
|
|
var canonicalToolBodyKeys = map[string]struct{}{
|
|
"action": {},
|
|
"answers": {},
|
|
"answersByQuestionId": {},
|
|
"changes": {},
|
|
"commandName": {},
|
|
"cwd": {},
|
|
"detailedContent": {},
|
|
"diff": {},
|
|
"duration": {},
|
|
"durationMs": {},
|
|
"exitCode": {},
|
|
"file": {},
|
|
"fileChanges": {},
|
|
"filePath": {},
|
|
"filenames": {},
|
|
"files": {},
|
|
"imageMimeType": {},
|
|
"isError": {},
|
|
"links": {},
|
|
"matches": {},
|
|
"message": {},
|
|
"mode": {},
|
|
"newString": {},
|
|
"oldString": {},
|
|
"patch": {},
|
|
"payload": {},
|
|
"requestId": {},
|
|
"reason": {},
|
|
"savedPath": {},
|
|
"savedPaths": {},
|
|
"selectedId": {},
|
|
"status": {},
|
|
"stderr": {},
|
|
"stdout": {},
|
|
"steps": {},
|
|
"structuredPatch": {},
|
|
"structuredContent": {},
|
|
"success": {},
|
|
"summary": {},
|
|
"text": {},
|
|
"totalDeferredTools": {},
|
|
"type": {},
|
|
}
|
|
|
|
var canonicalToolMetadataKeys = map[string]struct{}{
|
|
"agentId": {},
|
|
"approvalPurpose": {},
|
|
"async": {},
|
|
"callType": {},
|
|
"childSessionID": {},
|
|
"child_session_id": {},
|
|
"cwd": {},
|
|
"durationMs": {},
|
|
"interactiveKind": {},
|
|
"kind": {},
|
|
"mcpServer": {},
|
|
"options": {},
|
|
"outputFile": {},
|
|
"parentToolUseId": {},
|
|
"server": {},
|
|
"serverName": {},
|
|
"steps": {},
|
|
"subagentAgentId": {},
|
|
"subagentAsync": {},
|
|
"subagentOutputFile": {},
|
|
"subagentSessionID": {},
|
|
"subagentStatus": {},
|
|
"subagent_session_id": {},
|
|
"taskId": {},
|
|
"taskStatus": {},
|
|
"tool": {},
|
|
"toolName": {},
|
|
}
|
|
|
|
// CompactToolCallPayload turns provider-shaped tool data into the canonical
|
|
// stored business projection. Callers that persist its result must use
|
|
// CompactToolCallPayloadChecked so an impossible budget is not ignored.
|
|
func CompactToolCallPayload(status string, payload map[string]any) map[string]any {
|
|
result, _ := CompactToolCallPayloadChecked(status, payload)
|
|
return result
|
|
}
|
|
|
|
// CompactToolCallPayloadChecked returns an error instead of allowing a
|
|
// replication-unsafe payload to be persisted when required non-truncatable
|
|
// data alone exceeds the byte budget.
|
|
func CompactToolCallPayloadChecked(status string, payload map[string]any) (map[string]any, error) {
|
|
if len(payload) == 0 {
|
|
return payload, nil
|
|
}
|
|
|
|
result := cloneToolMap(payload)
|
|
input := compactToolInput(result["input"])
|
|
output := compactToolBody(result["output"])
|
|
toolError := compactToolBody(result["error"])
|
|
metadata := compactToolMetadata(result["metadata"])
|
|
steps := compactToolSteps(result["steps"])
|
|
if len(steps) == 0 {
|
|
steps = compactToolSteps(output["steps"])
|
|
}
|
|
if len(steps) == 0 {
|
|
steps = compactToolSteps(metadata["steps"])
|
|
}
|
|
delete(output, "steps")
|
|
delete(toolError, "steps")
|
|
delete(metadata, "steps")
|
|
|
|
projection := toolContentProjection{}
|
|
projection.add(result["content"])
|
|
|
|
if len(projection.texts) > 0 {
|
|
target := &output
|
|
if isFailedToolStatus(status) {
|
|
target = &toolError
|
|
}
|
|
if *target == nil {
|
|
*target = map[string]any{}
|
|
}
|
|
if toolString((*target)["text"]) == "" {
|
|
(*target)["text"] = strings.Join(projection.texts, "\n")
|
|
}
|
|
}
|
|
if len(projection.toolReferences) > 0 {
|
|
if output == nil {
|
|
output = map[string]any{}
|
|
}
|
|
if len(toolStringSlice(output["matches"])) == 0 {
|
|
output["matches"] = stringsToAny(projection.toolReferences)
|
|
}
|
|
if _, exists := output["totalDeferredTools"]; !exists {
|
|
output["totalDeferredTools"] = len(projection.toolReferences)
|
|
}
|
|
}
|
|
if len(projection.imagePaths) > 0 {
|
|
if output == nil {
|
|
output = map[string]any{}
|
|
}
|
|
savedPaths := append(toolStringSlice(output["savedPaths"]), projection.imagePaths...)
|
|
savedPaths = uniqueToolStrings(savedPaths)
|
|
output["savedPaths"] = stringsToAny(savedPaths)
|
|
if toolString(output["savedPath"]) == "" {
|
|
output["savedPath"] = savedPaths[0]
|
|
}
|
|
if toolString(output["imageMimeType"]) == "" && projection.imageMimeType != "" {
|
|
output["imageMimeType"] = projection.imageMimeType
|
|
}
|
|
}
|
|
if len(projection.fileChanges) > 0 {
|
|
result["fileChanges"] = mergeToolFileChanges(result["fileChanges"], projection.fileChanges)
|
|
}
|
|
if fileChanges := normalizeToolFileChanges(result["fileChanges"]); fileChanges != nil {
|
|
result["fileChanges"] = fileChanges
|
|
} else {
|
|
delete(result, "fileChanges")
|
|
}
|
|
|
|
// Compare aliases before per-field truncation can make equal source
|
|
// strings diverge at the byte boundary.
|
|
if isTerminalToolStatus(status) && isTerminalCommandPayload(result, input) {
|
|
compactTerminalCommandBodyAlias(output)
|
|
compactTerminalCommandBodyAlias(toolError)
|
|
}
|
|
CompactToolStructuredContentAliases(map[string]any{
|
|
"output": output,
|
|
"error": toolError,
|
|
"steps": steps,
|
|
})
|
|
if output != nil {
|
|
delete(output, "content")
|
|
output = TruncateToolOutputBody(selectToolKeys(output, canonicalToolBodyKeys))
|
|
}
|
|
if toolError != nil {
|
|
delete(toolError, "content")
|
|
toolError = TruncateToolOutputBody(selectToolKeys(toolError, canonicalToolBodyKeys))
|
|
}
|
|
|
|
delete(result, "content")
|
|
delete(result, "error")
|
|
delete(result, "input")
|
|
delete(result, "metadata")
|
|
delete(result, "output")
|
|
delete(result, "steps")
|
|
if len(input) > 0 {
|
|
result["input"] = input
|
|
}
|
|
if len(output) > 0 {
|
|
result["output"] = output
|
|
}
|
|
if len(toolError) > 0 && !isCompletedToolStatus(status) {
|
|
result["error"] = toolError
|
|
}
|
|
if len(metadata) > 0 {
|
|
result["metadata"] = metadata
|
|
}
|
|
if len(steps) > 0 {
|
|
result["steps"] = steps
|
|
}
|
|
|
|
result = selectToolKeys(result, canonicalToolPayloadKeys)
|
|
CompactTerminalCommandOutputAliases(status, result)
|
|
CompactToolStructuredContentAliases(result)
|
|
_, fits := FitToolCallPayloadOutputBudget(result, ToolCallPayloadMaxBytes)
|
|
encoded, err := json.Marshal(result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode canonical tool call payload: %w", err)
|
|
}
|
|
if !fits || len(encoded) > ToolCallPayloadMaxBytes {
|
|
return nil, &ToolCallPayloadBudgetError{
|
|
EncodedBytes: len(encoded),
|
|
MaxBytes: ToolCallPayloadMaxBytes,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func compactToolSteps(value any) []any {
|
|
rawSteps, ok := value.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
steps := make([]any, 0, len(rawSteps))
|
|
for _, value := range rawSteps {
|
|
rawStep := toolMap(value)
|
|
if rawStep == nil {
|
|
continue
|
|
}
|
|
payload := toolMap(rawStep["payload"])
|
|
input := compactToolInput(firstToolValue(
|
|
rawStep["toolInput"],
|
|
rawStep["tool_input"],
|
|
payload["input"],
|
|
))
|
|
output := compactToolBody(firstToolValue(
|
|
rawStep["toolResult"],
|
|
rawStep["tool_result"],
|
|
payload["output"],
|
|
))
|
|
toolError := compactToolBody(firstToolValue(
|
|
rawStep["toolError"],
|
|
rawStep["tool_error"],
|
|
payload["error"],
|
|
))
|
|
metadata := compactToolMetadata(firstToolValue(
|
|
rawStep["metadata"],
|
|
payload["metadata"],
|
|
))
|
|
status := firstToolString(rawStep["status"], output["status"], toolError["status"])
|
|
step := map[string]any{}
|
|
for _, key := range []string{
|
|
"id",
|
|
"toolUseId",
|
|
"name",
|
|
"toolName",
|
|
"callType",
|
|
"status",
|
|
"summary",
|
|
"locations",
|
|
"occurredAtUnixMs",
|
|
} {
|
|
if rawStep[key] != nil {
|
|
step[key] = cloneToolValue(rawStep[key])
|
|
}
|
|
}
|
|
if step["toolUseId"] == nil && rawStep["tool_use_id"] != nil {
|
|
step["toolUseId"] = cloneToolValue(rawStep["tool_use_id"])
|
|
}
|
|
if step["toolName"] == nil && rawStep["tool_name"] != nil {
|
|
step["toolName"] = cloneToolValue(rawStep["tool_name"])
|
|
}
|
|
if step["callType"] == nil && rawStep["call_type"] != nil {
|
|
step["callType"] = cloneToolValue(rawStep["call_type"])
|
|
}
|
|
if status != "" {
|
|
step["status"] = status
|
|
}
|
|
if len(input) > 0 {
|
|
step["toolInput"] = input
|
|
}
|
|
if len(metadata) > 0 {
|
|
step["metadata"] = metadata
|
|
}
|
|
if isTerminalToolStatus(status) && isTerminalCommandPayload(step, input) {
|
|
compactTerminalCommandBodyAlias(output)
|
|
compactTerminalCommandBodyAlias(toolError)
|
|
}
|
|
output = TruncateToolOutputBody(output)
|
|
toolError = TruncateToolOutputBody(toolError)
|
|
if len(output) > 0 {
|
|
step["toolResult"] = output
|
|
}
|
|
if len(toolError) > 0 && !isCompletedToolStatus(status) {
|
|
step["toolError"] = toolError
|
|
}
|
|
if fileChanges := normalizeToolFileChanges(firstToolValue(rawStep["fileChanges"], payload["fileChanges"])); fileChanges != nil {
|
|
step["fileChanges"] = fileChanges
|
|
}
|
|
if len(step) > 0 {
|
|
steps = append(steps, step)
|
|
}
|
|
}
|
|
return steps
|
|
}
|
|
|
|
func compactToolBody(value any) map[string]any {
|
|
body := toolBodyMap(value)
|
|
if body == nil {
|
|
return nil
|
|
}
|
|
for _, rawKey := range []string{"rawOutput", "raw_output"} {
|
|
switch raw := body[rawKey].(type) {
|
|
case map[string]any:
|
|
body = mergeMissingToolValues(body, raw)
|
|
case string:
|
|
if toolString(body["text"]) == "" && strings.TrimSpace(raw) != "" {
|
|
body["text"] = raw
|
|
}
|
|
}
|
|
delete(body, rawKey)
|
|
}
|
|
|
|
if metadata := toolMap(body["metadata"]); len(metadata) > 0 {
|
|
for _, key := range []string{"changes", "detailedContent", "diff", "files", "steps", "structuredPatch"} {
|
|
if _, exists := body[key]; !exists && metadata[key] != nil {
|
|
body[key] = cloneToolValue(metadata[key])
|
|
}
|
|
}
|
|
if _, exists := body["exitCode"]; !exists && metadata["exit"] != nil {
|
|
body["exitCode"] = cloneToolValue(metadata["exit"])
|
|
}
|
|
}
|
|
|
|
projection := toolContentProjection{}
|
|
projection.add(body["content"])
|
|
if toolString(body["text"]) == "" {
|
|
if text := firstToolBodyText(body, projection.texts); text != "" {
|
|
body["text"] = text
|
|
}
|
|
}
|
|
if len(toolStringSlice(body["matches"])) == 0 && len(projection.toolReferences) > 0 {
|
|
body["matches"] = stringsToAny(projection.toolReferences)
|
|
if _, exists := body["totalDeferredTools"]; !exists {
|
|
body["totalDeferredTools"] = len(projection.toolReferences)
|
|
}
|
|
}
|
|
if len(projection.imagePaths) > 0 {
|
|
savedPaths := append(toolStringSlice(body["savedPaths"]), projection.imagePaths...)
|
|
savedPaths = uniqueToolStrings(savedPaths)
|
|
body["savedPaths"] = stringsToAny(savedPaths)
|
|
if toolString(body["savedPath"]) == "" {
|
|
body["savedPath"] = savedPaths[0]
|
|
}
|
|
if projection.imageMimeType != "" {
|
|
body["imageMimeType"] = projection.imageMimeType
|
|
}
|
|
}
|
|
if len(projection.fileChanges) > 0 {
|
|
body["fileChanges"] = mergeToolFileChanges(body["fileChanges"], projection.fileChanges)
|
|
}
|
|
if fileChanges := normalizeToolFileChanges(body["fileChanges"]); fileChanges != nil {
|
|
body["fileChanges"] = fileChanges
|
|
} else {
|
|
delete(body, "fileChanges")
|
|
}
|
|
|
|
if value, exists := body["exit_code"]; exists {
|
|
if _, canonicalExists := body["exitCode"]; !canonicalExists {
|
|
body["exitCode"] = cloneToolValue(value)
|
|
}
|
|
}
|
|
if value, exists := body["duration_ms"]; exists {
|
|
if _, canonicalExists := body["durationMs"]; !canonicalExists {
|
|
body["durationMs"] = cloneToolValue(value)
|
|
}
|
|
}
|
|
if value, exists := body["saved_path"]; exists && toolString(body["savedPath"]) == "" {
|
|
body["savedPath"] = cloneToolValue(value)
|
|
}
|
|
if value, exists := body["total_deferred_tools"]; exists {
|
|
if _, canonicalExists := body["totalDeferredTools"]; !canonicalExists {
|
|
body["totalDeferredTools"] = cloneToolValue(value)
|
|
}
|
|
}
|
|
if steps := compactToolSteps(body["steps"]); len(steps) > 0 {
|
|
body["steps"] = steps
|
|
} else {
|
|
delete(body, "steps")
|
|
}
|
|
delete(body, "_meta")
|
|
delete(body, "content")
|
|
delete(body, "metadata")
|
|
delete(body, "toolResponse")
|
|
return selectToolKeys(body, canonicalToolBodyKeys)
|
|
}
|
|
|
|
func compactToolMetadata(value any) map[string]any {
|
|
metadata := toolMap(value)
|
|
if metadata == nil {
|
|
return nil
|
|
}
|
|
delete(metadata, "adapter")
|
|
delete(metadata, "fileChange")
|
|
if steps := compactToolSteps(metadata["steps"]); len(steps) > 0 {
|
|
metadata["steps"] = steps
|
|
} else {
|
|
delete(metadata, "steps")
|
|
}
|
|
return selectToolKeys(metadata, canonicalToolMetadataKeys)
|
|
}
|
|
|
|
func firstToolBodyText(body map[string]any, contentTexts []string) string {
|
|
for _, key := range []string{
|
|
"output",
|
|
"aggregated_output",
|
|
"formatted_output",
|
|
"result",
|
|
} {
|
|
if text := toolString(body[key]); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
if len(contentTexts) > 0 {
|
|
return strings.Join(contentTexts, "\n")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type toolContentProjection struct {
|
|
texts []string
|
|
toolReferences []string
|
|
imagePaths []string
|
|
imageMimeType string
|
|
fileChanges []map[string]any
|
|
}
|
|
|
|
func (projection *toolContentProjection) add(value any) {
|
|
switch typed := value.(type) {
|
|
case []any:
|
|
for _, item := range typed {
|
|
projection.add(item)
|
|
}
|
|
case map[string]any:
|
|
itemType := strings.ToLower(toolString(typed["type"]))
|
|
nested := toolMap(typed["content"])
|
|
nestedType := strings.ToLower(toolString(nested["type"]))
|
|
|
|
if text := toolString(typed["text"]); text != "" {
|
|
projection.texts = appendUniqueToolString(projection.texts, text)
|
|
}
|
|
if text := toolString(typed["content"]); text != "" {
|
|
projection.texts = appendUniqueToolString(projection.texts, text)
|
|
}
|
|
if nested != nil {
|
|
if text := toolString(nested["text"]); text != "" {
|
|
projection.texts = appendUniqueToolString(projection.texts, text)
|
|
}
|
|
}
|
|
|
|
if itemType == "tool_reference" {
|
|
if name := firstToolString(typed["tool_name"], typed["toolName"], typed["name"]); name != "" {
|
|
projection.toolReferences = appendUniqueToolString(projection.toolReferences, name)
|
|
}
|
|
}
|
|
if itemType == "image" || nestedType == "image" {
|
|
image := typed
|
|
if nestedType == "image" {
|
|
image = nested
|
|
}
|
|
if path := firstToolString(image["uri"], image["path"]); path != "" && !strings.HasPrefix(strings.ToLower(path), "data:image/") {
|
|
projection.imagePaths = appendUniqueToolString(projection.imagePaths, path)
|
|
}
|
|
if projection.imageMimeType == "" {
|
|
projection.imageMimeType = firstToolString(image["mimeType"], image["media_type"], image["mediaType"])
|
|
}
|
|
}
|
|
if itemType == "file_change" || itemType == "diff" {
|
|
projection.fileChanges = append(projection.fileChanges, toolFileChangesFromContent(typed)...)
|
|
}
|
|
|
|
if content, ok := typed["content"].([]any); ok {
|
|
projection.add(content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func toolFileChangesFromContent(value map[string]any) []map[string]any {
|
|
paths := toolStringSlice(value["paths"])
|
|
if path := firstToolString(value["path"], value["filePath"], value["file_path"]); path != "" {
|
|
paths = append([]string{path}, paths...)
|
|
}
|
|
paths = uniqueToolStrings(paths)
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
|
|
oldString, hasOld := firstPresentToolString(value["oldString"], value["old_string"], value["oldText"])
|
|
newString, hasNew := firstPresentToolString(value["newString"], value["new_string"], value["newText"])
|
|
content, hasContent := firstPresentToolString(value["content"])
|
|
change := firstToolChange(value["change"], value["status"], value["kind"], value["type"])
|
|
if change == "" {
|
|
switch {
|
|
case hasOld && !hasNew:
|
|
change = "deleted"
|
|
case hasNew && (!hasOld || oldString == ""):
|
|
change = "added"
|
|
case hasContent:
|
|
change = "added"
|
|
default:
|
|
change = "modified"
|
|
}
|
|
}
|
|
|
|
files := make([]map[string]any, 0, len(paths))
|
|
for _, path := range paths {
|
|
file := map[string]any{"path": path, "change": change}
|
|
if hasOld {
|
|
file["oldString"] = oldString
|
|
}
|
|
if hasNew {
|
|
file["newString"] = newString
|
|
}
|
|
if hasContent {
|
|
file["content"] = content
|
|
}
|
|
for _, key := range []string{"diff", "patch", "unifiedDiff", "unified_diff"} {
|
|
if text, ok := value[key].(string); ok {
|
|
file[key] = text
|
|
}
|
|
}
|
|
files = append(files, file)
|
|
}
|
|
return files
|
|
}
|
|
|
|
func mergeToolFileChanges(existing any, incoming []map[string]any) map[string]any {
|
|
filesByPath := map[string]map[string]any{}
|
|
order := make([]string, 0)
|
|
if existingMap := normalizeToolFileChanges(existing); existingMap != nil {
|
|
for _, raw := range toolFileChangeMaps(existingMap["files"]) {
|
|
path := toolString(raw["path"])
|
|
if path == "" {
|
|
continue
|
|
}
|
|
order = append(order, path)
|
|
filesByPath[path] = raw
|
|
}
|
|
}
|
|
for _, raw := range incoming {
|
|
file := normalizeToolFileChange(raw)
|
|
if file == nil {
|
|
continue
|
|
}
|
|
path := toolString(file["path"])
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if current, exists := filesByPath[path]; exists {
|
|
filesByPath[path] = mergeToolFileChangeValues(current, file)
|
|
continue
|
|
}
|
|
order = append(order, path)
|
|
filesByPath[path] = cloneToolMap(file)
|
|
}
|
|
files := make([]any, 0, len(order))
|
|
for _, path := range order {
|
|
files = append(files, filesByPath[path])
|
|
}
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
return map[string]any{"files": files}
|
|
}
|
|
|
|
func toolBodyMap(value any) map[string]any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
return cloneToolMap(typed)
|
|
case string:
|
|
if strings.TrimSpace(typed) == "" {
|
|
return nil
|
|
}
|
|
return map[string]any{"text": typed}
|
|
case nil:
|
|
return nil
|
|
default:
|
|
return map[string]any{"text": fmt.Sprint(typed)}
|
|
}
|
|
}
|
|
|
|
func toolMap(value any) map[string]any {
|
|
typed, ok := value.(map[string]any)
|
|
if !ok || len(typed) == 0 {
|
|
return nil
|
|
}
|
|
return cloneToolMap(typed)
|
|
}
|
|
|
|
func cloneToolMap(input map[string]any) map[string]any {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
output := make(map[string]any, len(input))
|
|
for key, value := range input {
|
|
output[key] = cloneToolValue(value)
|
|
}
|
|
return output
|
|
}
|
|
|
|
func cloneToolValue(value any) any {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
return cloneToolMap(typed)
|
|
case []any:
|
|
output := make([]any, len(typed))
|
|
for index, item := range typed {
|
|
output[index] = cloneToolValue(item)
|
|
}
|
|
return output
|
|
default:
|
|
return typed
|
|
}
|
|
}
|
|
|
|
func mergeMissingToolValues(current map[string]any, fallback map[string]any) map[string]any {
|
|
result := cloneToolMap(current)
|
|
if result == nil {
|
|
result = map[string]any{}
|
|
}
|
|
for key, value := range fallback {
|
|
if _, exists := result[key]; !exists {
|
|
result[key] = cloneToolValue(value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func selectToolKeys(input map[string]any, allowed map[string]struct{}) map[string]any {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
output := make(map[string]any)
|
|
for key, value := range input {
|
|
if _, ok := allowed[key]; !ok || toolValueEmpty(value) {
|
|
continue
|
|
}
|
|
output[key] = value
|
|
}
|
|
if len(output) == 0 {
|
|
return nil
|
|
}
|
|
return output
|
|
}
|
|
|
|
func toolValueEmpty(value any) bool {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return strings.TrimSpace(typed) == ""
|
|
case map[string]any:
|
|
return len(typed) == 0
|
|
case []any:
|
|
return len(typed) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func toolString(value any) string {
|
|
text, ok := value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(text)
|
|
}
|
|
|
|
func firstToolString(values ...any) string {
|
|
for _, value := range values {
|
|
if text := toolString(value); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstToolValue(values ...any) any {
|
|
for _, value := range values {
|
|
if !toolValueEmpty(value) {
|
|
return value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstPresentToolString(values ...any) (string, bool) {
|
|
for _, value := range values {
|
|
if text, ok := value.(string); ok {
|
|
return text, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func toolStringSlice(value any) []string {
|
|
switch typed := value.(type) {
|
|
case []any:
|
|
values := make([]string, 0, len(typed))
|
|
for _, item := range typed {
|
|
if text := toolString(item); text != "" {
|
|
values = appendUniqueToolString(values, text)
|
|
}
|
|
}
|
|
return values
|
|
case []string:
|
|
return uniqueToolStrings(typed)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func appendUniqueToolString(values []string, value string) []string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return values
|
|
}
|
|
for _, current := range values {
|
|
if current == value {
|
|
return values
|
|
}
|
|
}
|
|
return append(values, value)
|
|
}
|
|
|
|
func uniqueToolStrings(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
result = appendUniqueToolString(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func stringsToAny(values []string) []any {
|
|
result := make([]any, len(values))
|
|
for index, value := range values {
|
|
result[index] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeToolChange(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "add", "added", "create", "created", "new", "write_file":
|
|
return "added"
|
|
case "delete", "deleted", "remove", "removed", "delete_file":
|
|
return "deleted"
|
|
case "modify", "modified", "update", "updated", "edit", "edited", "change", "changed", "edit_file":
|
|
return "modified"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isCompletedToolStatus(status string) bool {
|
|
return strings.TrimSpace(status) == "completed"
|
|
}
|
|
|
|
func isFailedToolStatus(status string) bool {
|
|
switch strings.TrimSpace(status) {
|
|
case "failed", "errored", "canceled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|