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

309 lines
10 KiB
Go

package agenthost
import (
"context"
"strings"
storesqlite "github.com/tutti-os/tutti/packages/agent/store-sqlite"
)
// ObserveTerminalFailuresFromDelta extracts aggregated terminal failures from
// an already-committed delta.
//
// Host.notifyCommitted owns this for every delta it publishes, so a
// CommitObserver must never call it again for the deltas it receives. Only a
// report path that commits and calls NotifyCommitted without going through
// Host — the daemon's direct activity-state, session-message, and stale-turn
// reports — calls it, exactly once, next to its own NotifyCommitted.
func ObserveTerminalFailuresFromDelta(ctx context.Context, observer TerminalFailureObserver, delta CommittedDelta) {
if observer == nil {
return
}
for _, failure := range terminalFailuresFromDelta(delta) {
if failure.ErrorMessage == "" && failure.ErrorCode == "" {
continue
}
observer.ObserveTerminalFailure(ctx, failure)
}
}
func terminalFailuresFromDelta(delta CommittedDelta) []TerminalFailure {
failures := make([]TerminalFailure, 0, 4)
childBySession := map[string]bool{}
if delta.ActivityState != nil {
sessionID := firstNonEmptyTrimmed(
delta.ActivityState.Input.AgentSessionID,
delta.ActivityState.Result.RootTurn.AgentSessionID,
)
if sessionID != "" {
childBySession[sessionID] = sessionStateIsChild(delta.ActivityState.Input.State)
}
}
if delta.RuntimeOperation != nil && delta.RuntimeOperation.Stage == RuntimeOperationFailed {
if failure, ok := terminalFailureFromRuntimeOperation(*delta.RuntimeOperation); ok {
failures = append(failures, failure)
}
}
if delta.GoalOperation != nil &&
(delta.GoalOperation.Stage == GoalOperationFailed || delta.GoalOperation.Stage == GoalOperationTerminal) {
if failure, ok := terminalFailureFromGoalOperation(*delta.GoalOperation); ok {
failures = append(failures, failure)
}
}
for _, settled := range delta.RootTurnsSettled {
if failure, ok := terminalFailureFromRootTurn(settled); ok {
failures = append(failures, failure)
}
}
if delta.SessionMessages != nil {
failures = append(failures, terminalFailuresFromSessionMessages(*delta.SessionMessages, childBySession)...)
}
return failures
}
func terminalFailureFromRuntimeOperation(committed RuntimeOperationCommitted) (TerminalFailure, bool) {
op := committed.Operation
flow := runtimeOperationFailureFlow(op.Kind)
if flow == "" {
return TerminalFailure{}, false
}
message := strings.TrimSpace(op.LastError)
if message == "" {
message = strings.TrimSpace(op.Result)
}
if message == "" {
message = "runtime operation failed"
}
return TerminalFailure{
Flow: flow,
FailureStage: "runtime_exec",
WorkspaceID: strings.TrimSpace(op.WorkspaceID),
AgentSessionID: strings.TrimSpace(op.AgentSessionID),
TurnID: strings.TrimSpace(op.TurnID),
OperationID: strings.TrimSpace(op.OperationID),
ClientSubmitID: runtimeOperationPayloadText(op.Payload, "clientSubmitId"),
RequestID: strings.TrimSpace(op.RequestID),
Provider: strings.TrimSpace(committed.Provider),
ErrorCode: strings.TrimSpace(op.Result),
ErrorMessage: message,
InteractionKind: runtimeOperationInteractionKind(op),
IsChildSession: committed.IsChildSession,
Retryable: false,
}, true
}
func runtimeOperationFailureFlow(kind string) string {
switch strings.TrimSpace(kind) {
case storesqlite.RuntimeOperationKindInteractiveResponse:
return "interactive_response"
case storesqlite.RuntimeOperationKindPlanDecision:
return "plan_decision"
case storesqlite.RuntimeOperationKindCancelTurn:
return "turn_cancel"
case storesqlite.RuntimeOperationKindEditRetry:
return "edit_retry"
default:
return ""
}
}
func runtimeOperationInteractionKind(op storesqlite.RuntimeOperation) string {
if value := runtimeOperationPayloadText(op.Payload, "interactionKind"); value != "" {
return value
}
if op.Kind == storesqlite.RuntimeOperationKindPlanDecision {
return storesqlite.InteractionKindPlan
}
return ""
}
func terminalFailureFromGoalOperation(committed GoalOperationCommitted) (TerminalFailure, bool) {
op := committed.Operation
message := strings.TrimSpace(op.LastError)
if message == "" {
message = strings.TrimSpace(committed.State.LastError)
}
if message == "" && committed.Stage == GoalOperationTerminal {
message = "goal control terminal incident"
}
if message == "" {
return TerminalFailure{}, false
}
workspaceID := strings.TrimSpace(op.WorkspaceID)
sessionID := strings.TrimSpace(op.AgentSessionID)
if workspaceID == "" {
workspaceID = strings.TrimSpace(committed.State.WorkspaceID)
}
if sessionID == "" {
sessionID = strings.TrimSpace(committed.State.AgentSessionID)
}
return TerminalFailure{
Flow: "goal_control",
FailureStage: string(committed.Stage),
WorkspaceID: workspaceID,
AgentSessionID: sessionID,
OperationID: strings.TrimSpace(op.OperationID),
ClientSubmitID: strings.TrimSpace(op.ClientSubmitID),
Provider: strings.TrimSpace(committed.Provider),
ErrorMessage: message,
IsChildSession: committed.IsChildSession,
Retryable: false,
}, true
}
func terminalFailureFromRootTurn(settled RootTurnSettled) (TerminalFailure, bool) {
outcome := strings.TrimSpace(settled.Turn.Outcome)
switch outcome {
case storesqlite.TurnOutcomeFailed:
default:
return TerminalFailure{}, false
}
message := strings.TrimSpace(settled.Turn.ErrorMessage)
if message == "" {
message = "turn " + outcome
}
return TerminalFailure{
Flow: "turn",
FailureStage: "settled",
WorkspaceID: strings.TrimSpace(settled.WorkspaceID),
AgentSessionID: strings.TrimSpace(settled.AgentSessionID),
TurnID: strings.TrimSpace(settled.Turn.TurnID),
OperationID: strings.TrimSpace(settled.Turn.SourceGoalOperationID),
Provider: strings.TrimSpace(settled.Provider),
ErrorCode: strings.TrimSpace(settled.Turn.ErrorCode),
ErrorMessage: message,
TurnOutcome: outcome,
DurationMS: settledTurnDurationMS(settled.Turn),
StartupReconciled: settled.StartupReconciled,
IsChildSession: settled.IsChildSession,
Retryable: false,
}, true
}
func terminalFailuresFromSessionMessages(committed SessionMessagesCommitted, childBySession map[string]bool) []TerminalFailure {
workspaceID := strings.TrimSpace(committed.Input.WorkspaceID)
// Result.Messages also carries messages the report replayed without
// changing anything. Only a real transition into the failed status is a
// new incident.
transitioned := make(map[string]struct{}, len(committed.Result.StatusTransitionedMessageIDs))
for _, messageID := range committed.Result.StatusTransitionedMessageIDs {
transitioned[strings.TrimSpace(messageID)] = struct{}{}
}
failures := make([]TerminalFailure, 0)
for _, message := range committed.Result.Messages {
if !isFailedToolCallMessage(message) {
continue
}
if _, ok := transitioned[strings.TrimSpace(message.MessageID)]; !ok {
continue
}
messageText := toolCallFailureMessage(message)
sessionID := firstNonEmptyTrimmed(message.AgentSessionID, committed.Input.AgentSessionID)
failures = append(failures, TerminalFailure{
Flow: "tool_call",
FailureStage: "settled",
WorkspaceID: workspaceID,
AgentSessionID: sessionID,
TurnID: strings.TrimSpace(message.TurnID),
RequestID: strings.TrimSpace(message.MessageID),
Provider: strings.TrimSpace(committed.Provider),
ErrorCode: strings.TrimSpace(message.Status),
ErrorMessage: messageText,
ToolNameFamily: toolNameFamily(message.Payload),
IsChildSession: committed.IsChildSession || childBySession[sessionID],
Retryable: false,
})
}
return failures
}
func settledTurnDurationMS(turn storesqlite.Turn) int64 {
if turn.StartedAtUnixMS <= 0 || turn.SettledAtUnixMS < turn.StartedAtUnixMS {
return 0
}
return turn.SettledAtUnixMS - turn.StartedAtUnixMS
}
func isFailedToolCallMessage(message storesqlite.Message) bool {
if strings.TrimSpace(message.Kind) != "tool_call" {
return false
}
switch strings.TrimSpace(message.Status) {
case "failed", "errored":
return true
default:
return false
}
}
func toolCallFailureMessage(message storesqlite.Message) string {
for _, key := range []string{"error", "errorMessage", "message"} {
if value := toolCallPayloadFailureText(message.Payload, key); value != "" {
return value
}
}
status := strings.TrimSpace(message.Status)
if status == "" {
return "tool call failed"
}
return "tool call " + status
}
// toolCallPayloadFailureText prefers the provider's own text. Normalized ACP
// tool failures carry it in a nested body under payload.error, while imported
// and legacy shapes keep a plain string on the same key.
func toolCallPayloadFailureText(payload map[string]any, key string) string {
if value := runtimeOperationPayloadText(payload, key); value != "" {
return value
}
nested, ok := payload[key].(map[string]any)
if !ok {
return ""
}
return firstNonEmptyTrimmed(
runtimeOperationPayloadText(nested, "text"),
runtimeOperationPayloadText(nested, "message"),
runtimeOperationPayloadText(nested, "errorMessage"),
runtimeOperationPayloadText(nested, "stderr"),
runtimeOperationPayloadText(nested, "stdout"),
runtimeOperationPayloadText(nested, "output"),
)
}
func toolNameFamily(payload map[string]any) string {
raw := firstNonEmptyTrimmed(
runtimeOperationPayloadText(payload, "toolName"),
runtimeOperationPayloadText(payload, "name"),
runtimeOperationPayloadText(payload, "tool_name"),
)
if raw == "" {
return "unknown"
}
normalized := strings.ToLower(strings.TrimSpace(raw))
normalized = strings.ReplaceAll(normalized, " ", "_")
switch {
case strings.Contains(normalized, "bash"), strings.Contains(normalized, "shell"), strings.Contains(normalized, "terminal"):
return "bash"
case strings.Contains(normalized, "edit"), strings.Contains(normalized, "write"), strings.Contains(normalized, "apply_patch"):
return "edit"
case strings.Contains(normalized, "read"), strings.Contains(normalized, "grep"), strings.Contains(normalized, "glob"), strings.Contains(normalized, "search"):
return "read"
case strings.Contains(normalized, "browser"), strings.Contains(normalized, "web"):
return "browser"
default:
if len(normalized) > 48 {
return normalized[:48]
}
return normalized
}
}
func firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}