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

506 lines
18 KiB
Go

package agentruntime
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
replay "github.com/tutti-os/tutti/packages/agent/session-replay"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
)
const WorkspaceAgentSessionOriginRuntime = "WORKSPACE_AGENT_SESSION_ORIGIN_RUNTIME"
var defaultReportRetryBackoff = []time.Duration{200 * time.Millisecond, 500 * time.Millisecond, time.Second}
type ActivityReporter interface {
Report(context.Context, agentsessionstore.ReportActivityInput) error
}
// DurableActivityReporter is the required host boundary for runtime
// controllers. ReportSubmitProvenance must atomically persist the report's
// session/turn patch and canonical client-submit message before returning.
// Compatibility ActivityReporter implementations that split state and message
// persistence do not satisfy this contract and cannot host a controller.
type DurableActivityReporter interface {
ActivityReporter
ReportSubmitProvenance(context.Context, agentsessionstore.ReportActivityInput) error
}
type ActivityClient interface {
ReportSessionState(context.Context, canonical.ReportSessionStateInput) (canonical.ReportSessionStateReply, error)
ReportSessionMessages(context.Context, canonical.ReportSessionMessagesInput) (canonical.ReportSessionMessagesReply, error)
}
type goalProvenanceActivityClient interface {
agentsessionstore.GoalProvenanceLedger
}
type Reporter struct {
ClientProvider func() ActivityClient
Logger *slog.Logger
MaxAttempts int
Backoff []time.Duration
}
func (r Reporter) BindGoalProvenance(ctx context.Context, input agentsessionstore.BindGoalProvenanceInput) (agentsessionstore.GoalProvenanceBinding, error) {
if r.ClientProvider == nil {
return agentsessionstore.GoalProvenanceBinding{}, errors.New("agent session activity client provider is nil")
}
client, ok := r.ClientProvider().(goalProvenanceActivityClient)
if !ok || client == nil {
return agentsessionstore.GoalProvenanceBinding{}, errors.New("agent session activity client does not support goal provenance")
}
return client.BindGoalProvenance(ctx, input)
}
func (r Reporter) LookupGoalProvenance(ctx context.Context, input agentsessionstore.LookupGoalProvenanceInput) (agentsessionstore.GoalProvenanceBinding, bool, error) {
if r.ClientProvider == nil {
return agentsessionstore.GoalProvenanceBinding{}, false, errors.New("agent session activity client provider is nil")
}
client, ok := r.ClientProvider().(goalProvenanceActivityClient)
if !ok || client == nil {
return agentsessionstore.GoalProvenanceBinding{}, false, errors.New("agent session activity client does not support goal provenance")
}
return client.LookupGoalProvenance(ctx, input)
}
func (r Reporter) Report(ctx context.Context, input agentsessionstore.ReportActivityInput) error {
if len(input.TimelineItems) == 0 && len(input.StatePatches) == 0 && len(input.MessageUpdates) == 0 && len(input.SessionAudits) == 0 && len(input.GoalReconcileRequests) == 0 {
return nil
}
input.Source.SessionOrigin = agentsessionstore.WorkspaceAgentSessionOriginRuntime
if input.Connector == nil && strings.TrimSpace(input.Source.Provider) != "" {
input.Connector = &canonical.ConnectorInfo{
ID: strings.TrimSpace(input.Source.Provider),
Version: "agent-gui-runtime",
}
}
if r.ClientProvider == nil {
err := errors.New("agent session activity client provider is nil")
r.logReportFailure(input, 1, 1, agentsessionstore.ReportActivityReply{}, err)
return err
}
timelineItemsForLog, statePatchesForLog := SummarizeReportActivityInputForLog(input)
r.logger().Debug(
"agent session activity report prepared",
"event", "agent_session.activity_report.prepared",
"room_id", input.WorkspaceID,
"agent_session_id", input.Source.AgentID,
"provider", input.Source.Provider,
"provider_session_id", input.Source.ProviderSessionID,
"timeline_item_count", len(input.TimelineItems),
"state_patch_count", len(input.StatePatches),
"message_update_count", len(input.MessageUpdates),
"session_audit_count", len(input.SessionAudits),
"timeline_items", timelineItemsForLog,
"state_patches", statePatchesForLog,
)
maxAttempts := r.maxAttempts()
var lastErr error
var lastReply agentsessionstore.ReportActivityReply
lastAttempt := 0
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastAttempt = attempt
client := r.ClientProvider()
if client == nil {
lastErr = errors.New("agent session activity client is nil")
} else {
lastReply, lastErr = reportSessionActivity(ctx, client, input)
if lastErr == nil {
lastErr = validateReportActivityAccepted(input, lastReply)
}
}
if lastErr == nil {
if attempt > 1 {
r.logger().Info(
"agent session activity report succeeded after retry",
"event", "agent_session.activity_report.succeeded_after_retry",
"room_id", input.WorkspaceID,
"agent_session_id", input.Source.AgentID,
"provider", input.Source.Provider,
"provider_session_id", input.Source.ProviderSessionID,
"timeline_item_count", len(input.TimelineItems),
"state_patch_count", len(input.StatePatches),
"message_update_count", len(input.MessageUpdates),
"timeline_items", timelineItemsForLog,
"state_patches", statePatchesForLog,
"accepted_timeline_item_count", lastReply.AcceptedTimelineItemCount,
"accepted_state_patch_count", lastReply.AcceptedStatePatchCount,
"accepted_message_update_count", lastReply.AcceptedMessageUpdateCount,
"attempt", attempt,
"max_attempts", maxAttempts,
)
}
r.logger().Debug(
"agent session activity report succeeded",
"event", "agent_session.activity_report.succeeded",
"room_id", input.WorkspaceID,
"agent_session_id", input.Source.AgentID,
"provider", input.Source.Provider,
"provider_session_id", input.Source.ProviderSessionID,
"timeline_item_count", len(input.TimelineItems),
"state_patch_count", len(input.StatePatches),
"message_update_count", len(input.MessageUpdates),
"timeline_items", timelineItemsForLog,
"state_patches", statePatchesForLog,
"accepted_timeline_item_count", lastReply.AcceptedTimelineItemCount,
"accepted_state_patch_count", lastReply.AcceptedStatePatchCount,
"accepted_message_update_count", lastReply.AcceptedMessageUpdateCount,
"attempt", attempt,
"max_attempts", maxAttempts,
)
return nil
}
if attempt >= maxAttempts {
break
}
r.logger().Warn(
"agent session activity report failed; retrying",
"event", "agent_session.activity_report.retry",
"room_id", input.WorkspaceID,
"agent_session_id", input.Source.AgentID,
"provider", input.Source.Provider,
"provider_session_id", input.Source.ProviderSessionID,
"timeline_item_count", len(input.TimelineItems),
"state_patch_count", len(input.StatePatches),
"message_update_count", len(input.MessageUpdates),
"timeline_items", timelineItemsForLog,
"state_patches", statePatchesForLog,
"accepted_timeline_item_count", lastReply.AcceptedTimelineItemCount,
"accepted_state_patch_count", lastReply.AcceptedStatePatchCount,
"accepted_message_update_count", lastReply.AcceptedMessageUpdateCount,
"attempt", attempt,
"max_attempts", maxAttempts,
"error", lastErr,
)
if err := sleepWithContext(ctx, r.backoffForAttempt(attempt)); err != nil {
lastErr = fmt.Errorf("agent session activity report retry canceled after attempt %d: %w", attempt, err)
break
}
}
r.logReportFailure(input, lastAttempt, maxAttempts, lastReply, lastErr)
return lastErr
}
func reportSessionActivity(
ctx context.Context,
client ActivityClient,
input agentsessionstore.ReportActivityInput,
) (agentsessionstore.ReportActivityReply, error) {
return agentsessionstore.ReportActivityAsSessionUpdates(ctx, client, input)
}
func validateReportActivityAccepted(input agentsessionstore.ReportActivityInput, reply agentsessionstore.ReportActivityReply) error {
if reply.AcceptedStatePatchCount < len(input.StatePatches) {
return fmt.Errorf("agent session activity report accepted %d/%d state patches", reply.AcceptedStatePatchCount, len(input.StatePatches))
}
if reply.AcceptedMessageUpdateCount < len(input.MessageUpdates) {
return fmt.Errorf("agent session activity report accepted %d/%d message updates", reply.AcceptedMessageUpdateCount, len(input.MessageUpdates))
}
if reply.AcceptedSessionAuditCount < len(input.SessionAudits) {
return fmt.Errorf("agent session activity report accepted %d/%d session audits", reply.AcceptedSessionAuditCount, len(input.SessionAudits))
}
if reply.AcceptedGoalReconcileRequestCount < len(input.GoalReconcileRequests) {
return fmt.Errorf("agent session activity report accepted %d/%d goal reconcile requests", reply.AcceptedGoalReconcileRequestCount, len(input.GoalReconcileRequests))
}
return nil
}
func reportActivityInput(session Session, events []activityshared.Event) agentsessionstore.ReportActivityInput {
activityEvents := ReportableActivityEvents(events)
source := eventSourceFromSession(session)
input := agentsessionstore.ReportActivityInput{
WorkspaceID: session.RoomID,
Connector: &canonical.ConnectorInfo{
ID: session.Provider,
Version: "agent-gui-runtime",
},
Source: source,
}
now := time.Now().UnixMilli()
for _, event := range events {
appendProviderObservation(&input, event)
sessionID := firstNonEmptyString(event.AgentSessionID, source.AgentID, event.ProviderSessionID, source.ProviderSessionID)
if sessionID == "" {
continue
}
timestamp := event.OccurredAtUnixMS
if timestamp <= 0 {
timestamp = now
}
if update, ok := messageUpdateFromSessionEvent(source, event, sessionID, timestamp); ok {
input.MessageUpdates = append(input.MessageUpdates, update)
}
if audit, ok := sessionAuditUpdateFromSessionEvent(event, sessionID, timestamp); ok {
input.SessionAudits = append(input.SessionAudits, audit)
}
if request, ok := goalReconcileRequestFromSessionEvent(event, sessionID); ok {
input.GoalReconcileRequests = append(input.GoalReconcileRequests, request)
}
if shouldAppendVisibleFailure(events, event) {
if audit, ok := visibleFailureSessionAuditUpdate(source, event, sessionID, timestamp); ok {
input.SessionAudits = append(input.SessionAudits, audit)
} else if update, ok := visibleFailureMessageUpdate(source, event, sessionID, timestamp); ok {
input.MessageUpdates = append(input.MessageUpdates, update)
}
}
}
for _, event := range activityEvents {
sessionID := firstNonEmptyString(event.AgentSessionID, source.AgentID, event.ProviderSessionID, source.ProviderSessionID)
if sessionID == "" {
continue
}
timestamp := event.OccurredAtUnixMS
if timestamp <= 0 {
timestamp = now
}
if patch, ok := statePatchFromSessionEvent(source, event, sessionID, timestamp); ok {
input.StatePatches = append(input.StatePatches, patch)
}
}
return input
}
func appendProviderObservation(
input *agentsessionstore.ReportActivityInput,
event activityshared.Event,
) {
position := event.ProviderInputUnit
if input == nil || position == nil || position.ConnectionID == "" ||
position.ChunkSeq == 0 || position.UnitIndex == 0 ||
position.EventIndex == 0 || !checkpointObservationEvent(event) {
return
}
batchIndex := -1
for index := range input.ProviderObservations {
candidate := input.ProviderObservations[index]
if candidate.RecordingID == position.RecordingID &&
candidate.ConnectionID == position.ConnectionID &&
candidate.ChunkSeq == position.ChunkSeq &&
candidate.UnitIndex == position.UnitIndex {
batchIndex = index
break
}
}
if batchIndex < 0 {
input.ProviderObservations = append(
input.ProviderObservations,
replay.ProviderObservationBatch{
RecordingID: position.RecordingID,
ConnectionID: position.ConnectionID,
ChunkSeq: position.ChunkSeq,
UnitIndex: position.UnitIndex,
UnitKind: position.UnitKind,
},
)
batchIndex = len(input.ProviderObservations) - 1
}
interactionID := ""
interactionKind := ""
if interaction := event.Payload.Interaction; interaction != nil {
interactionID = strings.TrimSpace(interaction.RequestID)
interactionKind = strings.TrimSpace(interaction.Kind)
}
observationType := string(event.Type)
messageKind, _ := event.Payload.Metadata["messageKind"].(string)
if isMessageActivityEvent(event.Type) &&
strings.TrimSpace(messageKind) == "plan" {
observationType = "plan.proposed"
}
messageID := strings.TrimSpace(payloadString(
event.Payload.Metadata,
"messageId",
))
if messageID == "" {
messageID = strings.TrimSpace(event.EventID)
}
messageStatus := strings.TrimSpace(payloadString(
event.Payload.Metadata,
"streamState",
))
noticeCommand := strings.TrimSpace(payloadString(
event.Payload.Metadata,
"noticeCommand",
))
noticeCommandStatus := strings.TrimSpace(payloadString(
event.Payload.Metadata,
"noticeCommandStatus",
))
attachmentCount := uint64(0)
for _, block := range payloadArray(event.Payload.Metadata["content"]) {
if strings.TrimSpace(payloadString(block, "attachmentId")) != "" {
attachmentCount++
}
}
if noticeCommand == "compact" {
observationType = "compaction.updated"
} else if attachmentCount > 0 {
observationType = "attachment.materialized"
}
input.ProviderObservations[batchIndex].Events = append(
input.ProviderObservations[batchIndex].Events,
replay.ProviderObservationEvent{
EventIndex: position.EventIndex,
Type: observationType,
AgentSessionID: strings.TrimSpace(event.AgentSessionID),
SessionKind: strings.TrimSpace(event.SessionKind),
RootAgentSessionID: strings.TrimSpace(event.RootAgentSessionID),
RootTurnID: strings.TrimSpace(event.RootTurnID),
ParentAgentSessionID: strings.TrimSpace(
event.ParentAgentSessionID,
),
ParentTurnID: strings.TrimSpace(event.ParentTurnID),
ParentToolCallID: strings.TrimSpace(
event.ParentToolCallID,
),
TurnID: strings.TrimSpace(event.Payload.TurnID),
MessageID: messageID,
MessageKind: strings.TrimSpace(messageKind),
NoticeCommand: noticeCommand,
NoticeCommandStatus: noticeCommandStatus,
AttachmentCount: attachmentCount,
CallID: strings.TrimSpace(event.Payload.CallID),
InteractionID: interactionID,
InteractionKind: interactionKind,
TurnPhase: strings.TrimSpace(event.Payload.TurnPhase),
TurnOutcome: strings.TrimSpace(event.Payload.TurnOutcome),
Status: firstNonEmptyString(
event.Payload.Status,
messageStatus,
),
},
)
}
func checkpointObservationEvent(event activityshared.Event) bool {
if isMessageActivityEvent(event.Type) {
messageKind, _ := event.Payload.Metadata["messageKind"].(string)
return strings.TrimSpace(messageKind) == "plan" ||
strings.TrimSpace(payloadString(
event.Payload.Metadata,
"noticeCommand",
)) == "compact" ||
providerObservationAttachmentCount(event) > 0
}
switch event.Type {
case activityshared.EventSessionStarted,
activityshared.EventSessionUpdated,
activityshared.EventSessionCompleted,
activityshared.EventSessionFailed,
activityshared.EventRootProviderTurnStarted,
activityshared.EventRootProviderTurnCompleted,
activityshared.EventTurnStarted,
activityshared.EventTurnUpdated,
activityshared.EventTurnCompleted,
activityshared.EventTurnFailed,
activityshared.EventTurnCanceled,
activityshared.EventCallCompleted,
activityshared.EventCallFailed,
activityshared.EventInteractionRequested,
activityshared.EventInteractionSuperseded:
return true
case activityshared.EventCallStarted:
// commandExecution/outputDelta reuses call.started for live tool
// output. Claude tool_updated likewise reuses call.started for input
// /progress streaming. Those frames must not mint another tool.started
// checkpoint (no matching commit → checkpoint_commit_unconfirmed).
if event.Payload.Metadata != nil {
if _, ok := event.Payload.Metadata[liveToolOutputOperationMetadataKey]; ok {
return false
}
if _, ok := event.Payload.Metadata[liveToolProgressMetadataKey]; ok {
return false
}
}
return true
default:
return false
}
}
func providerObservationAttachmentCount(event activityshared.Event) uint64 {
var count uint64
for _, block := range payloadArray(event.Payload.Metadata["content"]) {
if strings.TrimSpace(payloadString(block, "attachmentId")) != "" {
count++
}
}
return count
}
func isMessageActivityEvent(eventType activityshared.EventType) bool {
return eventType == activityshared.EventMessageAppended ||
eventType == activityshared.EventMessageCreated
}
func (r Reporter) maxAttempts() int {
if r.MaxAttempts > 0 {
return r.MaxAttempts
}
return 3
}
func (r Reporter) backoffForAttempt(attempt int) time.Duration {
index := attempt - 1
if index >= 0 && index < len(r.Backoff) {
return r.Backoff[index]
}
if index >= 0 && index < len(defaultReportRetryBackoff) {
return defaultReportRetryBackoff[index]
}
return defaultReportRetryBackoff[len(defaultReportRetryBackoff)-1]
}
func (r Reporter) logger() *slog.Logger {
if r.Logger != nil {
return r.Logger
}
return slog.Default()
}
func (r Reporter) logReportFailure(input agentsessionstore.ReportActivityInput, attempt int, maxAttempts int, reply agentsessionstore.ReportActivityReply, err error) {
timelineItemsForLog, statePatchesForLog := SummarizeReportActivityInputForLog(input)
r.logger().Error(
"agent session activity report failed after retries",
"event", "agent_session.activity_report.failed",
"room_id", input.WorkspaceID,
"agent_session_id", input.Source.AgentID,
"provider", input.Source.Provider,
"provider_session_id", input.Source.ProviderSessionID,
"timeline_item_count", len(input.TimelineItems),
"state_patch_count", len(input.StatePatches),
"message_update_count", len(input.MessageUpdates),
"timeline_items", timelineItemsForLog,
"state_patches", statePatchesForLog,
"accepted_timeline_item_count", reply.AcceptedTimelineItemCount,
"accepted_state_patch_count", reply.AcceptedStatePatchCount,
"accepted_message_update_count", reply.AcceptedMessageUpdateCount,
"attempt", attempt,
"max_attempts", maxAttempts,
"error", err,
)
}
func sleepWithContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return ctx.Err()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}