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
323 lines
12 KiB
Go
323 lines
12 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
|
|
)
|
|
|
|
func (a *CodexAppServerAdapter) ApplyPermissionMode(_ context.Context, session Session) error {
|
|
// The app-server protocol has no live "set mode" call; the permission mode
|
|
// maps to approvalPolicy/sandboxPolicy overrides applied on every
|
|
// turn/start. Record the mode so session state reflects it immediately.
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[strings.TrimSpace(session.AgentSessionID)]
|
|
if appSession == nil {
|
|
return nil
|
|
}
|
|
if modeID := codexACPEffectiveModeID(session); modeID != "" {
|
|
appSession.currentMode = modeID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SessionState(session Session) SessionStateSnapshot {
|
|
snapshot := SessionStateSnapshot{
|
|
RoomID: session.RoomID,
|
|
AgentSessionID: session.AgentSessionID,
|
|
Provider: session.Provider,
|
|
ProviderSessionID: session.ProviderSessionID,
|
|
Status: session.Status,
|
|
PermissionModeID: session.PermissionModeID,
|
|
RuntimeContext: map[string]any{
|
|
"cwd": session.CWD,
|
|
"title": session.Title,
|
|
"permissionModeId": session.PermissionModeID,
|
|
},
|
|
UpdatedAtUnixMS: session.UpdatedAtUnixMS,
|
|
}
|
|
state, ok := a.snapshotSessionState(session.AgentSessionID)
|
|
if !ok {
|
|
return snapshot
|
|
}
|
|
for key, value := range state.resumeRuntimeContext {
|
|
snapshot.RuntimeContext[key] = value
|
|
}
|
|
snapshot.RuntimeContext["cwd"] = session.CWD
|
|
snapshot.RuntimeContext["title"] = session.Title
|
|
snapshot.RuntimeContext["permissionModeId"] = session.PermissionModeID
|
|
if len(state.serverInfo) > 0 {
|
|
snapshot.RuntimeContext["agent"] = state.serverInfo
|
|
}
|
|
if len(state.account) > 0 {
|
|
snapshot.RuntimeContext["account"] = state.account
|
|
}
|
|
if len(state.rateLimits) > 0 {
|
|
snapshot.RuntimeContext["rateLimits"] = state.rateLimits
|
|
}
|
|
startup := map[string]any{
|
|
"models": codexAppServerStartupStatus(state.startupModelsReady),
|
|
}
|
|
if a.config.rateLimits {
|
|
startup["rateLimits"] = codexAppServerStartupStatus(state.startupRateLimitsReady)
|
|
}
|
|
snapshot.RuntimeContext["appServerStartup"] = startup
|
|
if len(state.goal) > 0 {
|
|
snapshot.RuntimeContext["goal"] = state.goal
|
|
}
|
|
if state.authState != "" {
|
|
snapshot.AuthState = state.authState
|
|
}
|
|
if state.authMessage != "" {
|
|
snapshot.RuntimeContext["authMessage"] = state.authMessage
|
|
}
|
|
if state.currentMode != "" {
|
|
snapshot.RuntimeContext["mode"] = state.currentMode
|
|
}
|
|
if checkpoint := codexAppServerProtocolCheckpoint(
|
|
state.planModeMask,
|
|
state.defaultModeMask,
|
|
state.defaultModel,
|
|
); checkpoint != nil {
|
|
snapshot.RuntimeContext[canonical.ProviderResumeCheckpointRuntimeContextKey] =
|
|
checkpoint
|
|
}
|
|
if len(state.availableCommands) > 0 {
|
|
snapshot.RuntimeContext["commands"] = agentSessionCommandNames(state.availableCommands)
|
|
}
|
|
if len(state.configOptions) > 0 {
|
|
snapshot.RuntimeContext["config"] = state.configOptions
|
|
}
|
|
if len(state.configOptionDescriptors) > 0 {
|
|
snapshot.RuntimeContext["configOptions"] = state.configOptionDescriptors
|
|
}
|
|
if providerConfig := providerRuntimeConfig(session, session.Provider); len(providerConfig) > 0 {
|
|
snapshot.RuntimeContext["providerConfig"] = providerConfig
|
|
}
|
|
if usage := acpUsageRuntimeContext(state.usage); len(usage) > 0 {
|
|
snapshot.RuntimeContext["usage"] = usage
|
|
}
|
|
codexCapabilities := codexAppServerCapabilities(state.planModeSupported)
|
|
if !a.config.rateLimits {
|
|
codexCapabilities = slices.DeleteFunc(codexCapabilities, func(capability string) bool {
|
|
return capability == CapabilityRateLimits
|
|
})
|
|
}
|
|
codexCapabilities = appendBrowserUseCapability(codexCapabilities, session.Env)
|
|
codexCapabilities = appendComputerUseCapability(codexCapabilities, session.Env)
|
|
snapshot.Capabilities = canonical.NewCapabilitySnapshot(codexCapabilities)
|
|
snapshot.Settings = codexAppServerSessionSettingsWithConfig(
|
|
session.Settings,
|
|
session.Provider,
|
|
session.PermissionModeID,
|
|
state.configOptions,
|
|
)
|
|
if snapshot.Settings != nil {
|
|
snapshot.RuntimeContext["model"] = snapshot.Settings.Model
|
|
snapshot.RuntimeContext["reasoningEffort"] = snapshot.Settings.ReasoningEffort
|
|
snapshot.RuntimeContext["speed"] = snapshot.Settings.Speed
|
|
snapshot.RuntimeContext["planMode"] = snapshot.Settings.PlanMode
|
|
}
|
|
if state.pendingPrompt != nil {
|
|
snapshot.PendingInteractive = state.pendingPrompt
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func codexAppServerStartupStatus(ready bool) string {
|
|
if ready {
|
|
return "ready"
|
|
}
|
|
return "loading"
|
|
}
|
|
|
|
type codexAppServerSessionStateSnapshot struct {
|
|
serverInfo map[string]any
|
|
account map[string]any
|
|
rateLimits map[string]any
|
|
startupModelsReady bool
|
|
startupRateLimitsReady bool
|
|
goal map[string]any
|
|
authState string
|
|
authMessage string
|
|
planModeSupported bool
|
|
planModeMask map[string]any
|
|
defaultModeMask map[string]any
|
|
defaultModel string
|
|
resumeRuntimeContext map[string]any
|
|
acpLiveStateSnapshot
|
|
pendingPrompt *SessionInteractivePrompt
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) snapshotSessionState(agentSessionID string) (codexAppServerSessionStateSnapshot, bool) {
|
|
if a == nil {
|
|
return codexAppServerSessionStateSnapshot{}, false
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
|
|
if appSession == nil {
|
|
return codexAppServerSessionStateSnapshot{}, false
|
|
}
|
|
var prompt *SessionInteractivePrompt
|
|
for _, pending := range appSession.pendingRequests {
|
|
if candidate := pending.snapshotPrompt(); candidate != nil {
|
|
prompt = candidate
|
|
break
|
|
}
|
|
}
|
|
return codexAppServerSessionStateSnapshot{
|
|
serverInfo: clonePayload(appSession.serverInfo),
|
|
account: clonePayload(appSession.account),
|
|
rateLimits: clonePayload(appSession.rateLimits),
|
|
startupModelsReady: appSession.startupModelsReady,
|
|
startupRateLimitsReady: appSession.startupRateLimitsReady,
|
|
goal: clonePayload(appSession.goal),
|
|
authState: strings.TrimSpace(appSession.authState),
|
|
authMessage: strings.TrimSpace(appSession.authMessage),
|
|
planModeSupported: appSession.planModeMask != nil,
|
|
planModeMask: clonePayload(appSession.planModeMask),
|
|
defaultModeMask: clonePayload(appSession.defaultModeMask),
|
|
defaultModel: strings.TrimSpace(appSession.defaultModel),
|
|
resumeRuntimeContext: clonePayload(appSession.resumeRuntimeContext),
|
|
acpLiveStateSnapshot: snapshotACPLiveState(appSession.acpLiveState),
|
|
pendingPrompt: prompt,
|
|
}, true
|
|
}
|
|
|
|
func codexAppServerProtocolCheckpoint(
|
|
planModeMask map[string]any,
|
|
defaultModeMask map[string]any,
|
|
defaultModel string,
|
|
) map[string]any {
|
|
checkpoint := map[string]any{}
|
|
if len(planModeMask) > 0 {
|
|
checkpoint["planModeMask"] = clonePayload(planModeMask)
|
|
}
|
|
if len(defaultModeMask) > 0 {
|
|
checkpoint["defaultModeMask"] = clonePayload(defaultModeMask)
|
|
}
|
|
if defaultModel = strings.TrimSpace(defaultModel); defaultModel != "" {
|
|
checkpoint["defaultModel"] = defaultModel
|
|
}
|
|
if len(checkpoint) == 0 {
|
|
return nil
|
|
}
|
|
return checkpoint
|
|
}
|
|
|
|
func codexAppServerProtocolCheckpointFromRuntimeContext(
|
|
runtimeContext map[string]any,
|
|
) (map[string]any, map[string]any, string, bool) {
|
|
checkpoint, ok := runtimeContext[canonical.ProviderResumeCheckpointRuntimeContextKey].(map[string]any)
|
|
if !ok || len(checkpoint) == 0 {
|
|
return nil, nil, "", false
|
|
}
|
|
planModeMask, _ := checkpoint["planModeMask"].(map[string]any)
|
|
defaultModeMask, _ := checkpoint["defaultModeMask"].(map[string]any)
|
|
return clonePayload(planModeMask),
|
|
clonePayload(defaultModeMask),
|
|
strings.TrimSpace(asString(checkpoint["defaultModel"])),
|
|
true
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SessionCommandSnapshot(session Session) (AgentSessionCommandSnapshot, bool) {
|
|
if a == nil {
|
|
return AgentSessionCommandSnapshot{}, false
|
|
}
|
|
a.mu.Lock()
|
|
appSession := a.sessions[strings.TrimSpace(session.AgentSessionID)]
|
|
if appSession == nil {
|
|
a.mu.Unlock()
|
|
return AgentSessionCommandSnapshot{}, false
|
|
}
|
|
snapshot, ok := commandSnapshotFromACPLiveState(session.AgentSessionID, appSession.acpLiveState)
|
|
a.mu.Unlock()
|
|
return snapshot, ok
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) SubmitInteractive(ctx context.Context, session Session, input SubmitInteractiveInput) (SubmitInteractiveResult, error) {
|
|
turnID := strings.TrimSpace(input.TurnID)
|
|
if turnID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive turn id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
requestID := strings.TrimSpace(input.RequestID)
|
|
if requestID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive request id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
targetAgentSessionID := firstNonEmpty(strings.TrimSpace(input.AgentSessionID), strings.TrimSpace(session.AgentSessionID))
|
|
pending := a.getPendingRequest(targetAgentSessionID, turnID, requestID)
|
|
if pending == nil {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: %q", ErrInteractiveRequestNotLive, requestID)
|
|
}
|
|
if pending.callType == "approval" {
|
|
optionID := interactiveApprovalOptionID(input)
|
|
if optionID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive option id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
resolvedOptionID, ok := pending.resolvePermissionOptionID(optionID)
|
|
if !ok {
|
|
return SubmitInteractiveResult{}, fmt.Errorf(
|
|
"%w: permission option %q is not available for request %q",
|
|
ErrInteractiveResponseInvalid,
|
|
optionID,
|
|
requestID,
|
|
)
|
|
}
|
|
if _, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{
|
|
optionID: resolvedOptionID,
|
|
payload: clonePayload(input.Payload),
|
|
}); err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
if state, err := pending.waitForDisposition(ctx); err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
} else if state != pendingInteractiveRequestStateAnswered {
|
|
return SubmitInteractiveResult{}, interactiveDispositionError(requestID, state)
|
|
}
|
|
return SubmitInteractiveResult{AgentSessionID: targetAgentSessionID, RequestID: requestID, Accepted: true, OptionID: resolvedOptionID, Disposition: InteractiveDispositionAnswered}, nil
|
|
}
|
|
optionID := strings.TrimSpace(input.OptionID)
|
|
action := strings.TrimSpace(input.Action)
|
|
payload := clonePayload(input.Payload)
|
|
if _, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{
|
|
optionID: optionID,
|
|
action: action,
|
|
payload: payload,
|
|
}); err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
if state, err := pending.waitForDisposition(ctx); err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
} else if state != pendingInteractiveRequestStateAnswered {
|
|
return SubmitInteractiveResult{}, interactiveDispositionError(requestID, state)
|
|
}
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: targetAgentSessionID,
|
|
RequestID: requestID,
|
|
Accepted: true,
|
|
Disposition: InteractiveDispositionAnswered,
|
|
}, nil
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) InteractiveDisposition(session Session, turnID string, requestID string) InteractiveDisposition {
|
|
return a.InteractiveDispositionForTarget(session, session.AgentSessionID, turnID, requestID)
|
|
}
|
|
|
|
func (a *CodexAppServerAdapter) InteractiveDispositionForTarget(_ Session, agentSessionID string, turnID string, requestID string) InteractiveDisposition {
|
|
if pending := a.getPendingRequest(agentSessionID, turnID, requestID); pending != nil {
|
|
return runtimeInteractiveDisposition(pending)
|
|
}
|
|
return a.terminalInteractiveDisposition(agentSessionID, turnID, requestID)
|
|
}
|
|
|
|
// lockSessionLifecycle serializes lifecycle operations (Start, Resume, Close,
|
|
// ReleaseLiveSession) for one agent session: any interleaving of these calls
|
|
// could otherwise spawn a second app-server process while the first is still
|
|
// live, or close the wrong process. The lock entry is refcounted so the map
|
|
// does not grow with retired session IDs.
|