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
603 lines
21 KiB
Go
603 lines
21 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func (c *Controller) Exec(ctx context.Context, input ExecInput) (result ExecResult, err error) {
|
|
if input.Guidance || input.HistoryReplacement || input.RequireProviderAcceptance {
|
|
defer func() {
|
|
if err != nil && result.ProviderDispatch == nil {
|
|
result.ProviderDispatch = &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
releaseLifecycleLock := c.acquireLifecycleLock(input.RoomID, input.AgentSessionID)
|
|
lifecycleLockHeld := true
|
|
defer func() {
|
|
if lifecycleLockHeld {
|
|
releaseLifecycleLock()
|
|
}
|
|
}()
|
|
|
|
session, adapter, err := c.sessionAndAdapter(input.RoomID, input.AgentSessionID)
|
|
if err != nil {
|
|
return ExecResult{}, err
|
|
}
|
|
var canonicalSubmit canonicalSubmitFact
|
|
if session.IsSideConversation() {
|
|
// Side submissions have a caller-stable transient identity, but no
|
|
// canonical SubmitClaim or durable occurrence. Keep the identity in
|
|
// execution metadata for provider correlation and the ephemeral
|
|
// projector; never manufacture a canonical submit fact for this lane.
|
|
if input.CanonicalSubmitOccurredAtUnixMS > 0 {
|
|
return ExecResult{}, errors.New(
|
|
"side conversation cannot carry a canonical submit occurrence",
|
|
)
|
|
}
|
|
} else {
|
|
canonicalSubmit, err = newCanonicalSubmitFact(
|
|
input.ClientSubmitID,
|
|
input.CanonicalSubmitOccurredAtUnixMS,
|
|
)
|
|
if err != nil {
|
|
return ExecResult{}, err
|
|
}
|
|
}
|
|
if canonicalSubmit.occurredAtUnixMS > 0 {
|
|
observeEventUnixMS(canonicalSubmit.occurredAtUnixMS)
|
|
ctx = withCanonicalSubmitFact(ctx, canonicalSubmit)
|
|
}
|
|
var historyAdapter EffectiveHistoryAdapter
|
|
if input.HistoryReplacement {
|
|
if input.Guidance {
|
|
return ExecResult{
|
|
ProviderDispatch: &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
},
|
|
}, errors.New("history replacement cannot guide an active turn")
|
|
}
|
|
var ok bool
|
|
historyAdapter, ok = adapter.(EffectiveHistoryAdapter)
|
|
if !ok {
|
|
return ExecResult{
|
|
ProviderDispatch: &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
},
|
|
}, ErrEffectiveHistoryUnsupported
|
|
}
|
|
}
|
|
var acceptanceAdapter ProviderAcceptanceExecAdapter
|
|
if input.RequireProviderAcceptance && !input.HistoryReplacement {
|
|
if _, forkCapable := adapter.(SessionForkAdapter); forkCapable {
|
|
var ok bool
|
|
acceptanceAdapter, ok = adapter.(ProviderAcceptanceExecAdapter)
|
|
if !ok {
|
|
return ExecResult{
|
|
ProviderDispatch: &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
},
|
|
}, errors.New("fork-capable agent provider does not expose durable turn acceptance")
|
|
}
|
|
}
|
|
}
|
|
metadata := cloneExecMetadata(input.Metadata)
|
|
delete(metadata, "clientSubmitId")
|
|
if clientSubmitID := strings.TrimSpace(input.ClientSubmitID); clientSubmitID != "" {
|
|
if metadata == nil {
|
|
metadata = make(map[string]any)
|
|
}
|
|
// Runtime adapters still consume execution context metadata internally;
|
|
// derive this compatibility projection from the typed host contract.
|
|
metadata["clientSubmitId"] = clientSubmitID
|
|
}
|
|
logAgentSubmitTrace("runtime.exec.entered", session, "", metadata, map[string]any{
|
|
"content_block_count": len(input.Content),
|
|
})
|
|
if err := c.ensureLiveAdapterSession(ctx, session, adapter); err != nil {
|
|
logAgentSubmitTrace("runtime.exec.ensure_live_failed", session, "", metadata, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
return ExecResult{}, err
|
|
}
|
|
logAgentSubmitTrace("runtime.exec.adapter_session_ready", session, "", metadata, nil)
|
|
if refreshed, ok := c.get(session.RoomID, session.AgentSessionID); ok {
|
|
session = refreshed
|
|
}
|
|
if err := validateRuntimePromptContentImages(input.Content); err != nil {
|
|
return ExecResult{}, err
|
|
}
|
|
content := normalizeRuntimePromptContent(input.Content)
|
|
if len(content) == 0 {
|
|
return ExecResult{}, fmt.Errorf("prompt is required")
|
|
}
|
|
providerContent, nextAnnounced := projectRuntimeConnectorPromptContent(
|
|
content,
|
|
session.AnnouncedConnectorKeys,
|
|
input.Guidance,
|
|
)
|
|
providerContent = prependConnectorRoutingUpdate(providerContent, input.ConnectorRoutingUpdate)
|
|
displayPrompt := strings.TrimSpace(input.DisplayPrompt)
|
|
if promptAdapter, ok := adapter.(PromptContentAdapter); ok {
|
|
if err := promptAdapter.ValidatePromptContent(session, providerContent); err != nil {
|
|
return ExecResult{}, err
|
|
}
|
|
}
|
|
if input.Guidance {
|
|
return c.guideActiveTurn(ctx, session, adapter, providerContent, displayPrompt, metadata, input.CapabilityRefs, input.TurnID)
|
|
}
|
|
session.AnnouncedConnectorKeys = append([]string(nil), nextAnnounced...)
|
|
previousSession := session
|
|
// Keep the initial title on the submitted Turn patch so owner admission
|
|
// persists the title, turn, and prompt as one state/message transaction.
|
|
submittedTitle := ""
|
|
if initialTitle := strings.TrimSpace(input.InitialTitle); initialTitle != "" &&
|
|
!session.InitialTitleEstablished &&
|
|
strings.TrimSpace(session.Title) == strings.TrimSpace(input.InitialTitleBase) {
|
|
session.Title = initialTitle
|
|
session = markInitialTitleEstablished(session)
|
|
session.UpdatedAtUnixMS = unixMS(now())
|
|
submittedTitle = session.Title
|
|
}
|
|
turnID := strings.TrimSpace(input.TurnID)
|
|
if turnID == "" {
|
|
// Internal callers that do not cross the daemon service boundary retain
|
|
// backwards-compatible allocation. External service submissions always
|
|
// preallocate and durably bind this canonical id before dispatch.
|
|
turnID = newID()
|
|
}
|
|
runCtx, cancel := context.WithCancel(context.Background())
|
|
if len(metadata) > 0 {
|
|
runCtx = context.WithValue(runCtx, execMetadataContextKey{}, metadata)
|
|
}
|
|
runCtx = withCanonicalSubmitFact(runCtx, canonicalSubmit)
|
|
runCtx = withCanonicalPromptContent(runCtx, content)
|
|
if canonicalSubmit.clientSubmitID == "" {
|
|
runCtx = withPromptActivityMessageID(runCtx, newTurnUserPromptActivityMessageID())
|
|
}
|
|
tuttiModeSnapshot := normalizeTuttiModeTurnSnapshot(input.TuttiModeSnapshot)
|
|
runCtx = withTuttiModeTurnSnapshot(runCtx, tuttiModeSnapshot)
|
|
var dispatchObserver *providerDispatchObserver
|
|
if historyAdapter != nil || acceptanceAdapter != nil {
|
|
dispatchObserver = newProviderDispatchObserver()
|
|
}
|
|
// beginTurn returns the zero session on failure; keep the real session
|
|
// for the goal-control fallback below.
|
|
startedSession, err := c.beginTurnWithTuttiModeSnapshot(session, turnID, cancel, tuttiModeSnapshot)
|
|
if err != nil {
|
|
cancel()
|
|
return ExecResult{}, err
|
|
}
|
|
session = startedSession
|
|
key := sessionKey(session.RoomID, session.AgentSessionID)
|
|
c.mu.Lock()
|
|
provisional := c.provisionalSessions[key]
|
|
c.mu.Unlock()
|
|
submitEvents := submittedTurnActivityEvents(
|
|
runCtx,
|
|
session,
|
|
content,
|
|
displayPrompt,
|
|
turnID,
|
|
input.CapabilityRefs,
|
|
submittedTitle,
|
|
)
|
|
// The submitted Turn is a durable user intent, not provider output. Keep
|
|
// the Session visible while the provider-identity acceptance barrier is
|
|
// pending so an explicit provider rejection cannot erase the prompt.
|
|
if err := c.reportSubmittedTurnDurable(ctx, session, submitEvents, false); err != nil {
|
|
cancel()
|
|
c.rollbackSubmittedTurn(previousSession, turnID)
|
|
logAgentSubmitTrace("runtime.exec.submitted_report_failed", session, turnID, metadata, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
return ExecResult{}, fmt.Errorf("persist submitted agent turn: %w", err)
|
|
}
|
|
if provisional {
|
|
c.mu.Lock()
|
|
delete(c.provisionalSessions, key)
|
|
c.mu.Unlock()
|
|
}
|
|
if len(submitEvents) > 0 {
|
|
c.publish(session, submitEvents)
|
|
}
|
|
if provisional {
|
|
c.publishPendingConfigOptionsUpdates(session)
|
|
if !c.publishPendingCommandSnapshot(session) {
|
|
c.publishAdapterCommandSnapshot(session, adapter)
|
|
}
|
|
}
|
|
logAgentSubmitTrace("runtime.submitted", session, turnID, metadata, map[string]any{
|
|
"phase": "submitted",
|
|
})
|
|
if historyAdapter != nil {
|
|
go c.runHistoryReplacementTurn(
|
|
runCtx,
|
|
session,
|
|
historyAdapter,
|
|
HistoryReplacementExecInput{
|
|
Content: providerContent,
|
|
DisplayPrompt: displayPrompt,
|
|
TurnID: turnID,
|
|
},
|
|
dispatchObserver.Report,
|
|
)
|
|
} else if acceptanceAdapter != nil {
|
|
acceptProviderTurn := func(receipt ProviderAcceptanceReceipt) error {
|
|
dispatch := ProviderDispatchResult{
|
|
Disposition: DispatchDispositionApplied,
|
|
Acceptance: &receipt,
|
|
AcceptanceDiagnostics: codexProviderAcceptanceDiagnostics(receipt.ProviderSessionID, receipt.ProviderTurnID, ""),
|
|
}
|
|
confirmed, confirmErr := c.confirmProviderDispatchDurable(
|
|
// Provider acceptance persistence must finish even when the
|
|
// caller concurrently cancels the submitted request.
|
|
context.WithoutCancel(runCtx),
|
|
session,
|
|
turnID,
|
|
dispatch,
|
|
)
|
|
if confirmErr != nil {
|
|
cancel()
|
|
}
|
|
// The waiting Exec caller is released only after the acceptance receipt
|
|
// has crossed the durable reporter (or has a typed rejection).
|
|
dispatchObserver.ReportWithError(confirmed, confirmErr)
|
|
return confirmErr
|
|
}
|
|
go c.runProviderAcceptanceTurn(
|
|
runCtx,
|
|
session,
|
|
acceptanceAdapter,
|
|
providerContent,
|
|
displayPrompt,
|
|
turnID,
|
|
dispatchObserver.Report,
|
|
acceptProviderTurn,
|
|
)
|
|
} else {
|
|
go c.runExecTurn(runCtx, session, adapter, providerContent, displayPrompt, turnID)
|
|
}
|
|
result = ExecResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Status: ExecStatusStarted,
|
|
TurnID: turnID,
|
|
Accepted: true,
|
|
SessionStatus: session.Status,
|
|
TurnLifecycle: *session.TurnLifecycle,
|
|
SubmitAvailability: *session.SubmitAvailability,
|
|
}
|
|
// The lifecycle lock protects canonical admission through durable submit
|
|
// and provider goroutine launch. Provider acceptance may arrive later (or
|
|
// never); keeping the lock while waiting would prevent Cancel from
|
|
// interrupting the active provider operation during that window.
|
|
releaseLifecycleLock()
|
|
lifecycleLockHeld = false
|
|
if dispatchObserver == nil {
|
|
return result, nil
|
|
}
|
|
select {
|
|
case observation := <-dispatchObserver.result:
|
|
dispatch := observation.dispatch
|
|
confirmErr := observation.err
|
|
if acceptanceAdapter == nil {
|
|
dispatch, confirmErr = c.confirmProviderDispatchDurable(
|
|
// Once the provider has positively accepted a Turn, user
|
|
// cancellation must not abort persistence of that identity.
|
|
context.WithoutCancel(runCtx),
|
|
session,
|
|
turnID,
|
|
dispatch,
|
|
)
|
|
}
|
|
result.ProviderDispatch = &dispatch
|
|
if confirmErr != nil {
|
|
return result, confirmErr
|
|
}
|
|
if dispatch.Disposition == DispatchDispositionRejected ||
|
|
dispatch.Disposition == DispatchDispositionNotDispatched {
|
|
// The provider supplied a definite negative delivery result. The
|
|
// submitted Turn and prompt stay canonical so the failure is visible;
|
|
// runBlockingExecTurn settles the in-memory Turn without waiting for a
|
|
// provider-root aggregation that can never arrive.
|
|
cancel()
|
|
}
|
|
if dispatch.Disposition == DispatchDispositionAppliedWithoutProviderTurn &&
|
|
dispatch.Acceptance == nil {
|
|
return result, nil
|
|
}
|
|
if dispatch.Disposition != DispatchDispositionApplied || dispatch.Acceptance == nil {
|
|
if input.RequireProviderAcceptance {
|
|
// Durable submit already published the Turn. Any outcome that
|
|
// never bound a provider Turn identity (cancel-before-accept,
|
|
// pre-acceptance interrupt races where adapter cancel settles
|
|
// before runCtx is canceled, caller disconnect) must not become
|
|
// delivery-unknown — that locks the Session for the next submit.
|
|
// An explicit acceptance diagnostic is different: it identifies a
|
|
// deterministic provider-boundary failure and must remain visible.
|
|
if dispatch.Acceptance == nil &&
|
|
dispatch.AcceptanceDiagnostics == nil &&
|
|
dispatch.Disposition != DispatchDispositionRejected &&
|
|
dispatch.Disposition != DispatchDispositionNotDispatched {
|
|
result.ProviderDispatch = &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionAppliedWithoutProviderTurn,
|
|
}
|
|
return result, nil
|
|
}
|
|
if dispatch.Failure != nil {
|
|
return result, dispatch.Failure
|
|
}
|
|
if diagnostics := dispatch.AcceptanceDiagnostics; diagnostics != nil &&
|
|
strings.TrimSpace(diagnostics.FailureReason) != "" {
|
|
return result, &AppError{
|
|
Code: AppErrorProviderAcceptanceMissingIdentity,
|
|
Message: "provider turn was not durably accepted",
|
|
Cause: errors.New(diagnostics.FailureReason),
|
|
}
|
|
}
|
|
return result, errors.New("provider turn was not durably accepted")
|
|
}
|
|
return result, nil
|
|
}
|
|
return result, nil
|
|
case <-ctx.Done():
|
|
// Caller context can end (HTTP cancel / stall) while provider acceptance
|
|
// is still pending. The Turn is already durable; do not leave
|
|
// OutcomeUnknown on the claim fence.
|
|
if input.RequireProviderAcceptance {
|
|
result.ProviderDispatch = &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionAppliedWithoutProviderTurn,
|
|
}
|
|
return result, nil
|
|
}
|
|
result.ProviderDispatch = &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionOutcomeUnknown,
|
|
}
|
|
return result, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (c *Controller) guideActiveTurn(
|
|
ctx context.Context,
|
|
session Session,
|
|
adapter Adapter,
|
|
content []PromptContentBlock,
|
|
displayPrompt string,
|
|
metadata map[string]any,
|
|
capabilityRefs []activityshared.CapabilityReference,
|
|
expectedTurnID string,
|
|
) (ExecResult, error) {
|
|
guidanceAdapter, ok := adapter.(ActiveTurnGuidanceAdapter)
|
|
if !ok {
|
|
return ExecResult{}, ErrActiveTurnGuidanceUnsupported
|
|
}
|
|
expectedTurnID = strings.TrimSpace(expectedTurnID)
|
|
turnID, ok := c.activeTurnID(session.RoomID, session.AgentSessionID)
|
|
if !ok {
|
|
if expectedTurnID != "" {
|
|
return ExecResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Status: ExecStatusStarted,
|
|
TurnID: expectedTurnID,
|
|
ProviderDispatch: &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
},
|
|
}, errors.Join(
|
|
fmt.Errorf("%w: expected %q, current turn is inactive", ErrActiveTurnTargetMismatch, expectedTurnID),
|
|
ErrSessionNoActiveTurn,
|
|
)
|
|
}
|
|
return ExecResult{}, ErrSessionNoActiveTurn
|
|
}
|
|
// The lifecycle lock held by Exec makes this comparison and the provider
|
|
// admission below one serialized decision. A guidance request is allowed to
|
|
// use the legacy empty target only for direct internal Controller callers;
|
|
// Host consumers must provide the target and are checked before reaching
|
|
// this method. When a target is present, never retarget to whichever turn is
|
|
// current when the request happens to arrive.
|
|
if expectedTurnID != "" && expectedTurnID != turnID {
|
|
return ExecResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Status: ExecStatusStarted,
|
|
TurnID: expectedTurnID,
|
|
ProviderDispatch: &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionNotDispatched,
|
|
},
|
|
}, fmt.Errorf("%w: expected %q, current %q", ErrActiveTurnTargetMismatch, expectedTurnID, turnID)
|
|
}
|
|
runCtx := ctx
|
|
if len(metadata) > 0 {
|
|
runCtx = context.WithValue(ctx, execMetadataContextKey{}, metadata)
|
|
}
|
|
// Guidance belongs to the already-running canonical turn. Reuse the
|
|
// snapshot frozen when that turn began rather than observing a later badge
|
|
// toggle from the session.
|
|
runCtx = withTuttiModeTurnSnapshot(runCtx, c.activeTurnTuttiModeSnapshot(session.RoomID, session.AgentSessionID))
|
|
var emittedMu sync.Mutex
|
|
var emitted []activityshared.Event
|
|
emit := func(next []activityshared.Event) {
|
|
if len(next) == 0 {
|
|
return
|
|
}
|
|
emittedMu.Lock()
|
|
emitted = append(emitted, next...)
|
|
emittedMu.Unlock()
|
|
c.applySessionEventsByAgentSessionID(session.AgentSessionID, next)
|
|
}
|
|
emitCommands := func(snapshot AgentSessionCommandSnapshot) {
|
|
c.applyCommandSnapshotByAgentSessionID(snapshot)
|
|
}
|
|
var providerDispatch *ProviderDispatchResult
|
|
var providerDispatchOnce sync.Once
|
|
reportProviderDispatch := func(result ProviderDispatchResult) {
|
|
providerDispatchOnce.Do(func() {
|
|
copy := result
|
|
providerDispatch = ©
|
|
})
|
|
}
|
|
var events []activityshared.Event
|
|
var err error
|
|
if dispatchAdapter, ok := adapter.(ActiveTurnGuidanceProviderDispatchAdapter); ok {
|
|
events, err = dispatchAdapter.GuideActiveTurnWithProviderDispatch(
|
|
runCtx,
|
|
session,
|
|
content,
|
|
displayPrompt,
|
|
turnID,
|
|
emit,
|
|
emitCommands,
|
|
reportProviderDispatch,
|
|
)
|
|
} else {
|
|
events, err = guidanceAdapter.GuideActiveTurn(
|
|
runCtx,
|
|
session,
|
|
content,
|
|
displayPrompt,
|
|
turnID,
|
|
emit,
|
|
emitCommands,
|
|
)
|
|
}
|
|
if err != nil {
|
|
logAgentSubmitTrace("runtime.exec.guidance_failed", session, turnID, metadata, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
// Untyped adapters retain the conservative legacy boundary. Typed
|
|
// adapters can prove a local preflight rejection, while any error after
|
|
// provider I/O remains outcome-unknown.
|
|
if providerDispatch == nil {
|
|
providerDispatch = &ProviderDispatchResult{
|
|
Disposition: DispatchDispositionOutcomeUnknown,
|
|
}
|
|
}
|
|
return ExecResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Status: ExecStatusStarted,
|
|
TurnID: turnID,
|
|
ProviderDispatch: providerDispatch,
|
|
}, err
|
|
}
|
|
emittedMu.Lock()
|
|
remaining := unemittedActivityEvents(events, emitted)
|
|
emittedMu.Unlock()
|
|
c.applySessionEventsByAgentSessionID(session.AgentSessionID, remaining)
|
|
if refreshed, ok := c.get(session.RoomID, session.AgentSessionID); ok {
|
|
session = refreshed
|
|
}
|
|
if provenancePatch, ok := guidanceTurnCapabilityReferenceStatePatch(session, turnID, capabilityRefs); ok {
|
|
// Capability provenance is metadata on the existing turn, not a
|
|
// lifecycle event. Persist it through the reporter and let the
|
|
// post-commit canonical turn_update invalidate AgentGUI.
|
|
c.enqueueSessionStatePatchReport(ctx, session, provenancePatch)
|
|
}
|
|
logAgentSubmitTrace("runtime.exec.guidance", session, turnID, metadata, map[string]any{
|
|
"activity_event_count": len(events),
|
|
})
|
|
result := ExecResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Status: ExecStatusStarted,
|
|
TurnID: turnID,
|
|
Accepted: true,
|
|
SessionStatus: session.Status,
|
|
ProviderDispatch: providerDispatch,
|
|
}
|
|
if session.TurnLifecycle != nil {
|
|
result.TurnLifecycle = *session.TurnLifecycle
|
|
}
|
|
if session.SubmitAvailability != nil {
|
|
result.SubmitAvailability = *session.SubmitAvailability
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type GoalControlInput struct {
|
|
RoomID string
|
|
AgentSessionID string
|
|
Action GoalControlAction
|
|
Objective string
|
|
OperationID string
|
|
GoalRevision int64
|
|
RepairEpoch int64
|
|
SubmissionMetadata map[string]any
|
|
RequireLive bool
|
|
}
|
|
|
|
type GoalControlResult struct {
|
|
AgentSessionID string
|
|
// Goal is the fresh goal snapshot after the action (nil after clear).
|
|
Goal map[string]any
|
|
Evidence map[string]any
|
|
ProviderPhase string
|
|
ExecutionPending bool
|
|
}
|
|
|
|
// GoalControl performs a direct goal action (banner buttons) as a
|
|
// session-level control operation — like Cancel, it never opens a turn, so it
|
|
// works regardless of what is currently running.
|
|
func (c *Controller) GoalControl(ctx context.Context, input GoalControlInput) (GoalControlResult, error) {
|
|
releaseLifecycleLock, err := c.acquireLifecycleLockContext(ctx, input.RoomID, input.AgentSessionID)
|
|
if err != nil {
|
|
return GoalControlResult{}, err
|
|
}
|
|
defer releaseLifecycleLock()
|
|
session, adapter, err := c.sessionAndAdapter(input.RoomID, input.AgentSessionID)
|
|
if err != nil {
|
|
return GoalControlResult{}, err
|
|
}
|
|
if session.IsSideConversation() {
|
|
return GoalControlResult{}, ErrSideConversationUnsupported
|
|
}
|
|
goalAdapter, ok := adapter.(GoalAdapter)
|
|
if !ok {
|
|
return GoalControlResult{}, fmt.Errorf("agent provider does not support goals")
|
|
}
|
|
if input.RequireLive {
|
|
if probe, ok := adapter.(LiveSessionProbeAdapter); ok && !probe.HasLiveSession(session) {
|
|
return GoalControlResult{}, ErrSessionDisconnected
|
|
}
|
|
} else {
|
|
if err := c.ensureLiveAdapterSession(ctx, session, adapter); err != nil {
|
|
return GoalControlResult{}, err
|
|
}
|
|
}
|
|
adapterResult, err := goalAdapter.ApplyGoal(ctx, session, GoalApplyInput{
|
|
Action: input.Action, Objective: input.Objective,
|
|
OperationID: input.OperationID, Revision: input.GoalRevision, RepairEpoch: input.RepairEpoch,
|
|
SubmissionMetadata: cloneExecMetadata(input.SubmissionMetadata),
|
|
})
|
|
if err != nil {
|
|
slog.Warn("agent session goal control failed",
|
|
"event", "agent_session.goal_control.failed",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"action", string(input.Action),
|
|
"error", err.Error(),
|
|
)
|
|
return GoalControlResult{}, err
|
|
}
|
|
c.applySessionEventsByAgentSessionID(session.AgentSessionID, adapterResult.Events)
|
|
slog.Info("agent session goal control accepted",
|
|
"event", "agent_session.goal_control.accepted",
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"action", string(input.Action),
|
|
)
|
|
return GoalControlResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
Goal: goalAdapter.NormalizeGoalObservation(adapterResult.Observation),
|
|
Evidence: clonePayload(adapterResult.Evidence),
|
|
ProviderPhase: adapterResult.ProviderPhase,
|
|
ExecutionPending: adapterResult.ExecutionPending,
|
|
}, nil
|
|
}
|