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

496 lines
16 KiB
Go

package agentruntime
import (
"fmt"
"strings"
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
)
func messageUpdateFromSessionEvent(
source canonical.EventSource,
event activityshared.Event,
sessionID string,
timestamp int64,
) (agentsessionstore.WorkspaceAgentMessageUpdate, bool) {
switch event.Type {
case activityshared.EventMessageAppended, activityshared.EventMessageCreated:
return textMessageUpdateFromSessionEvent(event, sessionID, timestamp)
case activityshared.EventCallStarted, activityshared.EventCallCompleted, activityshared.EventCallFailed:
return callMessageUpdateFromSessionEvent(source, event, sessionID, timestamp)
default:
return agentsessionstore.WorkspaceAgentMessageUpdate{}, false
}
}
func textMessageUpdateFromSessionEvent(
event activityshared.Event,
sessionID string,
timestamp int64,
) (agentsessionstore.WorkspaceAgentMessageUpdate, bool) {
messageID := firstNonEmptyString(stringFromPayload(event.Payload.Metadata, "messageId"), event.EventID)
if strings.TrimSpace(sessionID) == "" || messageID == "" || timestamp <= 0 {
return agentsessionstore.WorkspaceAgentMessageUpdate{}, false
}
role := strings.TrimSpace(string(event.Payload.Role))
if role == "" {
role = string(activityshared.MessageRoleAssistant)
}
kind := "text"
if role == string(activityshared.MessageRoleAssistantThinking) {
role = string(activityshared.MessageRoleAssistant)
kind = "reasoning"
}
payload := map[string]any{
"source": "runtime",
}
if event.Payload.Content != "" {
payload["content"] = event.Payload.Content
payload["text"] = event.Payload.Content
}
if content, ok := event.Payload.Metadata["content"]; ok {
payload["content"] = acpSanitizeImagePayload(content)
}
if displayPrompt := stringFromPayload(event.Payload.Metadata, "displayPrompt"); displayPrompt != "" {
payload["displayPrompt"] = displayPrompt
}
if clientSubmitID := stringFromPayload(event.Payload.Metadata, "clientSubmitId"); clientSubmitID != "" {
payload["clientSubmitId"] = clientSubmitID
}
update := agentsessionstore.WorkspaceAgentMessageUpdate{
AgentSessionID: strings.TrimSpace(sessionID),
MessageID: messageID,
Seq: uint64(timestamp),
TurnID: strings.TrimSpace(event.Payload.TurnID),
Role: role,
Kind: kind,
Status: firstNonEmptyString(stringFromPayload(event.Payload.Metadata, "streamState"), event.Payload.Status),
Payload: payload,
OccurredAtUnixMS: timestamp,
}
if contentMode := stringFromPayload(event.Payload.Metadata, "contentMode"); contentMode != "" {
update.Payload["contentMode"] = contentMode
}
// Carry the adapter's message kind tag (e.g. codex plan proposals) so the
// GUI can render dedicated treatments instead of a plain assistant bubble.
if messageKind := stringFromPayload(event.Payload.Metadata, "messageKind"); messageKind != "" {
update.Payload["messageKind"] = messageKind
}
update.Semantics = messageSemanticsFromEventPayload(event.Payload)
forwardSystemNoticeMessageMetadata(update.Payload, event.Payload.Metadata)
return update, true
}
func messageSemanticsFromEventPayload(payload activityshared.EventPayload) *canonical.WorkspaceAgentMessageSemantics {
metadata := payload.Metadata
semantics := canonical.WorkspaceAgentMessageSemantics{
UserVisibleAssistantResponse: payload.Semantics.UserVisibleAssistantResponse,
}
if value, ok := metadata["turnSettling"].(bool); ok {
semantics.TurnSettling = value
}
semantics.NoticeCommand = stringFromPayload(metadata, "noticeCommand")
semantics.NoticeCommandStatus = stringFromPayload(metadata, "noticeCommandStatus")
return &semantics
}
func forwardSystemNoticeMessageMetadata(payload map[string]any, metadata map[string]any) {
if stringFromPayload(metadata, "kind") != "agent_system_notice" {
return
}
for _, key := range []string{
"kind",
"noticeKind",
"severity",
"title",
"detail",
"additionalDetails",
"code",
"noticeCommand",
"noticeCommandStatus",
} {
if value := stringFromPayload(metadata, key); value != "" {
payload[key] = value
}
}
if retryable, ok := metadata["retryable"].(bool); ok {
payload["retryable"] = retryable
}
for _, key := range []string{"acp", "codexErrorInfo", "extra"} {
if value, ok := metadata[key]; ok && !payloadValueIsEmpty(value) {
payload[key] = clonePayloadValue(value)
}
}
}
func callMessageUpdateFromSessionEvent(
source canonical.EventSource,
event activityshared.Event,
sessionID string,
timestamp int64,
) (agentsessionstore.WorkspaceAgentMessageUpdate, bool) {
if strings.TrimSpace(sessionID) == "" || timestamp <= 0 {
return agentsessionstore.WorkspaceAgentMessageUpdate{}, false
}
callID := strings.TrimSpace(event.Payload.CallID)
messageID := toolCallMessageUpdateID(event, sessionID, timestamp)
if messageID == "" {
return agentsessionstore.WorkspaceAgentMessageUpdate{}, false
}
status := callMessageUpdateStatus(event)
payload := map[string]any{
"source": "runtime",
}
rawName := callMessageUpdateDisplayName(event, callID)
toolName := callMessageUpdateToolName(event, callID, rawName)
name := firstNonEmptyString(toolName, rawName)
if name != "" {
payload["name"] = name
}
if callType := firstNonEmptyString(event.Payload.CallType, stringFromPayload(event.Payload.Metadata, "callType")); callType != "" {
payload["callType"] = callType
}
for _, key := range []string{
"toolName",
"activityKind",
"fileChangeKind",
"fileChanges",
"command",
"status",
"exitCode",
"exit_code",
"sessionID",
"paths",
"requestId",
} {
if value, ok := event.Payload.Metadata[key]; ok && !payloadValueIsEmpty(value) {
if key == "toolName" {
continue
}
payload[key] = clonePayloadValue(value)
}
}
if toolName != "" {
payload["toolName"] = toolName
}
if metadata, ok := event.Payload.Metadata["metadata"].(map[string]any); ok && len(metadata) > 0 {
payload["metadata"] = canonicalToolMetadataPayload(metadata)
}
for _, key := range []string{"input", "output", "error", "content", "locations"} {
if value, ok := event.Payload.Metadata[key]; ok && !payloadValueIsEmpty(value) {
switch key {
case "output", "error":
payload[key] = canonicalToolBodyPayload(value)
default:
payload[key] = clonePayloadValue(value)
}
}
}
switch event.Type {
case activityshared.EventCallStarted:
if len(event.Payload.Input) > 0 {
payload["input"] = clonePayload(event.Payload.Input)
}
case activityshared.EventCallCompleted:
// Cursor ACP completes with empty rawInput; the normalizer merges the
// earlier start input into Metadata/Input. Keep copying Payload.Input so
// a completed update cannot drop Glob/Grep/Read args when Metadata omit
// them during projection.
if len(event.Payload.Input) > 0 {
if _, exists := payload["input"]; !exists {
payload["input"] = clonePayload(event.Payload.Input)
}
}
if len(event.Payload.Output) > 0 {
payload["output"] = canonicalToolBodyPayload(event.Payload.Output)
}
case activityshared.EventCallFailed:
if len(event.Payload.Input) > 0 {
if _, exists := payload["input"]; !exists {
payload["input"] = clonePayload(event.Payload.Input)
}
}
if len(event.Payload.Output) > 0 {
payload["output"] = canonicalToolBodyPayload(event.Payload.Output)
}
if len(event.Payload.Error) > 0 {
payload["error"] = canonicalToolBodyPayload(event.Payload.Error)
}
}
// Preserve an explicit nil until the canonical deep merge so a terminal
// snapshot can clear reconstructible text left by an earlier running
// update. Detect the alias while the raw stream still has its full prefix;
// independent per-field truncation can otherwise make equal values diverge.
canonical.TombstoneTerminalCommandOutputAliases(status, payload)
truncateCanonicalToolMessagePayload(payload)
update := agentsessionstore.WorkspaceAgentMessageUpdate{
AgentSessionID: strings.TrimSpace(sessionID),
MessageID: messageID,
Seq: uint64(timestamp),
TurnID: strings.TrimSpace(event.Payload.TurnID),
Role: string(activityshared.MessageRoleAssistant),
Kind: "tool_call",
Status: status,
Semantics: &canonical.WorkspaceAgentMessageSemantics{UserVisibleAssistantResponse: false},
CallID: callID,
Title: name,
Payload: payload,
OccurredAtUnixMS: timestamp,
}
if provider := firstNonEmptyString(string(event.Provider), source.Provider); provider != "" {
update.Payload["provider"] = provider
}
switch event.Type {
case activityshared.EventCallStarted:
update.StartedAtUnixMS = timestamp
case activityshared.EventCallCompleted, activityshared.EventCallFailed:
update.CompletedAtUnixMS = timestamp
}
return update, true
}
func canonicalToolBodyPayload(value any) any {
body, ok := clonePayloadValue(value).(map[string]any)
if !ok || len(body) == 0 {
return clonePayloadValue(value)
}
if strings.TrimSpace(asString(body["text"])) == "" {
if text := canonicalToolBodyText(body); text != "" {
body["text"] = text
}
}
return body
}
func canonicalToolMetadataPayload(metadata map[string]any) map[string]any {
return clonePayload(metadata)
}
func truncateCanonicalToolMessagePayload(payload map[string]any) {
for _, key := range []string{"output", "error"} {
body, _ := payload[key].(map[string]any)
if body != nil {
payload[key] = canonical.TruncateToolOutputBody(body)
}
}
if steps := payload["steps"]; steps != nil {
truncated := canonical.TruncateToolOutputBody(map[string]any{
"steps": steps,
})
payload["steps"] = truncated["steps"]
}
metadata, _ := payload["metadata"].(map[string]any)
if metadata == nil || metadata["steps"] == nil {
return
}
truncated := canonical.TruncateToolOutputBody(map[string]any{
"steps": metadata["steps"],
})
metadata["steps"] = truncated["steps"]
}
func canonicalToolBodyText(body map[string]any) string {
for _, key := range []string{"text", "output", "stdout", "aggregated_output", "formatted_output", "stderr", "message"} {
if text := strings.TrimSpace(asString(body[key])); text != "" {
return text
}
}
return strings.TrimSpace(acpContentText(body["content"]))
}
func callMessageUpdateDisplayName(event activityshared.Event, callID string) string {
for _, candidate := range []string{
event.Payload.Name,
stringFromPayload(event.Payload.Metadata, "name"),
} {
if trimmed := strings.TrimSpace(candidate); trimmed != "" && !isOpaqueCallIdentifierString(trimmed, callID) {
return trimmed
}
}
return ""
}
func callMessageUpdateToolName(event activityshared.Event, callID string, displayName string) string {
for _, candidate := range []string{
stringFromPayload(event.Payload.Metadata, "toolName"),
stringFromPayload(event.Payload.Metadata, "tool"),
displayName,
event.Payload.Name,
stringFromPayload(event.Payload.Metadata, "name"),
stringFromPayload(event.Payload.Metadata, "activityKind"),
stringFromPayload(event.Payload.Metadata, "activity_kind"),
stringFromPayload(event.Payload.Metadata, "kind"),
} {
if toolName := canonicalAgentToolName(candidate, callID); toolName != "" {
return toolName
}
}
if commandFromPayload(event.Payload.Input) != "" {
return "Bash"
}
return ""
}
func canonicalAgentToolName(value string, callID string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || isOpaqueCallIdentifierString(trimmed, callID) {
return ""
}
if strings.HasPrefix(strings.ToLower(trimmed), "mcp__") {
return trimmed
}
normalized := normalizeAgentToolToken(trimmed)
switch normalized {
case "approval":
return "Approval"
case "askuserquestion":
return "AskUserQuestion"
case "enterplanmode":
return "EnterPlanMode"
case "exitplanmode":
return "ExitPlanMode"
case "toolsearch":
return "ToolSearch"
case "skill":
return "Skill"
case "think":
return "Think"
case "bash", "shell", "exec", "execcommand", "runshellcommand", "terminal", "command", "runcommand":
return "Bash"
case "read", "readfile", "openfile", "listdir", "listdirectory", "listfiles", "ls":
return "Read"
case "write", "writefile", "createfile", "createnewfile", "writetofile":
return "Write"
case "edit", "editfile", "multiedit", "replaceinfile", "inserttext", "applypatch", "move":
return "Edit"
case "grep", "rg", "ripgrep", "search", "searchfiles", "searchfilecontent", "codebasesearch":
return "Grep"
case "glob", "find", "fd", "file_search", "filesearch", "findfiles":
return "Glob"
case "websearch", "googlesearch", "googlewebsearch", "websearchpreview":
return "WebSearch"
case "webfetch", "fetch", "fetchurl", "openpage":
return "WebFetch"
case "todowrite", "todo", "todowritefile", "updatetodo", "updatetodos", "writetodos":
return "TodoWrite"
case "task", "agent", "subagent", "runsubagent", "delegatetask", "delegateagent", "executeagent":
return "Agent"
case "run_command":
return "Bash"
case "read_file", "read_notebook", "list_files":
return "Read"
case "write_file":
return "Write"
case "edit_file", "edit_notebook", "apply_patch":
return "Edit"
case "find_files":
return "Glob"
case "search_files":
return "Grep"
case "web_search":
return "WebSearch"
case "web_fetch":
return "WebFetch"
case "update_todos":
return "TodoWrite"
case "delegate_agent":
return "Agent"
default:
return trimmed
}
}
func normalizeAgentToolToken(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = strings.TrimPrefix(normalized, "tool.")
normalized = strings.ReplaceAll(normalized, "-", "")
normalized = strings.ReplaceAll(normalized, "_", "")
normalized = strings.ReplaceAll(normalized, " ", "")
return normalized
}
func commandFromPayload(payload map[string]any) string {
return firstNonEmptyString(asString(payload["command"]), asString(payload["cmd"]), asString(payload["shell_command"]))
}
func isOpaqueCallIdentifierString(value string, callID string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
if normalizedCallID := strings.TrimSpace(callID); normalizedCallID != "" && trimmed == normalizedCallID {
return true
}
// Cursor ACP call ids are `call-<uuid>-N\nfc_<uuid>_N`. Treat any value that
// equals / starts as that opaque id as non-displayable so merge logic does
// not re-derive tool names from the call id after an empty tool_call_update.
if callID = strings.TrimSpace(callID); callID != "" {
if primary, _, ok := strings.Cut(callID, "\n"); ok && primary != "" && (trimmed == primary || strings.HasPrefix(trimmed, primary+"\n")) {
return true
}
}
lower := strings.ToLower(trimmed)
switch {
case strings.HasPrefix(lower, "call_"):
return isOpaqueIdentifierTail(trimmed[len("call_"):])
case strings.HasPrefix(lower, "call-"):
rest := trimmed[len("call-"):]
if primary, _, ok := strings.Cut(rest, "\n"); ok {
rest = primary
}
return len(rest) >= 12
case strings.HasPrefix(lower, "ws_"):
return isOpaqueIdentifierTail(trimmed[len("ws_"):])
default:
return false
}
}
func isOpaqueIdentifierTail(value string) bool {
if len(value) < 12 {
return false
}
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
continue
}
return false
}
return true
}
func toolCallMessageUpdateID(event activityshared.Event, sessionID string, timestamp int64) string {
if callID := strings.TrimSpace(event.Payload.CallID); callID != "" {
return "toolcall:" + callID
}
if eventID := strings.TrimSpace(event.EventID); eventID != "" {
return "toolcall:" + eventID
}
fallback := firstNonEmptyString(event.Payload.TurnID, sessionID, fmt.Sprintf("%d", timestamp))
if fallback == "" {
return ""
}
return "toolcall:" + fallback
}
func callMessageUpdateStatus(event activityshared.Event) string {
switch event.Type {
case activityshared.EventCallCompleted:
return string(activityshared.ActivityStatusCompleted)
case activityshared.EventCallFailed:
return string(activityshared.ActivityStatusFailed)
case activityshared.EventCallStarted:
status := firstNonEmptyString(event.Payload.Status, stringFromPayload(event.Payload.Metadata, "status"))
switch strings.ToLower(status) {
case string(activityshared.TurnPhaseWaitingApproval), string(activityshared.TurnPhaseWaitingInput), "waiting":
return status
default:
return string(activityshared.ActivityStatusRunning)
}
default:
return ""
}
}