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

585 lines
20 KiB
Go

package agentruntime
import (
"encoding/json"
"sort"
"strings"
"sync"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/daemon/liveprotocol"
)
type acpTurnNormalizer struct {
assistantMessageID string
assistantContent strings.Builder
assistantSegmentCompleted bool
thinkingMessageID string
thinkingContent strings.Builder
thinkingSegmentCompleted bool
thinkingMessageKind string
toolItemIDs map[string]string
toolCallsSeen map[string]bool
pendingToolCalls map[string]pendingToolCallSnapshot
toolOutputText map[string]string
toolOutputTruncated map[string]bool
earlyToolOutput map[string]earlyToolOutputSnapshot
earlyToolOutputBytes int
fileChanges map[string]any
authoritativeFileChanges map[string]struct{}
compactionMu sync.Mutex
compactionMessageID string
compactionTerminalStatus string
suppressAssistantOutput bool
systemNoticeOutputSeen bool
}
// StartCompactionNotice atomically claims the compaction lifecycle's stable
// message id. The bool reports whether the caller should publish the running
// notice; repeated provider starts reuse the id without emitting another row.
func (n *acpTurnNormalizer) StartCompactionNotice(messageID string) (string, bool) {
messageID = strings.TrimSpace(messageID)
if n == nil || messageID == "" {
return messageID, false
}
n.compactionMu.Lock()
defer n.compactionMu.Unlock()
if n.compactionMessageID != "" {
return n.compactionMessageID, false
}
n.compactionMessageID = messageID
return messageID, true
}
func (n *acpTurnNormalizer) HasCompactionNotice() bool {
if n == nil {
return false
}
n.compactionMu.Lock()
defer n.compactionMu.Unlock()
return n.compactionMessageID != ""
}
// CompleteCompactionNotice selects the provider-reported completed terminal.
// Terminal selection is first-write-wins: a late completion after a locally
// synthesized failed/canceled terminal is ignored.
func (n *acpTurnNormalizer) CompleteCompactionNotice(messageID string) (string, bool) {
messageID = strings.TrimSpace(messageID)
if n == nil || messageID == "" {
return messageID, false
}
n.compactionMu.Lock()
defer n.compactionMu.Unlock()
if n.compactionMessageID == "" {
n.compactionMessageID = messageID
}
if n.compactionTerminalStatus != "" {
return n.compactionMessageID, false
}
n.compactionTerminalStatus = "completed"
return n.compactionMessageID, true
}
// settlePendingCompactionEvents replaces a still-in-progress compaction banner
// in place (same messageId) when the turn ends without the compaction item
// completing.
func (n *acpTurnNormalizer) settlePendingCompactionEvents(
session Session,
turnID string,
status string,
) []activityshared.Event {
if n == nil {
return nil
}
n.compactionMu.Lock()
if n.compactionMessageID == "" || n.compactionTerminalStatus != "" {
n.compactionMu.Unlock()
return nil
}
messageID := n.compactionMessageID
n.compactionTerminalStatus = status
n.compactionMu.Unlock()
return []activityshared.Event{appServerCompactionNoticeEvent(session, turnID, messageID, status)}
}
// SetThinkingPresentation tags thinking snapshots with an optional messageKind
// and adjusts streaming behavior. Review inline turns use messageKind
// review-process so the GUI renders reasoning as direct prose.
func (n *acpTurnNormalizer) SetThinkingPresentation(messageKind string) {
if n == nil {
return
}
n.thinkingMessageKind = strings.TrimSpace(messageKind)
}
func (n *acpTurnNormalizer) SuppressAssistantOutput() {
if n == nil {
return
}
n.suppressAssistantOutput = true
}
func newACPTurnNormalizer() *acpTurnNormalizer {
return &acpTurnNormalizer{
toolItemIDs: make(map[string]string),
toolCallsSeen: make(map[string]bool),
pendingToolCalls: make(map[string]pendingToolCallSnapshot),
toolOutputText: make(map[string]string),
toolOutputTruncated: make(map[string]bool),
earlyToolOutput: make(map[string]earlyToolOutputSnapshot),
authoritativeFileChanges: make(map[string]struct{}),
}
}
func (n *acpTurnNormalizer) AppendAssistantChunk(session Session, turnID string, chunk string) []activityshared.Event {
if n == nil || chunk == "" {
return nil
}
if n.suppressAssistantOutput {
return nil
}
if n.assistantMessageID == "" || n.assistantSegmentCompleted {
n.assistantMessageID = newID()
n.assistantContent.Reset()
n.assistantSegmentCompleted = false
}
liveOperation := n.mergeAssistantText(chunk)
if liveOperation == nil {
// Duplicate and backtracking provider snapshots do not change the
// normalized message. Suppress them here so they cannot fall through
// the stream projection as full precommit message_update snapshots.
return nil
}
event := n.assistantSnapshotEvent(session, turnID, messageStreamStateStreaming)
attachTextLiveOperation(&event, liveOperation, RoleAssistant, "text")
return []activityshared.Event{event}
}
func (n *acpTurnNormalizer) AppendThinkingChunk(session Session, turnID string, chunk string) []activityshared.Event {
if n == nil || chunk == "" {
return nil
}
firstChunk := n.thinkingMessageID == "" || n.thinkingSegmentCompleted
if firstChunk {
n.thinkingMessageID = newID()
n.thinkingContent.Reset()
n.thinkingSegmentCompleted = false
}
_, _ = n.thinkingContent.WriteString(chunk)
if n.thinkingMessageKind == "review-process" {
// Codex summaryTextDelta often streams word-sized tokens without spaces.
// Defer emission until item/completed supplies the authoritative summary.
return nil
}
event := n.thinkingSnapshotEvent(session, turnID, messageStreamStateStreaming)
operation := &liveprotocol.MessageContentOperation{Operation: "append_text", Text: chunk}
if firstChunk {
value, _ := json.Marshal(chunk)
operation = &liveprotocol.MessageContentOperation{Operation: "set", Value: value}
}
attachTextLiveOperation(&event, operation, RoleAssistantThinking, "reasoning")
return []activityshared.Event{event}
}
// CurrentAssistantText returns the text of the assistant segment currently
// accumulating (the turn's most recent one). Exec uses it to inspect the
// trailing output when a turn ends right after an in-band error line. A
// finalized segment returns "": once Finish closed it out (as the
// auto-continue path does before retrying), its text must not leak into the
// next attempt's inspection — a continuation that streams no new assistant
// text would otherwise re-detect the previous attempt's error tail.
func (n *acpTurnNormalizer) CurrentAssistantText() string {
if n == nil || n.assistantSegmentCompleted {
return ""
}
return n.assistantContent.String()
}
// SeenToolCallCount returns how many distinct tool calls this turn has
// observed. Auto-continue uses it (with CurrentAssistantText) to decide
// whether the failed attempt made useful progress.
func (n *acpTurnNormalizer) SeenToolCallCount() int {
if n == nil {
return 0
}
return len(n.toolCallsSeen)
}
// HasObservableOutput reports whether the current provider turn produced
// anything the user can observe. Thinking and system notices are valid
// assistant output even when the provider emits no final assistant text.
func (n *acpTurnNormalizer) HasObservableOutput() bool {
if n == nil {
return false
}
return strings.TrimSpace(n.CurrentAssistantText()) != "" ||
(!n.thinkingSegmentCompleted && strings.TrimSpace(n.thinkingContent.String()) != "") ||
n.SeenToolCallCount() > 0 ||
n.systemNoticeOutputSeen
}
func (n *acpTurnNormalizer) MarkSystemNoticeOutput() {
if n == nil {
return
}
n.systemNoticeOutputSeen = true
}
func (n *acpTurnNormalizer) ApplyAssistantFinalText(finalText string) {
if n == nil {
return
}
if n.suppressAssistantOutput {
return
}
finalText = strings.TrimSpace(finalText)
if finalText == "" {
return
}
// Codex may close a streamed assistant segment before item/completed
// redelivers the same answer with whitespace polish. Preserve the message id
// for equivalent text so the replay updates one bubble instead of opening a
// duplicate.
if n.assistantSegmentCompleted && n.assistantMessageID != "" {
previous := strings.TrimSpace(n.assistantContent.String())
if previous == finalText {
return
}
if assistantTextEquivalent(previous, finalText) {
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(finalText)
n.assistantSegmentCompleted = false
return
}
}
if n.assistantMessageID == "" || n.assistantSegmentCompleted {
n.assistantMessageID = newID()
n.assistantSegmentCompleted = false
}
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(finalText)
}
func assistantTextEquivalent(left, right string) bool {
return normalizeAssistantCompareText(left) == normalizeAssistantCompareText(right)
}
func normalizeAssistantCompareText(text string) string {
return strings.Join(strings.Fields(strings.TrimSpace(text)), "")
}
// ApplyAssistantTurnFinalText uses turn/completed text only when no assistant
// segment has already completed. item/completed is authoritative once shown;
// the turn payload commonly replays the same answer with minor polish.
func (n *acpTurnNormalizer) ApplyAssistantTurnFinalText(finalText string) {
if n == nil || n.assistantSegmentCompleted {
return
}
n.ApplyAssistantFinalText(finalText)
}
func (n *acpTurnNormalizer) AppendAssistantSnapshot(
session Session,
turnID string,
text string,
messageID string,
) []activityshared.Event {
if n == nil {
return nil
}
if n.suppressAssistantOutput {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
current := strings.TrimSpace(n.assistantContent.String())
if current == text && n.assistantMessageID != "" {
if n.assistantSegmentCompleted {
return nil
}
return n.Finish(session, turnID, messageStreamStateCompleted)
}
if n.assistantMessageID == "" || n.assistantSegmentCompleted {
n.assistantMessageID = firstNonEmpty(strings.TrimSpace(messageID), newID())
n.assistantSegmentCompleted = false
}
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(text)
return n.Finish(session, turnID, messageStreamStateCompleted)
}
func (n *acpTurnNormalizer) FailAssistantSnapshot(
session Session,
turnID string,
text string,
messageID string,
) []activityshared.Event {
if n == nil || n.suppressAssistantOutput {
return nil
}
text = strings.TrimSpace(text)
if text == "" {
return nil
}
messageID = strings.TrimSpace(messageID)
if n.assistantMessageID == "" || (messageID != "" && n.assistantMessageID != messageID) {
n.assistantMessageID = firstNonEmpty(messageID, newID())
}
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(text)
n.assistantSegmentCompleted = false
return n.Finish(session, turnID, messageStreamStateFailed)
}
func (n *acpTurnNormalizer) mergeAssistantText(next string) *liveprotocol.MessageContentOperation {
if n == nil || next == "" {
return nil
}
current := n.assistantContent.String()
trimmedCurrent := strings.TrimSpace(current)
trimmedNext := strings.TrimSpace(next)
switch {
case current == "":
_, _ = n.assistantContent.WriteString(next)
value, _ := json.Marshal(next)
return &liveprotocol.MessageContentOperation{Operation: "set", Value: value}
case next == current || trimmedNext == trimmedCurrent:
return nil
case strings.HasPrefix(next, current):
suffix := strings.TrimPrefix(next, current)
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(next)
return &liveprotocol.MessageContentOperation{Operation: "append_text", Text: suffix}
case strings.HasPrefix(trimmedNext, trimmedCurrent):
n.assistantContent.Reset()
_, _ = n.assistantContent.WriteString(next)
value, _ := json.Marshal(next)
return &liveprotocol.MessageContentOperation{Operation: "set", Value: value}
case strings.HasPrefix(current, next) || strings.HasPrefix(trimmedCurrent, trimmedNext):
return nil
default:
_, _ = n.assistantContent.WriteString(next)
return &liveprotocol.MessageContentOperation{Operation: "append_text", Text: next}
}
}
func (n *acpTurnNormalizer) Finish(session Session, turnID string, streamState string) []activityshared.Event {
if n == nil {
return nil
}
events := make([]activityshared.Event, 0, 2)
if n.thinkingMessageID != "" && n.thinkingContent.Len() > 0 && !n.thinkingSegmentCompleted {
events = append(events, n.thinkingSnapshotEvent(session, turnID, streamState))
n.thinkingSegmentCompleted = true
}
if n.assistantMessageID != "" && n.assistantContent.Len() > 0 && !n.assistantSegmentCompleted {
events = append(events, n.assistantSnapshotEvent(session, turnID, streamState))
n.assistantSegmentCompleted = true
}
return events
}
// hasStreamingThinkingSegment reports whether an in-flight thinking segment is
// still accumulating chunks (e.g. from reasoning textDelta) and has not been
// finalized yet.
func (n *acpTurnNormalizer) hasStreamingThinkingSegment() bool {
return n != nil &&
n.thinkingMessageID != "" &&
n.thinkingContent.Len() > 0 &&
!n.thinkingSegmentCompleted
}
// FinalizeThinkingItem closes out the thinking segment for a reasoning
// item/completed payload. When reasoning already streamed as textDelta chunks
// the content is buffered, so it only finalizes; for inline delivery (no
// deltas, e.g. /review) it seeds the segment from fullText first. This keeps
// streaming and inline reasoning from double-appending and makes each reasoning
// item render as exactly one finalized thinking row.
func (n *acpTurnNormalizer) FinalizeThinkingItem(session Session, turnID string, fullText string) []activityshared.Event {
if n == nil {
return nil
}
if fullText != "" {
if n.thinkingMessageID == "" || n.thinkingSegmentCompleted {
n.thinkingMessageID = newID()
n.thinkingContent.Reset()
n.thinkingSegmentCompleted = false
}
// item/completed summary is authoritative; replace streamed word-token deltas.
n.thinkingContent.Reset()
_, _ = n.thinkingContent.WriteString(fullText)
} else if !n.hasStreamingThinkingSegment() {
return nil
}
return n.Finish(session, turnID, messageStreamStateCompleted)
}
func (n *acpTurnNormalizer) FinishCompleted(session Session, turnID string) []activityshared.Event {
events := n.Finish(session, turnID, messageStreamStateCompleted)
// A tool call still pending when its own turn reaches a normal terminal
// state never received its own item/completed (for example codex silently
// declining a spawnAgent call for a schema conflict, with no further
// notification tied to that item id for the rest of the turn - confirmed
// via exported session transcripts). It must not be reported as a
// successful completion: that would paint a rejected/never-run tool call
// as having succeeded. Close it out the same way an interrupted or failed
// turn already does - as a failure - so the GUI can render a clear
// failed/rejected state instead of an indefinite "running"/"queued" one.
events = append(events, n.terminalToolCallEvents(session, turnID, messageStreamStateFailed, "turn_completed_without_call_result")...)
// A turn that completed normally implies the compaction it ran finished;
// no-op in the usual flow because item/completed already selected the
// lifecycle terminal state.
events = append(events, n.settlePendingCompactionEvents(session, turnID, "completed")...)
return events
}
func (n *acpTurnNormalizer) FinishFailed(session Session, turnID string) []activityshared.Event {
return n.finishTerminal(session, turnID, messageStreamStateFailed, messageStreamStateFailed, "turn_failed")
}
func (n *acpTurnNormalizer) FinishInterrupted(session Session, turnID string, reason string) []activityshared.Event {
return n.finishTerminal(session, turnID, messageStreamStateFailed, SessionStatusCanceled, reason)
}
func (n *acpTurnNormalizer) finishTerminal(
session Session,
turnID string,
streamState string,
toolStatus string,
reason string,
) []activityshared.Event {
events := n.Finish(session, turnID, streamState)
events = append(events, n.terminalToolCallEvents(session, turnID, toolStatus, reason)...)
events = append(events, n.settlePendingCompactionEvents(session, turnID, toolStatus)...)
return events
}
func (n *acpTurnNormalizer) terminalToolCallEvents(
session Session,
turnID string,
toolStatus string,
reason string,
) []activityshared.Event {
if n == nil || len(n.pendingToolCalls) == 0 {
return nil
}
keys := make([]string, 0, len(n.pendingToolCalls))
for key := range n.pendingToolCalls {
keys = append(keys, key)
}
sort.Strings(keys)
events := make([]activityshared.Event, 0, len(keys))
for _, key := range keys {
snapshot := n.pendingToolCalls[key]
payload := clonePayload(snapshot.payload)
if payload == nil {
payload = map[string]any{}
}
payload["status"] = toolStatus
errorPayload := payloadMap(payload, "error")
if errorPayload == nil {
errorPayload = map[string]any{}
}
errorPayload["status"] = toolStatus
if trimmedReason := strings.TrimSpace(reason); trimmedReason != "" {
errorPayload["reason"] = trimmedReason
errorPayload["message"] = trimmedReason
}
payload["error"] = errorPayload
events = append(events, newTurnActivityEventWithID(
session,
snapshot.eventID,
EventCallFailed,
turnID,
toolStatus,
"",
payloadString(payload, "name"),
payload,
))
delete(n.pendingToolCalls, key)
}
return events
}
func (n *acpTurnNormalizer) assistantSnapshotEvent(session Session, turnID string, streamState string) activityshared.Event {
status := messageStreamStateStreaming
switch streamState {
case messageStreamStateCompleted:
status = messageStreamStateCompleted
case messageStreamStateFailed:
status = messageStreamStateFailed
}
event := newTurnActivityEventWithID(session, n.assistantMessageID, EventMessage, turnID, status, RoleAssistant, n.assistantContent.String(), map[string]any{
"messageId": n.assistantMessageID,
"contentMode": messageContentModeSnapshot,
"streamState": status,
})
return event
}
func (n *acpTurnNormalizer) thinkingSnapshotEvent(session Session, turnID string, streamState string) activityshared.Event {
status := messageStreamStateStreaming
switch streamState {
case messageStreamStateCompleted:
status = messageStreamStateCompleted
case messageStreamStateFailed:
status = messageStreamStateFailed
}
metadata := map[string]any{
"messageId": n.thinkingMessageID,
"contentMode": messageContentModeSnapshot,
"streamState": status,
}
if messageKind := strings.TrimSpace(n.thinkingMessageKind); messageKind != "" {
metadata["messageKind"] = messageKind
}
event := newTurnActivityEventWithID(session, n.thinkingMessageID, EventMessage, turnID, status, RoleAssistantThinking, n.thinkingContent.String(), metadata)
return event
}
const (
liveContentOperationMetadataKey = "_tuttiLiveContentOperation"
liveToolOutputOperationMetadataKey = "_tuttiLiveToolOutputOperation"
// liveToolProgressMetadataKey marks Claude tool_updated call.started
// events that stream input/progress onto an already-open tool call. Those
// must still project durable message updates, but must not mint another
// tool.started checkpoint (no matching commit → checkpoint_commit_unconfirmed).
liveToolProgressMetadataKey = "_tuttiLiveToolProgress"
liveMessageRoleMetadataKey = "_tuttiLiveMessageRole"
liveMessageKindMetadataKey = "_tuttiLiveMessageKind"
)
func attachTextLiveOperation(
event *activityshared.Event,
operation *liveprotocol.MessageContentOperation,
role string,
kind string,
) {
if event == nil || operation == nil {
return
}
if event.Payload.Metadata == nil {
event.Payload.Metadata = map[string]any{}
}
event.Payload.Metadata[liveContentOperationMetadataKey] = operation
event.Payload.Metadata[liveMessageRoleMetadataKey] = role
event.Payload.Metadata[liveMessageKindMetadataKey] = kind
}
func attachToolOutputLiveOperation(
event *activityshared.Event,
operation *liveprotocol.MessageToolOutputOperation,
) {
if event == nil || operation == nil {
return
}
if event.Payload.Metadata == nil {
event.Payload.Metadata = map[string]any{}
}
event.Payload.Metadata[liveToolOutputOperationMetadataKey] = operation
event.Payload.Metadata[liveMessageRoleMetadataKey] = RoleAssistant
event.Payload.Metadata[liveMessageKindMetadataKey] = "tool_call"
}