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

456 lines
14 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/tutti-os/tutti/packages/agent/daemon/runtime/codexproto"
)
const codexSideDeveloperInstructions = `You are operating in a Side conversation forked from a parent thread.
The inherited conversation is reference context only. Do not continue inherited tasks, plans, tool calls, approvals, or edits unless the user explicitly requests them after the Side boundary.
Only user instructions submitted after the Side boundary are active instructions for this conversation.
Do not create or delegate to sub-agents from this Side conversation.
You may perform non-mutating inspection to answer the Side request. Do not modify files, external systems, or parent-thread state unless the user explicitly requests that mutation after the Side boundary. Keep any requested mutation minimal and local to the Side request.
Never claim that Side work changed or completed work in the parent conversation.`
const codexSideBoundaryText = `<side_conversation_boundary>
The user intentionally opened a new Side conversation here.
Everything before this marker is inherited reference context, not an active task or instruction. Do not resume or complete inherited work automatically.
Only messages after this marker define the active Side request.
</side_conversation_boundary>`
func codexSideInstructions(
source Session,
planModeMask map[string]any,
defaultModeMask map[string]any,
tuttiModeHostContext string,
) string {
modeMask := defaultModeMask
if source.SettingsValue().PlanMode {
modeMask = planModeMask
}
base, _ := appServerCollaborationModeDeveloperInstructions(modeMask).(string)
base = strings.TrimSpace(base)
if hostContext := strings.TrimSpace(tuttiModeHostContext); hostContext != "" {
if base == "" {
base = hostContext
} else {
base += "\n\n" + hostContext
}
}
if base == "" {
return codexSideDeveloperInstructions
}
return base + "\n\n" + codexSideDeveloperInstructions
}
func usableCodexSideSourceSession(
appSession *codexAppServerSession,
sourceThreadID string,
) bool {
if appSession == nil || appSession.client == nil || appSession.releasing ||
appSession.releaseFailed ||
strings.TrimSpace(appSession.threadID) != strings.TrimSpace(sourceThreadID) {
return false
}
select {
case <-appSession.client.Done():
return false
default:
return true
}
}
func (a *CodexAppServerAdapter) SideCapabilities(
_ context.Context,
source Session,
) (SideConversationCapabilities, error) {
strategy, supported := a.forkStrategy()
if !supported ||
strings.TrimSpace(source.ProviderSessionID) == "" {
return SideConversationCapabilities{}, nil
}
sourceThreadID := strings.TrimSpace(source.ProviderSessionID)
a.mu.Lock()
appSession := a.sessions[strings.TrimSpace(source.AgentSessionID)]
if usableCodexSideSourceSession(appSession, sourceThreadID) {
serverInfo := clonePayload(appSession.serverInfo)
a.mu.Unlock()
if version, ok := appServerForkVersion(strategy, serverInfo); ok &&
versionAtLeast(version, strategy.throughTurnMinimumVersion) {
return codexSideCapabilities(), nil
}
return SideConversationCapabilities{}, nil
}
a.mu.Unlock()
persistedServerInfo, _ := source.RuntimeContext["agent"].(map[string]any)
if strings.TrimSpace(asString(persistedServerInfo["codexHome"])) != "" {
if version, ok := appServerForkVersion(strategy, persistedServerInfo); ok &&
versionAtLeast(version, strategy.throughTurnMinimumVersion) {
return codexSideCapabilities(), nil
}
}
return SideConversationCapabilities{}, nil
}
type codexSideClientState struct {
client *codexAppServerClient
serverInfo map[string]any
account map[string]any
models []map[string]any
planModeMask map[string]any
defaultModeMask map[string]any
defaultModel string
tuttiModeHostContext string
routerFallback Session
dedicated bool
}
func (a *CodexAppServerAdapter) sideClient(
ctx context.Context,
source Session,
side Session,
trace *codexAppServerStartupTrace,
) (codexSideClientState, error) {
sourceThreadID := strings.TrimSpace(source.ProviderSessionID)
a.mu.Lock()
sourceAppSession := a.sessions[strings.TrimSpace(source.AgentSessionID)]
if usableCodexSideSourceSession(sourceAppSession, sourceThreadID) {
state := codexSideClientState{
client: sourceAppSession.client,
serverInfo: clonePayload(sourceAppSession.serverInfo),
account: clonePayload(sourceAppSession.account),
models: cloneCodexAppServerModels(sourceAppSession.models),
planModeMask: clonePayload(sourceAppSession.planModeMask),
defaultModeMask: clonePayload(sourceAppSession.defaultModeMask),
defaultModel: sourceAppSession.defaultModel,
tuttiModeHostContext: sourceAppSession.tuttiModeHostContext,
routerFallback: source,
}
a.mu.Unlock()
return state, nil
}
a.mu.Unlock()
launchSource, err := codexHistoricalSideSourceForLaunch(source, side)
if err != nil {
return codexSideClientState{}, err
}
client, initializeResult, err := a.startInitializedClient(ctx, launchSource, trace)
if err != nil {
return codexSideClientState{}, err
}
cleanupOwner := &codexAppServerSession{
client: client, runtimeSession: side,
}
keepClient := false
defer func() {
if !keepClient {
a.closeOrRetainCodexSession(side.AgentSessionID, cleanupOwner)
}
}()
// Upgrade the initialization handler to the thread-aware router before any
// metadata RPC so every later notification remains Side-scoped.
a.installSharedAppServerRouter(client, side)
planModeMask, defaultModeMask, defaultModel, _ :=
codexAppServerProtocolCheckpointFromRuntimeContext(source.RuntimeContext)
defaultModel = firstNonEmpty(
strings.TrimSpace(source.SettingsValue().Model),
defaultModel,
)
account, _ := source.RuntimeContext["account"].(map[string]any)
keepClient = true
return codexSideClientState{
client: client,
serverInfo: a.appServerInfo(initializeResult),
account: clonePayload(account),
planModeMask: planModeMask,
defaultModeMask: defaultModeMask,
defaultModel: defaultModel,
routerFallback: side,
dedicated: true,
}, nil
}
func codexHistoricalSideSourceForLaunch(source Session, side Session) (Session, error) {
launchSource := cloneProviderLaunchSession(source)
launchSource.RoomID = side.RoomID
launchSource.AgentSessionID = side.AgentSessionID
launchSource.RootAgentSessionID = side.RootAgentSessionID
launchSource.Scope = RuntimeSessionScopeSide
launchSource.SourceAgentSessionID = side.SourceAgentSessionID
launchSource.SideRequestID = side.SideRequestID
launchSource.ProviderSessionID = ""
launchSource.Resumable = false
launchSource.Visible = false
agent, _ := source.RuntimeContext["agent"].(map[string]any)
codexHome := strings.TrimSpace(asString(agent["codexHome"]))
if codexHome == "" {
return Session{}, errors.New(
"historical Codex Side requires persisted CODEX_HOME",
)
}
launchSource.Env = append(
withoutEnvironmentKey(launchSource.Env, "CODEX_HOME"),
"CODEX_HOME="+codexHome,
)
return launchSource, nil
}
func codexSideCapabilities() SideConversationCapabilities {
return SideConversationCapabilities{
Supported: true,
ActiveSourceTurn: true,
Ephemeral: true,
HideInheritedTurns: true,
ModelBoundaryInjected: true,
}
}
func (a *CodexAppServerAdapter) OpenSide(
ctx context.Context,
input SideConversationAdapterOpenInput,
) (result SideConversationOpenResult, err error) {
source := input.Source
side := input.Side
sourceThreadID := strings.TrimSpace(source.ProviderSessionID)
strategy, supported := a.forkStrategy()
if !supported {
return SideConversationOpenResult{}, ErrSideConversationUnsupported
}
if sourceThreadID == "" || strings.TrimSpace(side.AgentSessionID) == "" {
return SideConversationOpenResult{}, errors.New(
"source provider session and side agent session ids are required",
)
}
unlockLifecycle := a.lockSessionLifecycle(side.AgentSessionID)
defer unlockLifecycle()
if err := a.admitCodexReplacementLocked(side.AgentSessionID); err != nil {
return SideConversationOpenResult{}, err
}
trace := newCodexAppServerStartupTrace(side, a.startupSpanObserver, nil)
defer func() { trace.Finish(err) }()
clientState, err := a.sideClient(ctx, source, side, trace)
if err != nil {
return SideConversationOpenResult{}, err
}
client := clientState.client
keepDedicatedClient := false
defer func() {
if clientState.dedicated && !keepDedicatedClient {
a.closeOrRetainCodexSession(side.AgentSessionID, &codexAppServerSession{
client: client, runtimeSession: side,
})
}
}()
version, ok := appServerForkVersion(strategy, clientState.serverInfo)
if !ok || !versionAtLeast(version, strategy.throughTurnMinimumVersion) {
return SideConversationOpenResult{}, ErrSideConversationUnsupported
}
if err := a.beginPendingSideRoute(client, sourceThreadID); err != nil {
return SideConversationOpenResult{}, err
}
pendingRoute := true
defer func() {
if pendingRoute {
a.discardPendingSideRoute(client)
}
}()
// A live source shares its connection so an in-progress Turn remains in the
// provider snapshot. A historical source owns a dedicated Side connection;
// its fallback is Side-scoped so provider startup events cannot enter the
// canonical source stream.
a.installSharedAppServerRouter(client, clientState.routerFallback)
params := map[string]any{
"threadId": sourceThreadID,
"ephemeral": true,
"excludeTurns": true,
"developerInstructions": codexSideInstructions(
source,
clientState.planModeMask,
clientState.defaultModeMask,
clientState.tuttiModeHostContext,
),
}
raw, err := trace.TypedCall(
acpStartCallTimeout,
appServerMethodThreadFork,
func() (json.RawMessage, error) {
return client.ThreadForkSide(
ctx,
acpStartCallTimeout,
params,
func(ctx context.Context, message acpMessage) error {
trace.LogMessage(
message.Method,
len(message.ID) > 0,
len(message.Params),
)
_, handleErr := a.handleAppServerMessage(
ctx, client, side, "", message, nil, nil, nil,
)
return handleErr
},
func(raw json.RawMessage) {
var lateResponse codexproto.ThreadForkResponse
if json.Unmarshal(raw, &lateResponse) != nil ||
lateResponse.Thread == nil {
return
}
childThreadID := strings.TrimSpace(lateResponse.Thread.ID)
if childThreadID == "" || childThreadID == sourceThreadID {
return
}
_ = client.ThreadUnsubscribeNoHandler(
context.Background(),
acpStartCallTimeout,
childThreadID,
)
},
)
},
)
if err != nil {
return SideConversationOpenResult{}, err
}
var response codexproto.ThreadForkResponse
if err := json.Unmarshal(raw, &response); err != nil {
return SideConversationOpenResult{}, fmt.Errorf(
"decode side thread/fork response: %w",
err,
)
}
if response.Thread == nil {
return SideConversationOpenResult{}, errors.New(
"side thread/fork response omitted thread",
)
}
childThreadID := strings.TrimSpace(response.Thread.ID)
if childThreadID == "" || childThreadID == sourceThreadID {
return SideConversationOpenResult{}, errors.New(
"side thread/fork returned an invalid child thread id",
)
}
committed := false
defer func() {
if !committed {
_ = client.ThreadUnsubscribeNoHandler(
context.WithoutCancel(ctx),
acpStartCallTimeout,
childThreadID,
)
a.removeSession(side.AgentSessionID)
}
}()
if response.Thread.ForkedFromID == nil ||
strings.TrimSpace(*response.Thread.ForkedFromID) != sourceThreadID {
return SideConversationOpenResult{}, errors.New(
"side thread/fork returned invalid lineage",
)
}
side.ProviderSessionID = childThreadID
side.Resumable = false
liveState := newACPLiveState()
liveState.currentMode = codexACPEffectiveModeID(side)
liveState.availableCommands = codexAppServerCommands()
liveState.commandsKnown = true
applyACPConfigOptionDescriptors(
&liveState,
codexAppServerConfigOptionDescriptors(clientState.models, side, raw),
)
// Register the child before boundary injection so connection-scoped
// notifications and server requests already have an exact Side owner.
sideAppSession := &codexAppServerSession{
client: client,
threadID: childThreadID,
runtimeSession: side,
serverInfo: clientState.serverInfo,
account: clientState.account,
models: cloneCodexAppServerModels(clientState.models),
startupModelsReady: len(clientState.models) > 0,
startupRateLimitsReady: false,
planModeMask: clientState.planModeMask,
defaultModeMask: clientState.defaultModeMask,
defaultModel: clientState.defaultModel,
tuttiModeHostContext: clientState.tuttiModeHostContext,
authState: "authenticated",
acpLiveState: liveState,
pendingRequests: make(map[string]*pendingInteractiveRequest),
}
if err := a.commitPendingSideRoute(
client,
side.AgentSessionID,
childThreadID,
sideAppSession,
); err != nil {
return SideConversationOpenResult{}, err
}
for {
bufferedMessages, drained := a.drainPendingSideMessages(client)
for _, buffered := range bufferedMessages {
if err := a.routeSharedAppServerMessageWithPending(
ctx,
client,
clientState.routerFallback,
buffered.message,
false,
); err != nil {
return SideConversationOpenResult{}, fmt.Errorf(
"replay buffered Side message for thread %q: %w",
buffered.threadID,
err,
)
}
}
if drained {
break
}
}
pendingRoute = false
if _, err := trace.TypedCall(
acpStartCallTimeout,
appServerMethodThreadInjectItems,
func() (json.RawMessage, error) {
return client.ThreadInjectItems(
ctx,
acpStartCallTimeout,
map[string]any{
"threadId": childThreadID,
"items": []any{map[string]any{
"type": "message",
"role": "user",
"content": []any{map[string]any{
"type": "input_text",
"text": codexSideBoundaryText,
}},
}},
},
nil,
)
},
); err != nil {
return SideConversationOpenResult{}, fmt.Errorf(
"inject side conversation boundary: %w",
err,
)
}
a.emitCommandSnapshot(AgentSessionCommandSnapshot{
AgentSessionID: side.AgentSessionID,
Commands: codexAppServerCommands(),
})
committed = true
keepDedicatedClient = true
return SideConversationOpenResult{
Session: side, Capabilities: codexSideCapabilities(),
}, nil
}