Files
tutti/packages/agent/daemon/runtime/codex_appserver_event_routing.go
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

641 lines
20 KiB
Go

package agentruntime
import (
"log/slog"
"strings"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
func (a *CodexAppServerAdapter) appServerNotificationRoute(
session Session,
rootTurnID string,
method string,
params map[string]any,
) appServerNotificationRoute {
parentThreadID := strings.TrimSpace(session.ProviderSessionID)
eventThreadID := strings.TrimSpace(asString(params["threadId"]))
if parentThreadID == "" || eventThreadID == "" || eventThreadID == parentThreadID {
rootTurnID = firstNonEmpty(strings.TrimSpace(rootTurnID), a.sessionMarkerTurnID(session.AgentSessionID))
added, events := a.rememberAppServerChildThreads(
session,
parentThreadID,
session.AgentSessionID,
rootTurnID,
session.AgentSessionID,
rootTurnID,
payloadObject(params["item"]),
)
if len(added) > 0 {
a.scheduleChildNicknameFetches(session, added)
}
return appServerNotificationRoute{
events: events,
registeredChildCount: len(added),
}
}
child, ok := a.appServerChildThread(session.AgentSessionID, eventThreadID)
if !ok {
if a.canceledProviderThread(session.AgentSessionID, eventThreadID) {
if method == appServerNotifyTurnStarted {
providerTurnID := strings.TrimSpace(asString(payloadObject(params["turn"])["id"]))
if appSession := a.getSession(session.AgentSessionID); appSession != nil && appSession.client != nil {
go a.sendThreadInterrupt(appSession.client, session, eventThreadID, providerTurnID, "root turn canceled")
}
}
return appServerNotificationRoute{drop: true}
}
a.recordForeignThreadDrop(session.AgentSessionID, eventThreadID)
a.bufferForeignThreadTerminal(session.AgentSessionID, eventThreadID, method, params)
a.logAppServerForeignThreadDrop(session, method, params, eventThreadID)
return appServerNotificationRoute{drop: true}
}
eventSession := appServerChildSession(session, eventThreadID, child)
added, prefixEvents := a.rememberAppServerChildThreads(
eventSession,
eventThreadID,
child.rootAgentSessionID,
child.rootTurnID,
child.agentSessionID,
child.turnID,
payloadObject(params["item"]),
)
if len(added) > 0 {
a.scheduleChildNicknameFetches(session, added)
}
if terminalEvents := appServerChildTerminalEvents(eventSession, child, method, params); len(terminalEvents) > 0 {
terminalEvents = appServerEventsForChild(terminalEvents, child)
return appServerNotificationRoute{events: append(prefixEvents, terminalEvents...), drop: true}
}
if appServerSuppressChildNotification(method) {
return appServerNotificationRoute{events: prefixEvents, drop: true}
}
if child.normalizer == nil {
child.normalizer = newACPTurnNormalizer()
a.storeAppServerChildThread(session.AgentSessionID, eventThreadID, child)
}
return appServerNotificationRoute{
session: eventSession,
child: child,
turnID: child.turnID,
normalizer: child.normalizer,
events: prefixEvents,
registeredChildCount: len(added),
}
}
func appServerEventsForChild(events []activityshared.Event, child *codexAppServerThreadContext) []activityshared.Event {
if child == nil {
return events
}
for index := range events {
events[index].SessionKind = "child"
events[index].RootAgentSessionID = child.rootAgentSessionID
events[index].RootTurnID = child.rootTurnID
events[index].ParentAgentSessionID = child.parentAgentSessionID
events[index].ParentTurnID = child.parentTurnID
events[index].ParentToolCallID = child.parentItemID
}
return events
}
const appServerForeignDropTrackerCap = 64
const appServerForeignTerminalBufferCap = 64
// recordForeignThreadDrop remembers an unknown-thread arrival so a later child
// registration can report the announce/stream ordering gap (ADR 0003
// verification telemetry). Ordinary progress remains dropped; terminal
// notifications are buffered separately. Bounded; unrelated foreign threads
// age out by never being registered.
func (a *CodexAppServerAdapter) recordForeignThreadDrop(agentSessionID string, threadID string) {
if a == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil {
return
}
if appSession.recentForeignDrops == nil {
appSession.recentForeignDrops = make(map[string]int)
}
if len(appSession.recentForeignDrops) >= appServerForeignDropTrackerCap {
if _, tracked := appSession.recentForeignDrops[threadID]; !tracked {
return
}
}
appSession.recentForeignDrops[threadID]++
}
func (a *CodexAppServerAdapter) bufferForeignThreadTerminal(
agentSessionID string,
threadID string,
method string,
params map[string]any,
) {
if a == nil || !appServerIsForeignTerminalNotification(method, params) {
return
}
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil {
return
}
if appSession.pendingForeignTerminalNotifications == nil {
appSession.pendingForeignTerminalNotifications = make(map[string]appServerBufferedNotification)
}
if len(appSession.pendingForeignTerminalNotifications) >= appServerForeignTerminalBufferCap {
if _, tracked := appSession.pendingForeignTerminalNotifications[threadID]; !tracked {
return
}
}
if existing, tracked := appSession.pendingForeignTerminalNotifications[threadID]; tracked {
if existing.method == appServerNotifyTurnCompleted || method != appServerNotifyTurnCompleted {
return
}
}
appSession.pendingForeignTerminalNotifications[threadID] = appServerBufferedNotification{
method: method,
params: clonePayload(params),
}
}
func appServerIsForeignTerminalNotification(method string, params map[string]any) bool {
switch method {
case appServerNotifyTurnCompleted:
return true
case appServerNotifyError:
willRetry, _ := params["willRetry"].(bool)
return !willRetry
default:
return false
}
}
func (a *CodexAppServerAdapter) rememberAppServerChildThreads(
session Session,
parentThreadID string,
rootAgentSessionID string,
rootTurnID string,
parentAgentSessionID string,
parentTurnID string,
item map[string]any,
) ([]string, []activityshared.Event) {
spawn, ok := appServerChildSpawnEvidenceFromItem(item)
if !ok {
return nil, nil
}
childThreadIDs := spawn.childThreadIDs
parentThreadID = strings.TrimSpace(parentThreadID)
parentItemID := spawn.parentItemID
rootAgentSessionID = strings.TrimSpace(rootAgentSessionID)
rootTurnID = strings.TrimSpace(rootTurnID)
parentAgentSessionID = strings.TrimSpace(parentAgentSessionID)
parentTurnID = strings.TrimSpace(parentTurnID)
if parentItemID == "" || rootAgentSessionID == "" || rootTurnID == "" || parentAgentSessionID == "" || parentTurnID == "" {
return nil, nil
}
a.mu.Lock()
appSession := a.sessions[rootAgentSessionID]
if appSession == nil {
a.mu.Unlock()
return nil, nil
}
if strings.TrimSpace(appSession.canceledRootTurnID) != "" {
if appSession.canceledProviderThreads == nil {
appSession.canceledProviderThreads = make(map[string]struct{})
}
lateChildThreadIDs := make([]string, 0, len(childThreadIDs))
for _, childThreadID := range childThreadIDs {
if childThreadID == "" || childThreadID == parentThreadID {
continue
}
appSession.canceledProviderThreads[childThreadID] = struct{}{}
lateChildThreadIDs = append(lateChildThreadIDs, childThreadID)
}
client := appSession.client
canceledRootTurnID := appSession.canceledRootTurnID
a.mu.Unlock()
cancelSession := session
cancelSession.AgentSessionID = rootAgentSessionID
for _, childThreadID := range lateChildThreadIDs {
if client != nil {
go a.sendThreadInterrupt(client, cancelSession, childThreadID, "", "root turn canceled")
}
}
if len(lateChildThreadIDs) > 0 {
slog.Warn(
"agent session app-server interrupting child threads discovered after root cancellation",
"event", "agent_session.app_server.child.late_after_cancel",
"agent_session_id", rootAgentSessionID,
"root_turn_id", canceledRootTurnID,
"child_thread_count", len(lateChildThreadIDs),
)
}
return nil, nil
}
if appSession.childThreads == nil {
appSession.childThreads = make(map[string]*codexAppServerThreadContext)
}
added := make([]string, 0, len(childThreadIDs))
addedContexts := make([]*codexAppServerThreadContext, 0, len(childThreadIDs))
bufferedTerminals := make(map[string]appServerBufferedNotification)
for _, childThreadID := range childThreadIDs {
if childThreadID == "" || childThreadID == parentThreadID {
continue
}
if existing := appSession.childThreads[childThreadID]; existing != nil {
continue
}
context := &codexAppServerThreadContext{
agentSessionID: newID(),
turnID: newID(),
rootAgentSessionID: rootAgentSessionID,
rootTurnID: rootTurnID,
parentAgentSessionID: parentAgentSessionID,
parentTurnID: parentTurnID,
parentThreadID: parentThreadID,
parentItemID: parentItemID,
normalizer: newACPTurnNormalizer(),
}
if dropped := appSession.recentForeignDrops[childThreadID]; dropped > 0 {
context.droppedBeforeRegistration = dropped
delete(appSession.recentForeignDrops, childThreadID)
slog.Warn(
"agent session app-server child events arrived before registration",
"agent_session_id", rootAgentSessionID,
"child_thread_id", childThreadID,
"dropped_events", dropped,
)
}
appSession.childThreads[childThreadID] = context
if buffered, ok := appSession.pendingForeignTerminalNotifications[childThreadID]; ok {
bufferedTerminals[childThreadID] = buffered
delete(appSession.pendingForeignTerminalNotifications, childThreadID)
}
added = append(added, childThreadID)
addedContexts = append(addedContexts, context)
}
a.mu.Unlock()
events := make([]activityshared.Event, 0, len(addedContexts))
for index, child := range addedContexts {
childSession := appServerChildSession(session, added[index], child)
if event := appServerChildStartedEvent(childSession, child); event.Type != "" {
events = append(events, event)
}
if buffered, ok := bufferedTerminals[added[index]]; ok {
terminalEvents := appServerChildTerminalEvents(childSession, child, buffered.method, buffered.params)
events = append(events, appServerEventsForChild(terminalEvents, child)...)
}
}
return added, events
}
type appServerChildSpawnEvidence struct {
parentItemID string
childThreadIDs []string
}
func appServerChildSpawnEvidenceFromItem(item map[string]any) (appServerChildSpawnEvidence, bool) {
parentItemID := strings.TrimSpace(asString(item["id"]))
if parentItemID == "" {
return appServerChildSpawnEvidence{}, false
}
switch asString(item["type"]) {
case "collabAgentToolCall":
// Wait/close cards reference existing children but are not delegation
// edges. A durable child is created only from a spawn-shaped card that
// supplies its immutable parent tool-call id.
if appServerAgentControlToolName(asString(item["tool"])) != "" {
return appServerChildSpawnEvidence{}, false
}
childThreadIDs := appServerReceiverThreadIDs(item["receiverThreadIds"])
if len(childThreadIDs) == 0 {
return appServerChildSpawnEvidence{}, false
}
return appServerChildSpawnEvidence{
parentItemID: parentItemID,
childThreadIDs: childThreadIDs,
}, true
case "subAgentActivity":
if asString(item["kind"]) != "started" {
return appServerChildSpawnEvidence{}, false
}
childThreadID := strings.TrimSpace(asString(item["agentThreadId"]))
if childThreadID == "" {
return appServerChildSpawnEvidence{}, false
}
return appServerChildSpawnEvidence{
parentItemID: parentItemID,
childThreadIDs: []string{childThreadID},
}, true
default:
return appServerChildSpawnEvidence{}, false
}
}
func (a *CodexAppServerAdapter) appServerChildThread(agentSessionID string, childThreadID string) (*codexAppServerThreadContext, bool) {
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil || appSession.childThreads == nil {
return nil, false
}
child := appSession.childThreads[strings.TrimSpace(childThreadID)]
if child == nil {
return nil, false
}
return &codexAppServerThreadContext{
agentSessionID: child.agentSessionID,
turnID: child.turnID,
rootAgentSessionID: child.rootAgentSessionID,
rootTurnID: child.rootTurnID,
parentAgentSessionID: child.parentAgentSessionID,
parentTurnID: child.parentTurnID,
parentThreadID: child.parentThreadID,
parentItemID: child.parentItemID,
normalizer: child.normalizer,
droppedBeforeRegistration: child.droppedBeforeRegistration,
}, true
}
func (a *CodexAppServerAdapter) storeAppServerChildThread(
agentSessionID string,
childThreadID string,
child *codexAppServerThreadContext,
) {
if child == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil {
return
}
if appSession.childThreads == nil {
appSession.childThreads = make(map[string]*codexAppServerThreadContext)
}
appSession.childThreads[strings.TrimSpace(childThreadID)] = child
}
func appServerSuppressChildNotification(method string) bool {
switch method {
case appServerNotifyThreadStarted,
appServerNotifyThreadSettingsUpdated,
appServerNotifyThreadNameUpdated,
appServerNotifyThreadCompacted,
appServerNotifyThreadGoalUpdated,
appServerNotifyThreadGoalCleared,
appServerNotifyTurnStarted,
appServerNotifyTurnCompleted,
// A child's error must never reach failActiveTurnFromAppServerError on
// the parent session: with an empty parent activeTurnID (wildcard
// match) it would fail the parent's running turn. Child failures reach
// the transcript through the parent's collabAgentToolCall item.
appServerNotifyError,
appServerNotifyPlanUpdated,
appServerNotifyTokenUsage,
appServerNotifyRateLimitsUpdated,
appServerNotifyAccountUpdated:
return true
default:
return false
}
}
func appServerChildSession(root Session, providerThreadID string, child *codexAppServerThreadContext) Session {
if child == nil {
return Session{}
}
root.AgentSessionID = child.agentSessionID
root.ProviderSessionID = strings.TrimSpace(providerThreadID)
root.Title = ""
root.Status = SessionStatusWorking
root.TurnLifecycle = nil
root.SubmitAvailability = blockedSubmitAvailability("active_turn")
return root
}
func appServerChildEventContext(
session Session,
child *codexAppServerThreadContext,
eventID string,
) (activityshared.EventContext, bool) {
if child == nil {
return activityshared.EventContext{}, false
}
ctx, ok := activityEventContext(session, eventID, child.turnID)
if !ok {
return activityshared.EventContext{}, false
}
ctx.SessionKind = "child"
ctx.RootAgentSessionID = child.rootAgentSessionID
ctx.RootTurnID = child.rootTurnID
ctx.ParentAgentSessionID = child.parentAgentSessionID
ctx.ParentTurnID = child.parentTurnID
ctx.ParentToolCallID = child.parentItemID
return ctx, true
}
func appServerChildStartedEvent(session Session, child *codexAppServerThreadContext) activityshared.Event {
ctx, ok := appServerChildEventContext(session, child, "child-session-started:"+child.agentSessionID)
if !ok {
return activityshared.Event{}
}
return activityshared.NewChildSessionStarted(ctx, child.turnID)
}
func appServerChildTerminalEvents(
session Session,
child *codexAppServerThreadContext,
method string,
params map[string]any,
) []activityshared.Event {
ctx, ok := appServerChildEventContext(session, child, strings.TrimSpace(method)+":"+newID())
if !ok {
return nil
}
switch method {
case appServerNotifyTurnStarted:
return []activityshared.Event{activityshared.NewTurnStarted(ctx, child.turnID)}
case appServerNotifyThreadNameUpdated:
name := strings.TrimSpace(asString(params["threadName"]))
if name == "" {
return nil
}
ctx.Title = name
return []activityshared.Event{activityshared.NewSessionTitleUpdated(ctx)}
case appServerNotifyTurnCompleted:
turn := payloadObject(params["turn"])
status := appServerChildLifecycleStatus(asString(turn["status"]))
detail := appServerChildFailureDetail(turn)
return appServerChildSettledEvents(session, child, ctx, status, detail)
case appServerNotifyError:
if willRetry, _ := params["willRetry"].(bool); willRetry {
return nil
}
return appServerChildSettledEvents(
session,
child,
ctx,
"failed",
appServerChildFailureDetail(payloadObject(params["error"])),
)
default:
return nil
}
}
func appServerChildSettledEvents(
session Session,
child *codexAppServerThreadContext,
ctx activityshared.EventContext,
status string,
detail string,
) []activityshared.Event {
var events []activityshared.Event
if child.normalizer == nil {
child.normalizer = newACPTurnNormalizer()
}
switch status {
case "failed":
events = child.normalizer.FinishFailed(session, child.turnID)
terminal := activityshared.NewTurnFailed(ctx, child.turnID)
if detail != "" {
terminal.Payload.Metadata = map[string]any{"error": detail}
}
return append(events, terminal)
case "canceled":
events = child.normalizer.FinishInterrupted(session, child.turnID, "interrupted")
terminal := activityshared.NewTurnCanceled(ctx, child.turnID)
if detail != "" {
terminal.Payload.Metadata = map[string]any{"error": detail}
}
return append(events, terminal)
default:
events = child.normalizer.FinishCompleted(session, child.turnID)
return append(events, activityshared.NewTurnCompleted(ctx, child.turnID, activityshared.TurnOutcomeCompleted))
}
}
func appServerChildLifecycleStatus(status string) string {
switch strings.TrimSpace(strings.ToLower(status)) {
case "failed", "error", "errored":
return "failed"
case "canceled", "cancelled", "interrupted":
return "canceled"
default:
return "completed"
}
}
func appServerChildFailureDetail(payload map[string]any) string {
return firstNonEmpty(
asStringRaw(payload["message"]),
asStringRaw(payload["detail"]),
asStringRaw(payload["error"]),
asStringRaw(payload["reason"]),
)
}
func appServerReceiverThreadIDs(value any) []string {
values, ok := value.([]any)
if !ok {
if typed, ok := value.([]string); ok {
out := make([]string, 0, len(typed))
for _, item := range typed {
if trimmed := strings.TrimSpace(item); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
if threadID := strings.TrimSpace(asString(value)); threadID != "" {
out = append(out, threadID)
}
}
return out
}
func (*CodexAppServerAdapter) logAppServerForeignThreadDrop(
session Session,
method string,
params map[string]any,
eventThreadID string,
) {
expectedThreadID := strings.TrimSpace(session.ProviderSessionID)
item := payloadObject(params["item"])
slog.Debug(
"agent session app-server notification ignored for foreign thread",
"agent_session_id", session.AgentSessionID,
"provider_session_id", expectedThreadID,
"event_thread_id", eventThreadID,
"event_turn_id", asString(params["turnId"]),
"method", method,
"item_id", asString(item["id"]),
"item_type", asString(item["type"]),
"item_status", asString(item["status"]),
)
}
func appServerItemStatus(status string) string {
switch strings.TrimSpace(status) {
case "inProgress", "in_progress", "":
return "in_progress"
case "declined":
return "failed"
default:
return status
}
}
func appServerPlanUpdate(turnID string, params map[string]any) map[string]any {
steps, _ := params["plan"].([]any)
if len(steps) == 0 {
return nil
}
todos := make([]any, 0, len(steps))
for _, step := range steps {
entry := payloadObject(step)
text := asStringRaw(entry["step"])
if text == "" {
continue
}
todos = append(todos, map[string]any{
"content": text,
"status": appServerPlanStepStatus(asString(entry["status"])),
})
}
if len(todos) == 0 {
return nil
}
return map[string]any{
"sessionUpdate": "tool_call",
"toolCallId": "plan:" + strings.TrimSpace(turnID),
"title": "update_todo",
"kind": "think",
"status": "completed",
"rawInput": map[string]any{"todos": todos},
}
}
func appServerPlanStepStatus(status string) string {
switch strings.TrimSpace(status) {
case "inProgress", "in_progress":
return "in_progress"
case "completed":
return "completed"
default:
return "pending"
}
}