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
275 lines
9.9 KiB
Go
275 lines
9.9 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
|
|
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
|
|
)
|
|
|
|
func (c *Controller) SubmitInteractive(ctx context.Context, input SubmitInteractiveInput) (SubmitInteractiveResult, error) {
|
|
rootAgentSessionID := strings.TrimSpace(input.RootAgentSessionID)
|
|
if rootAgentSessionID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: root agent session id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
input.AgentSessionID = strings.TrimSpace(input.AgentSessionID)
|
|
if input.AgentSessionID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: target agent session id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
session, adapter, err := c.sessionAndAdapter(input.RoomID, rootAgentSessionID)
|
|
if err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
if interactiveAdapter, ok := adapter.(InteractiveAdapter); ok {
|
|
result, err := interactiveAdapter.SubmitInteractive(ctx, session, input)
|
|
if result.Disposition == "" {
|
|
if dispositionAdapter, ok := adapter.(InteractiveDispositionAdapter); ok {
|
|
result.Disposition = dispositionAdapter.InteractiveDisposition(session, input.TurnID, input.RequestID)
|
|
}
|
|
}
|
|
if isTerminalInteractiveDisposition(result.Disposition) {
|
|
c.recordTerminalInteractiveDisposition(input.AgentSessionID, input.TurnID, input.RequestID, result.Disposition)
|
|
}
|
|
if err == nil {
|
|
c.syncInteractiveSelectionState(adapter, session, result.OptionID)
|
|
if adapterShouldReceiveInteractiveDenyFollowUp(adapter) {
|
|
result.FollowUpPrompt = interactiveDenyFollowUpPrompt(input)
|
|
}
|
|
}
|
|
return result, err
|
|
}
|
|
return SubmitInteractiveResult{}, fmt.Errorf("agent provider %q does not support interactive submission", session.Provider)
|
|
}
|
|
|
|
func (c *Controller) InteractiveDisposition(roomID string, rootAgentSessionID string, agentSessionID string, turnID string, requestID string) InteractiveDisposition {
|
|
if disposition := c.terminalInteractiveDisposition(agentSessionID, turnID, requestID); disposition != InteractiveDispositionUnknown {
|
|
return disposition
|
|
}
|
|
session, adapter, err := c.sessionAndAdapter(roomID, rootAgentSessionID)
|
|
if err != nil {
|
|
return InteractiveDispositionUnknown
|
|
}
|
|
interactiveAdapter, ok := adapter.(InteractiveDispositionAdapter)
|
|
if targeted, targetedOK := adapter.(TargetedInteractiveDispositionAdapter); targetedOK {
|
|
return targeted.InteractiveDispositionForTarget(session, agentSessionID, turnID, requestID)
|
|
}
|
|
if !ok || strings.TrimSpace(agentSessionID) != strings.TrimSpace(rootAgentSessionID) {
|
|
return InteractiveDispositionUnknown
|
|
}
|
|
return interactiveAdapter.InteractiveDisposition(session, turnID, requestID)
|
|
}
|
|
|
|
func isTerminalInteractiveDisposition(disposition InteractiveDisposition) bool {
|
|
return disposition == InteractiveDispositionAnswered ||
|
|
disposition == InteractiveDispositionSuperseded ||
|
|
disposition == InteractiveDispositionInterrupted
|
|
}
|
|
|
|
func (c *Controller) recordTerminalInteractiveDisposition(agentSessionID string, turnID string, requestID string, disposition InteractiveDisposition) {
|
|
if c == nil || !isTerminalInteractiveDisposition(disposition) {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.terminalInteractions.put(newInteractiveRequestKey(agentSessionID, turnID, requestID), disposition)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Controller) terminalInteractiveDisposition(agentSessionID string, turnID string, requestID string) InteractiveDisposition {
|
|
if c == nil {
|
|
return InteractiveDispositionUnknown
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.terminalInteractions.get(newInteractiveRequestKey(agentSessionID, turnID, requestID))
|
|
}
|
|
|
|
// syncInteractiveSelectionState asks the adapter to interpret its protocol
|
|
// option id, then keeps the controller as the single session-state writer.
|
|
func (c *Controller) syncInteractiveSelectionState(adapter Adapter, session Session, optionID string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
stateAdapter, ok := adapter.(InteractiveSelectionStateAdapter)
|
|
if !ok {
|
|
return
|
|
}
|
|
state, ok := stateAdapter.StateAfterInteractiveSelection(session, optionID)
|
|
if !ok {
|
|
return
|
|
}
|
|
current, found := c.Session(session.RoomID, session.AgentSessionID)
|
|
if !found {
|
|
return
|
|
}
|
|
c.applyInteractiveSelectionState(current, state)
|
|
}
|
|
|
|
func (c *Controller) applyInteractiveSelectionState(current Session, state InteractiveSelectionState) {
|
|
currentSettings := normalizeSessionSettings(current.Settings, current.Provider, current.PermissionModeID)
|
|
nextPermission := strings.TrimSpace(state.PermissionMode)
|
|
if nextPermission == "" {
|
|
nextPermission = strings.TrimSpace(currentSettings.PermissionModeID)
|
|
}
|
|
if currentSettings.PlanMode == state.PlanMode &&
|
|
strings.TrimSpace(currentSettings.PermissionModeID) == nextPermission &&
|
|
strings.TrimSpace(current.PermissionModeID) == nextPermission {
|
|
return
|
|
}
|
|
nextSession := current
|
|
nextSession.PermissionModeID = nextPermission
|
|
settings := normalizeSessionSettings(nextSession.Settings, nextSession.Provider, nextSession.PermissionModeID)
|
|
settings.PlanMode = state.PlanMode
|
|
settings.PermissionModeID = nextPermission
|
|
nextSession.Settings = cloneSessionSettings(settings)
|
|
nextSession.UpdatedAtUnixMS = unixMS(now())
|
|
c.store(nextSession)
|
|
patch := permissionModeStatePatch(nextSession)
|
|
c.publishSessionStatePatch(nextSession, patch)
|
|
c.enqueueSessionStatePatchReport(context.Background(), nextSession, patch)
|
|
}
|
|
|
|
// applySessionPlanModeOnly updates the orthogonal plan-mode flag without
|
|
// touching permission tiers or model selection.
|
|
func (c *Controller) applySessionPlanModeOnly(current Session, planMode bool) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
key := sessionKey(current.RoomID, current.AgentSessionID)
|
|
latest, found := c.sessions[key]
|
|
if !found {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
current = latest
|
|
currentSettings := normalizeSessionSettings(current.Settings, current.Provider, current.PermissionModeID)
|
|
if currentSettings.PlanMode == planMode {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
settings := normalizeSessionSettings(current.Settings, current.Provider, current.PermissionModeID)
|
|
settings.PlanMode = planMode
|
|
nextSession := current
|
|
nextSession.Settings = cloneSessionSettings(settings)
|
|
nextSession.UpdatedAtUnixMS = unixMS(now())
|
|
c.sessions[key] = nextSession
|
|
c.mu.Unlock()
|
|
patch := permissionModeStatePatch(nextSession)
|
|
c.publishSessionStatePatch(nextSession, patch)
|
|
c.enqueueSessionStatePatchReport(context.Background(), nextSession, patch)
|
|
}
|
|
|
|
func (c *Controller) syncCursorPlanModeFromACPUpdate(session Session, modeID string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
descriptor, found := providerregistry.Find(session.Provider)
|
|
if !found || !descriptor.Runtime.StandardACP.ProjectCurrentMode {
|
|
return
|
|
}
|
|
planMode, ok := projectCurrentPlanModeFromACPModeID(descriptor.Runtime.StandardACP, modeID)
|
|
if !ok {
|
|
return
|
|
}
|
|
current, found := c.Session(session.RoomID, session.AgentSessionID)
|
|
if !found || strings.TrimSpace(current.Provider) != descriptor.Identity.ID {
|
|
return
|
|
}
|
|
c.applySessionPlanModeOnly(current, planMode)
|
|
}
|
|
|
|
func projectCurrentPlanModeFromACPModeID(descriptor providerregistry.StandardACPRuntimeDescriptor, modeID string) (bool, bool) {
|
|
modeID = strings.TrimSpace(modeID)
|
|
if modeID == "" {
|
|
return false, false
|
|
}
|
|
for _, mode := range descriptor.PermissionModes {
|
|
if strings.TrimSpace(mode.RuntimeID) == modeID {
|
|
return modeID == strings.TrimSpace(descriptor.PlanModeRuntimeID), true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func (c *Controller) syncCursorPlanModeFromEvents(session Session, events []activityshared.Event) {
|
|
for _, event := range events {
|
|
if event.Type != activityshared.EventSessionUpdated {
|
|
continue
|
|
}
|
|
if normalizedSessionUpdateKind(event.Payload.Metadata) != "current_mode_update" {
|
|
continue
|
|
}
|
|
c.syncCursorPlanModeFromACPUpdate(session, asString(event.Payload.Metadata["acpModeId"]))
|
|
}
|
|
}
|
|
|
|
func permissionModeStatePatch(session Session) agentsessionstore.WorkspaceAgentStatePatch {
|
|
settings := normalizeSessionSettings(session.Settings, session.Provider, session.PermissionModeID)
|
|
runtimeContext := map[string]any{
|
|
"permissionModeId": strings.TrimSpace(settings.PermissionModeID),
|
|
"planMode": settings.PlanMode,
|
|
}
|
|
if strings.TrimSpace(session.CWD) != "" {
|
|
runtimeContext["cwd"] = strings.TrimSpace(session.CWD)
|
|
}
|
|
if strings.TrimSpace(session.Title) != "" {
|
|
runtimeContext["title"] = strings.TrimSpace(session.Title)
|
|
}
|
|
return agentsessionstore.WorkspaceAgentStatePatch{
|
|
AgentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
Provider: strings.TrimSpace(session.Provider),
|
|
ProviderSessionID: strings.TrimSpace(session.ProviderSessionID),
|
|
PermissionModeID: strings.TrimSpace(settings.PermissionModeID),
|
|
Settings: sessionSettingsPayload(&settings),
|
|
RuntimeContextPatch: &canonical.RuntimeContextPatch{Set: runtimeContext},
|
|
OccurredAtUnixMS: session.UpdatedAtUnixMS,
|
|
}
|
|
}
|
|
|
|
func adapterShouldReceiveInteractiveDenyFollowUp(adapter Adapter) bool {
|
|
if policy, ok := adapter.(InteractiveDenyFollowUpPolicyAdapter); ok {
|
|
return policy.ControllerSendsInteractiveDenyFollowUp()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func interactiveDenyFollowUpPrompt(input SubmitInteractiveInput) string {
|
|
if input.Payload == nil || !isInteractiveDenySelection(input) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(asString(input.Payload["denyMessage"]))
|
|
}
|
|
|
|
func isInteractiveDenySelection(input SubmitInteractiveInput) bool {
|
|
for _, value := range []string{
|
|
input.Action,
|
|
input.OptionID,
|
|
asString(input.Payload["optionId"]),
|
|
} {
|
|
if isDenyInteractiveSelectionValue(value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isDenyInteractiveSelectionValue(value string) bool {
|
|
token := normalizePermissionOptionToken(value)
|
|
if token == "" {
|
|
return false
|
|
}
|
|
if permissionOptionDecision(token) == "denied" {
|
|
return true
|
|
}
|
|
switch token {
|
|
case "abort", "aborted":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|