Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

797 lines
23 KiB
Go

package agentruntime
import (
"log/slog"
"os"
"strings"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func acpToolCallEvent(session Session, turnID string, update map[string]any) (activityshared.Event, bool) {
return acpToolCallEventWithID(session, newID(), turnID, update)
}
func acpToolCallEventWithID(session Session, eventID string, turnID string, update map[string]any) (activityshared.Event, bool) {
callID := firstNonEmpty(asString(update["toolCallId"]), asString(update["callId"]), asString(update["id"]))
name := firstNonEmpty(asString(update["title"]), asString(update["name"]), callID, "tool")
kind := asString(update["kind"])
status := acpResolvedToolCallStatus(update, string(activityshared.ActivityStatusRunning))
rawInput := acpToolCallRawInput(update)
rawOutput := acpToolCallRawOutput(update)
locations := clonePayloadValue(update["locations"])
content := acpSanitizeImagePayload(update["content"])
eventType := EventCallStarted
inputBody := acpNormalizeToolInput(rawInput, kind, locations)
callBody := inputBody
switch normalizedCallStatus(status) {
case messageStreamStateCompleted:
eventType = EventCallCompleted
callBody = acpNormalizeToolOutput(rawOutput, content)
case messageStreamStateFailed:
eventType = EventCallFailed
callBody = acpNormalizeToolOutput(rawOutput, content)
default:
status = string(activityshared.ActivityStatusRunning)
}
toolName := firstNonEmpty(
asString(update["toolName"]),
acpToolNameWithOutput(callID, name, kind, rawInput, rawOutput),
)
payload := map[string]any{
"callId": callID,
"callType": "tool",
"name": name,
"status": status,
}
if toolName != "" {
payload["toolName"] = toolName
}
if kind != "" {
payload["kind"] = kind
payload["acp"] = map[string]any{
"sessionUpdate": asString(update["sessionUpdate"]),
"kind": kind,
}
} else if sessionUpdate := asString(update["sessionUpdate"]); sessionUpdate != "" {
payload["acp"] = map[string]any{
"sessionUpdate": sessionUpdate,
}
}
if locations != nil {
payload["locations"] = locations
}
if content != nil {
payload["content"] = content
}
// Some tools (notably Codex web search) stream an empty input on the
// `started` event and only populate the real input — the search query — on
// the `completed` event. The terminal event must therefore carry the input
// too, otherwise the empty start payload wins the merge and the query is lost.
if eventType != EventCallStarted && len(inputBody) > 0 {
payload["input"] = inputBody
}
if len(callBody) > 0 {
switch eventType {
case EventCallCompleted:
payload["output"] = callBody
case EventCallFailed:
payload["error"] = callBody
if mirroredOutput := acpMirrorFailedToolOutput(callBody); len(mirroredOutput) > 0 {
payload["output"] = mirroredOutput
}
default:
payload["input"] = callBody
}
}
logACPToolCallDiagnostic(session, turnID, update, payload)
return newTurnActivityEventWithID(session, eventID, eventType, turnID, status, "", name, payload), true
}
func acpToolCallRawInput(update map[string]any) any {
if rawInput, ok := update["rawInput"]; ok {
return clonePayloadValue(rawInput)
}
if input, ok := update["input"]; ok {
return clonePayloadValue(input)
}
return nil
}
func acpToolCallRawOutput(update map[string]any) any {
if rawOutput, ok := update["rawOutput"]; ok {
return clonePayloadValue(rawOutput)
}
if output, ok := update["output"]; ok {
return clonePayloadValue(output)
}
return nil
}
func acpToolName(callID string, title string, kind string, rawInput any) string {
return acpToolNameWithOutput(callID, title, kind, rawInput, nil)
}
func acpToolNameWithOutput(callID string, title string, kind string, rawInput any, rawOutput any) string {
input, _ := rawInput.(map[string]any)
normalizedCallID := strings.ToLower(strings.TrimSpace(callID))
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
trimmedTitle := strings.TrimSpace(title)
normalizedTitle := strings.ToLower(trimmedTitle)
if isOpaqueCallIdentifierString(trimmedTitle, callID) {
trimmedTitle = ""
normalizedTitle = ""
}
if syntheticToolName := acpSyntheticToolName(normalizedTitle); syntheticToolName != "" {
return syntheticToolName
}
if strings.HasPrefix(normalizedCallID, "web_search_") {
return "WebSearch"
}
if input != nil {
if strings.TrimSpace(asString(input["cmd"])) != "" || acpExtractShellCommand(input["command"]) != "" {
return "Bash"
}
if input["todos"] != nil {
return "TodoWrite"
}
if strings.TrimSpace(asString(input["patchText"])) != "" {
return "Edit"
}
}
if fetchToolName := acpFetchToolName(input); fetchToolName != "" {
return fetchToolName
}
if strings.HasPrefix(normalizedTitle, "searching for:") {
return "WebSearch"
}
// Cursor ACP uses descriptive titles ("Find `path` `*.go`", `grep "x"`)
// and kind=search for Glob/Grep. Prefer title/input/output shape over the
// historical kind=search → Bash fallback that blanked the tool card.
if toolName := acpToolNameFromSearchHints(normalizedTitle, normalizedKind, input, rawOutput); toolName != "" {
return toolName
}
switch normalizedKind {
case "think":
if normalizedTitle == "update_todo" || input != nil && input["todos"] != nil {
return "TodoWrite"
}
return "Think"
case "read":
if input != nil && (strings.TrimSpace(asString(input["pattern"])) != "" || strings.TrimSpace(asString(input["glob_pattern"])) != "" || strings.TrimSpace(asString(input["globPattern"])) != "") {
return "Glob"
}
return "Read"
case "search":
switch normalizedTitle {
case "glob", "find", "fd", "file_search":
return "Glob"
case "grep", "rg", "ripgrep", "codebase_search":
return "Grep"
default:
// Unknown search tools are not shell commands (Cursor used to fall
// through to Bash here, which blanked Glob/Grep cards).
return "Grep"
}
case "other":
switch normalizedTitle {
case "task":
return "Agent"
case "agent":
return "Agent"
case "rg", "ripgrep":
return "Grep"
case "find", "fd":
return "Glob"
}
case "edit", "move":
return "Edit"
case "delete":
return "Write"
case "execute":
if input != nil {
agentName := asString(input["agentName"])
if controlToolName := appServerAgentControlToolName(agentName); controlToolName != "" {
return controlToolName
}
if strings.TrimSpace(agentName) != "" {
return "Agent"
}
if strings.TrimSpace(asString(input["task"])) != "" {
return "Agent"
}
}
return "Bash"
case "fetch":
return "WebFetch"
}
switch normalizedTitle {
case "run_subagent":
return "Agent"
case "grep_search", "codebase_search":
return "Grep"
case "file_search":
return "Glob"
case "create_file", "create_new_file", "write_file", "write_to_file":
return "Write"
case "insert_text", "replace_in_file", "edit_file", "apply_patch":
return "Edit"
case "read_file", "list_dir":
return "Read"
}
if trimmedTitle != "" {
return trimmedTitle
}
return "Tool"
}
// acpToolNameFromSearchHints maps Cursor-style Glob/Grep ACP updates.
// Cursor sends kind=search with titles like `Find \`dir\` \`*.go\“ / `grep "x"`
// and rawInput `{pattern}` (Glob) or `{pattern,path}` (Grep), not exact tool names.
func acpToolNameFromSearchHints(normalizedTitle string, normalizedKind string, input map[string]any, rawOutput any) string {
if strings.HasPrefix(normalizedTitle, "find ") || normalizedTitle == "find" ||
strings.HasPrefix(normalizedTitle, "glob ") || normalizedTitle == "glob" {
return "Glob"
}
if strings.HasPrefix(normalizedTitle, "grep") ||
strings.HasPrefix(normalizedTitle, "rg ") || normalizedTitle == "rg" ||
strings.HasPrefix(normalizedTitle, "ripgrep") {
return "Grep"
}
if strings.HasPrefix(normalizedTitle, "search:") || strings.HasPrefix(normalizedTitle, "codebase search") {
return "Grep"
}
if strings.HasPrefix(normalizedTitle, "web search") {
return "WebSearch"
}
if strings.HasPrefix(normalizedTitle, "read ") || normalizedTitle == "read file" {
return "Read"
}
output := acpMapFromValue(rawOutput, "output")
if len(output) > 0 {
if _, ok := output["totalFiles"]; ok {
return "Glob"
}
if _, ok := output["totalMatches"]; ok {
return "Grep"
}
if _, ok := output["resultCount"]; ok {
return "Grep"
}
}
if input == nil {
return ""
}
globPattern := firstNonEmpty(
asString(input["glob_pattern"]),
asString(input["globPattern"]),
asString(input["pattern"]),
)
hasPath := firstNonEmpty(asString(input["path"]), asString(input["file_path"]), asString(input["filePath"])) != ""
hasQuery := firstNonEmpty(asString(input["query"]), asString(input["searchTerm"]), asString(input["search_query"])) != ""
if hasQuery && strings.TrimSpace(asString(input["pattern"])) == "" {
if normalizedKind == "search" || normalizedKind == "fetch" {
return "WebSearch"
}
}
if globPattern == "" {
return ""
}
// Cursor Glob rawInput is typically `{pattern}` only; Grep includes path/glob flags.
if hasPath || input["glob"] != nil || input["type"] != nil || input["multiline"] != nil ||
input["A"] != nil || input["B"] != nil || input["C"] != nil || input["headLimit"] != nil ||
input["outputMode"] != nil {
return "Grep"
}
if normalizedKind == "search" || normalizedKind == "read" || normalizedKind == "other" || normalizedKind == "" {
// Bare pattern on search/read is Glob from Cursor's extractToolCallInput.
if !hasPath {
return "Glob"
}
}
return ""
}
func acpSyntheticToolName(normalizedTitle string) string {
switch normalizedTitle {
case "enterplanmode":
return "EnterPlanMode"
case "exitplanmode":
return "ExitPlanMode"
case "askuserquestion":
return "AskUserQuestion"
case "toolsearch":
return "ToolSearch"
case "skill":
return "Skill"
case "closeagent":
return "CloseAgent"
case "wait":
return "Wait"
default:
return ""
}
}
func acpFetchToolName(input map[string]any) string {
if input == nil {
return ""
}
action, _ := input["action"].(map[string]any)
actionType := strings.ToLower(strings.TrimSpace(asString(action["type"])))
switch actionType {
case "search", "search_query", "web_search":
return "WebSearch"
case "open_page", "open", "fetch", "web_fetch":
return "WebFetch"
}
if firstNonEmpty(asString(action["url"]), asString(input["url"])) != "" {
return "WebFetch"
}
if firstNonEmpty(
asString(action["query"]),
asString(input["query"]),
asString(input["search_query"]),
asString(input["searchQuery"]),
) != "" {
return "WebSearch"
}
return ""
}
func acpNormalizeToolInput(rawInput any, kind string, locations any) map[string]any {
body := acpMapFromValue(rawInput, "rawInput")
if len(body) == 0 {
return nil
}
if command := acpExtractShellCommand(firstNonEmptyShellCommand(body["command"], body["cmd"])); command != "" {
body["command"] = command
delete(body, "cmd")
}
if cwd := strings.TrimSpace(asString(body["cwd"])); cwd == "" {
delete(body, "cwd")
}
locationList := acpLocationList(locations)
normalizedKind := strings.ToLower(strings.TrimSpace(kind))
switch normalizedKind {
case "read":
if path := acpFirstLocationPath(locationList); path != "" {
body["file_path"] = path
}
if path := firstNonEmpty(asString(body["path"]), asString(body["file_path"]), asString(body["filePath"])); path != "" {
body["path"] = path
if strings.TrimSpace(asString(body["file_path"])) == "" {
body["file_path"] = path
}
}
case "search":
if path := acpFirstLocationPath(locationList); path != "" {
if strings.TrimSpace(asString(body["target_directory"])) == "" && strings.TrimSpace(asString(body["path"])) == "" {
// Cursor Glob puts the search root in locations, not rawInput.
if firstNonEmpty(asString(body["pattern"]), asString(body["glob_pattern"]), asString(body["globPattern"])) != "" &&
strings.TrimSpace(asString(body["path"])) == "" {
body["target_directory"] = path
} else if strings.TrimSpace(asString(body["path"])) == "" {
body["path"] = path
}
}
}
if pattern := firstNonEmpty(asString(body["glob_pattern"]), asString(body["globPattern"]), asString(body["pattern"])); pattern != "" {
body["pattern"] = pattern
}
case "edit", "delete", "move":
if path := acpFirstLocationPath(locationList); path != "" {
body["file_path"] = path
}
case "execute":
if task := strings.TrimSpace(asString(body["task"])); task != "" {
body["prompt"] = task
body["description"] = task
}
if agentName := strings.TrimSpace(asString(body["agentName"])); agentName != "" {
body["subagent_type"] = agentName
}
case "fetch":
action, _ := body["action"].(map[string]any)
actionType := strings.ToLower(strings.TrimSpace(asString(action["type"])))
switch actionType {
case "search", "search_query", "web_search":
if query := firstNonEmpty(
asString(action["query"]),
asString(body["query"]),
asString(body["search_query"]),
asString(body["searchQuery"]),
); query != "" {
body["query"] = query
}
case "open_page", "open", "fetch", "web_fetch":
if url := firstNonEmpty(asString(action["url"]), asString(body["url"])); url != "" {
body["url"] = url
}
}
case "think":
if todos := acpNormalizeTodos(body["todos"]); len(todos) > 0 {
body["todos"] = todos
}
}
return acpSanitizeImagePayloadMap(body)
}
func firstNonEmptyShellCommand(values ...any) any {
for _, value := range values {
if acpExtractShellCommand(value) != "" {
return value
}
}
return nil
}
func acpNormalizeToolOutput(rawOutput any, content any) map[string]any {
body := acpMapFromValue(rawOutput, "output")
if len(body) == 0 && content == nil {
return nil
}
if body == nil {
body = map[string]any{}
}
body = acpSanitizeImagePayloadMap(body)
acpPromoteToolOutputMetadata(body)
acpPromoteToolErrorMetadata(body)
if content != nil {
sanitizedContent := acpSanitizeImagePayload(content)
body["content"] = sanitizedContent
if stdout := strings.TrimSpace(asString(body["stdout"])); stdout == "" {
if text := acpContentText(sanitizedContent); text != "" {
body["stdout"] = text
}
}
acpApplyDiffContent(body, sanitizedContent)
}
return body
}
func acpPromoteToolOutputMetadata(body map[string]any) {
metadata, _ := body["metadata"].(map[string]any)
if len(metadata) == 0 {
return
}
for _, key := range []string{"diff", "files", "structuredPatch", "detailedContent", "changes"} {
if _, exists := body[key]; !exists && metadata[key] != nil {
body[key] = clonePayloadValue(metadata[key])
}
}
if _, exists := body["exitCode"]; !exists && metadata["exit"] != nil {
body["exitCode"] = clonePayloadValue(metadata["exit"])
}
}
func acpSanitizeImagePayloadMap(value map[string]any) map[string]any {
sanitized, _ := acpSanitizeImagePayload(value).(map[string]any)
return sanitized
}
func acpSanitizeImagePayload(value any) any {
switch typed := value.(type) {
case map[string]any:
sanitized := make(map[string]any, len(typed))
imageLike := acpLooksLikeImagePayload(typed)
for key, entry := range typed {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
if imageLike && normalizedKey == "data" {
continue
}
if imageLike && (normalizedKey == "uri" || normalizedKey == "path") {
if text, ok := entry.(string); ok && strings.HasPrefix(strings.ToLower(strings.TrimSpace(text)), "data:image/") {
continue
}
}
sanitized[key] = acpSanitizeImagePayload(entry)
}
return sanitized
case []any:
sanitized := make([]any, len(typed))
for index, entry := range typed {
sanitized[index] = acpSanitizeImagePayload(entry)
}
return sanitized
default:
return clonePayloadValue(value)
}
}
func acpLooksLikeImagePayload(value map[string]any) bool {
normalizedType := strings.ToLower(strings.TrimSpace(asString(value["type"])))
normalizedMimeType := strings.ToLower(strings.TrimSpace(asString(value["mimeType"])))
uri := strings.TrimSpace(firstNonEmpty(asString(value["uri"]), asString(value["path"])))
_, hasData := value["data"]
return normalizedType == "image" || strings.HasPrefix(normalizedMimeType, "image/") || (uri != "" && hasData)
}
func acpMirrorFailedToolOutput(body map[string]any) map[string]any {
if len(body) == 0 {
return nil
}
mirrored := map[string]any{}
for _, key := range []string{"text", "stdout", "stderr", "aggregated_output", "formatted_output", "content", "structuredContent", "result", "error", "err", "errorMessage", "error_message", "message", "detail", "rawErrorMessages", "raw_error_messages", "isError", "is_error", "success", "changes", "status", "call_id", "turn_id", "cwd", "parsed_cmd", "command", "exit_code", "duration", "duration_ms", "completed_at_ms", "source", "process_id"} {
if value, ok := body[key]; ok && value != nil {
mirrored[key] = clonePayloadValue(value)
}
}
if len(mirrored) == 0 {
return nil
}
return mirrored
}
func acpMapFromValue(value any, scalarKey string) map[string]any {
switch typed := value.(type) {
case map[string]any:
return clonePayloadDeep(typed)
case nil:
return nil
default:
return map[string]any{scalarKey: clonePayloadValue(value)}
}
}
func acpExtractShellCommand(command any) string {
switch typed := command.(type) {
case string:
return strings.TrimSpace(typed)
case []any:
for index := len(typed) - 1; index >= 0; index-- {
if candidate := strings.TrimSpace(asString(typed[index])); candidate != "" {
return candidate
}
}
}
return ""
}
func acpLocationList(value any) []map[string]any {
items, _ := value.([]any)
if len(items) == 0 {
return nil
}
locations := make([]map[string]any, 0, len(items))
for _, item := range items {
location, _ := item.(map[string]any)
if len(location) == 0 {
continue
}
locations = append(locations, location)
}
return locations
}
func acpFirstLocationPath(locations []map[string]any) string {
if len(locations) == 0 {
return ""
}
return strings.TrimSpace(asString(locations[0]["path"]))
}
func acpNormalizeTodos(value any) []map[string]any {
switch typed := value.(type) {
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
todo, _ := item.(map[string]any)
if len(todo) == 0 {
continue
}
out = append(out, clonePayloadDeep(todo))
}
return out
case string:
lines := strings.Split(typed, "\n")
out := make([]map[string]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "- [") {
continue
}
status := "pending"
if strings.HasPrefix(strings.ToLower(line), "- [x]") {
status = "completed"
}
content := strings.TrimSpace(line[5:])
if content == "" {
continue
}
out = append(out, map[string]any{
"content": content,
"status": status,
})
}
return out
default:
return nil
}
}
func acpContentText(value any) string {
items, _ := value.([]any)
parts := make([]string, 0, len(items))
for _, item := range items {
if text := acpExtractContentText(item); text != "" {
parts = append(parts, text)
}
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func acpExtractContentText(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if text := strings.TrimSpace(asString(typed["text"])); text != "" {
return text
}
if content := strings.TrimSpace(asString(typed["content"])); content != "" {
return content
}
if nested, ok := typed["content"].(map[string]any); ok {
if text := strings.TrimSpace(asString(nested["text"])); text != "" {
return text
}
}
}
return ""
}
func acpApplyDiffContent(body map[string]any, value any) {
items, _ := value.([]any)
for _, item := range items {
diff, _ := item.(map[string]any)
if strings.TrimSpace(asString(diff["type"])) != "diff" {
continue
}
if path := strings.TrimSpace(asString(diff["path"])); path != "" {
body["filePath"] = path
}
if oldText, ok := diff["oldText"].(string); ok && oldText != "" {
body["oldString"] = oldText
}
if newText, ok := diff["newText"].(string); ok && newText != "" {
body["newString"] = newText
}
return
}
}
func acpToolCallDiagnosticEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("TUTTI_ACP_TOOL_DEBUG"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func logACPToolCallDiagnostic(session Session, turnID string, raw map[string]any, normalized map[string]any) {
if !acpToolCallDiagnosticEnabled() {
return
}
slog.Info(
"acp tool call normalized",
"event", "agent_session.acp_tool_call.normalized",
"room_id", strings.TrimSpace(session.RoomID),
"agent_session_id", strings.TrimSpace(session.AgentSessionID),
"turn_id", strings.TrimSpace(turnID),
"raw", clonePayloadDeep(raw),
"normalized", clonePayload(normalized),
)
}
func clonePayloadDeep(payload map[string]any) map[string]any {
if len(payload) == 0 {
return nil
}
out := make(map[string]any, len(payload))
for key, value := range payload {
out[key] = clonePayloadValue(value)
}
return out
}
func normalizedCallStatus(status string) string {
switch canonicalACPStatusToken(status) {
case "completed", "complete", "success", "succeeded", "ok", "done":
return messageStreamStateCompleted
case "failed", "failure", "error", "errored", "canceled", "cancel":
return messageStreamStateFailed
default:
return messageStreamStateStreaming
}
}
func canonicalACPStatusToken(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "cancelled":
return SessionStatusCanceled
default:
return strings.ToLower(strings.TrimSpace(status))
}
}
func acpInferTerminalToolStatus(rawOutput any) string {
body := acpMapFromValue(rawOutput, "output")
if len(body) == 0 {
return ""
}
if status := normalizedCallStatus(asString(body["status"])); status != messageStreamStateStreaming {
return status
}
if status := normalizedCallStatus(asString(body["state"])); status != messageStreamStateStreaming {
return status
}
if exitCode, ok := acpIntFromValue(body["exitCode"]); ok {
if exitCode == 0 {
return messageStreamStateCompleted
}
return messageStreamStateFailed
}
if exitCode, ok := acpIntFromValue(body["exit_code"]); ok {
if exitCode == 0 {
return messageStreamStateCompleted
}
return messageStreamStateFailed
}
if exitCode, ok := acpExitCodeFromText(body["output"]); ok {
if exitCode == 0 {
return messageStreamStateCompleted
}
return messageStreamStateFailed
}
return ""
}
func acpInferImageGenerationTerminalStatus(update map[string]any, rawOutput any) string {
if !acpToolCallLooksLikeImageGeneration(update) {
return ""
}
if acpContainsImageContent(update["content"]) {
return messageStreamStateCompleted
}
if strings.TrimSpace(firstNonEmpty(
asString(update["saved_path"]),
asString(update["savedPath"]),
asString(update["result"]),
)) != "" {
return messageStreamStateCompleted
}
body := acpMapFromValue(rawOutput, "output")
if len(body) == 0 {
return ""
}
if strings.TrimSpace(firstNonEmpty(
asString(body["saved_path"]),
asString(body["savedPath"]),
asString(body["result"]),
)) != "" {
return messageStreamStateCompleted
}
return ""
}
func acpToolCallLooksLikeImageGeneration(update map[string]any) bool {
for _, candidate := range []string{
asString(update["toolName"]),
asString(update["title"]),
asString(update["name"]),
asString(update["toolCallId"]),
asString(update["id"]),
} {
normalized := strings.ToLower(strings.TrimSpace(candidate))
if strings.HasPrefix(normalized, "ig_") {
return true
}
if toolName := acpCanonicalImageGenerationToolName(candidate, update["content"]); toolName != "" {
return true
}
}
return false
}