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
706 lines
26 KiB
Go
706 lines
26 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
func (a *standardACPAdapter) Exec(
|
|
ctx context.Context,
|
|
session Session,
|
|
content []PromptContentBlock,
|
|
displayPrompt string,
|
|
turnID string,
|
|
emit EventSink,
|
|
emitCommands CommandSnapshotSink,
|
|
) ([]activityshared.Event, error) {
|
|
acpSession := a.getUsableSession(session.AgentSessionID)
|
|
if acpSession == nil || acpSession.client == nil {
|
|
return []activityshared.Event{standardACPRootProviderTurnCompletedEvent(
|
|
session,
|
|
turnID,
|
|
activityshared.TurnOutcomeFailed,
|
|
map[string]any{"error": ErrSessionDisconnected.Error()},
|
|
)}, ErrSessionDisconnected
|
|
}
|
|
session.ProviderSessionID = acpSession.providerSessionID
|
|
explicitDisplayPrompt, visibleText := explicitAndVisiblePromptText(content, displayPrompt)
|
|
mentionRoutingApplied, mentionRoutingSkills := tuttiMentionRoutingSkills(visibleText)
|
|
a.rememberSessionTurn(session.AgentSessionID, turnID)
|
|
normalizer := newACPTurnNormalizer()
|
|
var events []activityshared.Event
|
|
var eventsMu sync.Mutex
|
|
emitEvents := func(next []activityshared.Event) {
|
|
if len(next) == 0 {
|
|
return
|
|
}
|
|
eventsMu.Lock()
|
|
defer eventsMu.Unlock()
|
|
next = a.inputUnits.stamp(session.AgentSessionID, next)
|
|
next = a.stampTurnLifecycleSnapshots(acpSession, next)
|
|
events = append(events, next...)
|
|
if emit != nil {
|
|
emit(next)
|
|
}
|
|
}
|
|
snapshotEvents := func() []activityshared.Event {
|
|
eventsMu.Lock()
|
|
defer eventsMu.Unlock()
|
|
return append([]activityshared.Event(nil), events...)
|
|
}
|
|
if a.config.localToolBridge != nil {
|
|
deactivate := a.config.localToolBridge.ActivateTurn(session, turnID, emitEvents)
|
|
if deactivate != nil {
|
|
defer deactivate()
|
|
}
|
|
}
|
|
|
|
startEvents := []activityshared.Event{
|
|
newUserPromptActivityEvent(ctx, session, content, explicitDisplayPrompt, visibleText, turnID, nil),
|
|
newTurnActivityEvent(session, EventTurnStarted, turnID, SessionStatusWorking, "", "", nil),
|
|
standardACPRootProviderTurnStartedEvent(session, turnID),
|
|
}
|
|
emitEvents(startEvents)
|
|
|
|
providerContent, err := materializeProviderPromptImagesAtBoundary(ctx, content, a.promptImageMaterializer)
|
|
if err != nil {
|
|
outcome := activityshared.TurnOutcomeFailed
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, errPermissionRequestCanceled) {
|
|
outcome = activityshared.TurnOutcomeCanceled
|
|
terminalEvents = normalizer.FinishInterrupted(session, turnID, "interrupted")
|
|
}
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(
|
|
session,
|
|
turnID,
|
|
outcome,
|
|
map[string]any{"error": err.Error()},
|
|
))
|
|
emitEvents(terminalEvents)
|
|
// Standard ACP reports provider failures through lifecycle events. Return
|
|
// nil so the controller does not replay the already-emitted event batch.
|
|
return events, nil
|
|
}
|
|
acpPromptContent := promptContentForACP(providerContent)
|
|
if mentionRoutingApplied {
|
|
acpPromptContent = appendTuttiMentionRoutingPrompt(acpPromptContent, mentionRoutingSkills)
|
|
}
|
|
initialPromptContext := a.pendingInitialPromptContext(acpSession)
|
|
if initialPromptContext != "" {
|
|
acpPromptContent = append(acpPromptContent, map[string]any{
|
|
"type": "text",
|
|
"text": initialPromptContext,
|
|
})
|
|
}
|
|
// ACP v1 has no developer/system or synthetic-message channel. Keep the
|
|
// canonical Tutti-owned context in the provider-only prompt payload; the
|
|
// activity event above is still projected exclusively from the original
|
|
// user content.
|
|
acpPromptContent = appendTuttiModeHostContextPrompt(
|
|
acpPromptContent,
|
|
tuttiModeTurnSnapshotFromContext(ctx),
|
|
)
|
|
slog.Info("agent session ACP exec started",
|
|
"event", "agent_session.acp.exec.start",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"prompt_length", len(visibleText),
|
|
"mention_uri_count", len(extractMentionURIs(visibleText)),
|
|
"mention_routing_applied", mentionRoutingApplied,
|
|
"mention_routing_skills", mentionRoutingSkills,
|
|
)
|
|
if mentionRoutingApplied {
|
|
slog.Info("agent session ACP mention routing applied",
|
|
"event", "agent_session.acp.mention_routing.applied",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"mention_routing_skills", mentionRoutingSkills,
|
|
"prompt_length", len(visibleText),
|
|
)
|
|
}
|
|
|
|
promptParams := acpPromptContent
|
|
autoContinueAttempts := 0
|
|
activePrompt := a.beginStandardACPPrompt(acpSession)
|
|
defer a.finishStandardACPPrompt(acpSession, activePrompt)
|
|
execLoop:
|
|
for {
|
|
result, err := acpSession.client.Call(ctx, acpMethodPrompt, map[string]any{
|
|
"sessionId": acpSession.providerSessionID,
|
|
"prompt": promptParams,
|
|
}, func(ctx context.Context, message acpMessage) error {
|
|
endInputUnit := a.inputUnits.begin(ctx, session.AgentSessionID)
|
|
defer endInputUnit()
|
|
slog.Debug("agent session ACP exec received message",
|
|
"event", "agent_session.acp.exec.message",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"message_method", message.Method,
|
|
"message_id", rawMessageLogValue(message.ID),
|
|
)
|
|
next, err := a.handleACPMessage(ctx, acpSession.client, session, turnID, message, normalizer, emitEvents, emitCommands)
|
|
if slog.Default().Enabled(ctx, slog.LevelDebug) {
|
|
slog.Debug("agent session ACP exec handled message",
|
|
"event", "agent_session.acp.exec.message_handled",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"message_method", message.Method,
|
|
"event_count", len(next),
|
|
"event_type_counts", activityEventTypeCounts(next),
|
|
"error", errString(err),
|
|
)
|
|
}
|
|
emitEvents(next)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
cancelRequested := a.standardACPPromptCancelRequested(acpSession, activePrompt)
|
|
if err != nil {
|
|
emittedEvents := snapshotEvents()
|
|
slog.Warn("agent session ACP exec call failed",
|
|
"event", "agent_session.acp.exec.call_failed",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"emitted_event_count", len(emittedEvents),
|
|
"emitted_event_type_counts", activityEventTypeCounts(emittedEvents),
|
|
"error", err.Error(),
|
|
)
|
|
if cancelRequested || errors.Is(err, context.Canceled) || errors.Is(err, errPermissionRequestCanceled) {
|
|
terminalEvents := normalizer.FinishInterrupted(session, turnID, "interrupted")
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeCanceled, map[string]any{
|
|
"error": err.Error(),
|
|
}))
|
|
terminalEvents = stampProviderInputUnitFromError(err, terminalEvents)
|
|
emitEvents(terminalEvents)
|
|
} else if planLimitMessage, ok := acpProviderPlanLimitMessage(err); ok {
|
|
// Match cursor-agent's soft plan-gate path: show the provider
|
|
// copy as a warning notice and settle the turn successfully so
|
|
// the next send is not a scary red turn-failed card.
|
|
if notice, ok := acpPlanLimitNoticeEvent(session, turnID, planLimitMessage); ok {
|
|
emitEvents([]activityshared.Event{notice})
|
|
}
|
|
terminalEvents := normalizer.FinishCompleted(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeCompleted, map[string]any{
|
|
"stopReason": "end_turn",
|
|
"planLimit": true,
|
|
}))
|
|
terminalEvents = stampProviderInputUnitFromError(err, terminalEvents)
|
|
emitEvents(terminalEvents)
|
|
slog.Info("agent session ACP exec settled plan-limit without failure card",
|
|
"event", "agent_session.acp.exec.plan_limit",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"plan_limit_message", planLimitMessage,
|
|
)
|
|
} else {
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeFailed, map[string]any{
|
|
"error": err.Error(),
|
|
}))
|
|
terminalEvents = stampProviderInputUnitFromError(err, terminalEvents)
|
|
emitEvents(terminalEvents)
|
|
}
|
|
return snapshotEvents(), nil
|
|
}
|
|
if initialPromptContext != "" {
|
|
a.consumeInitialPromptContext(acpSession)
|
|
initialPromptContext = ""
|
|
}
|
|
|
|
stopReason := acpStopReason(result)
|
|
normalizer.ApplyAssistantFinalText(acpPromptResultAssistantText(result))
|
|
emittedEvents := snapshotEvents()
|
|
slog.Info("agent session ACP exec call completed",
|
|
"event", "agent_session.acp.exec.call_completed",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"stop_reason", firstNonEmpty(stopReason, "end_turn"),
|
|
"auto_continue_attempts", autoContinueAttempts,
|
|
"emitted_event_count", len(emittedEvents),
|
|
"emitted_event_type_counts", activityEventTypeCounts(emittedEvents),
|
|
)
|
|
if cancelRequested {
|
|
terminalEvents := normalizer.FinishInterrupted(session, turnID, "canceled")
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeCanceled, map[string]any{
|
|
"stopReason": firstNonEmpty(stopReason, "canceled"),
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
break execLoop
|
|
}
|
|
if a.config.autoContinueRetriableTurnError && acpStopReasonEndsTurnNormally(stopReason) {
|
|
assistantText := normalizer.CurrentAssistantText()
|
|
if errLine, ok := acpRetriableTurnTailError(assistantText); ok {
|
|
if autoContinueAttempts < acpAutoContinueMaxAttempts {
|
|
autoContinueAttempts++
|
|
hasUsefulProgress := acpAutoContinueHasUsefulProgress(assistantText, normalizer.SeenToolCallCount())
|
|
// Close out the error-text segment so the continuation
|
|
// streams into a fresh message instead of appending to it.
|
|
emitEvents(normalizer.Finish(session, turnID, messageStreamStateCompleted))
|
|
if notice, ok := acpAutoContinueNoticeEvent(session, turnID, errLine, autoContinueAttempts); ok {
|
|
emitEvents([]activityshared.Event{notice})
|
|
}
|
|
slog.Warn("agent session ACP auto-continue after retriable turn error",
|
|
"event", "agent_session.acp.exec.auto_continue",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"attempt", autoContinueAttempts,
|
|
"max_attempts", acpAutoContinueMaxAttempts,
|
|
"error_line", errLine,
|
|
"has_useful_progress", hasUsefulProgress,
|
|
)
|
|
promptParams = acpAutoContinuePromptContent(hasUsefulProgress)
|
|
continue execLoop
|
|
}
|
|
// The retries were cut short too: surface the turn as failed
|
|
// instead of a silent "completed" that strands the conversation.
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeFailed, map[string]any{
|
|
"error": errLine,
|
|
"stopReason": firstNonEmpty(stopReason, "end_turn"),
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
slog.Warn("agent session ACP auto-continue attempts exhausted",
|
|
"event", "agent_session.acp.exec.auto_continue_exhausted",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"attempts", autoContinueAttempts,
|
|
"error_line", errLine,
|
|
)
|
|
break execLoop
|
|
}
|
|
}
|
|
switch stopReason {
|
|
case "canceled":
|
|
terminalEvents := normalizer.FinishInterrupted(session, turnID, stopReason)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeCanceled, map[string]any{
|
|
"stopReason": stopReason,
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
case "refusal", "max_tokens", "max_turn_requests":
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeFailed, map[string]any{
|
|
"stopReason": stopReason,
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
default:
|
|
if !normalizer.HasObservableOutput() {
|
|
const emptyResponseError = "provider_empty_response: ACP agent ended the turn without assistant output or tool activity"
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeFailed, map[string]any{
|
|
"error": emptyResponseError,
|
|
"stopReason": firstNonEmpty(stopReason, "end_turn"),
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
slog.Warn("agent session ACP turn ended without observable output",
|
|
"event", "agent_session.acp.exec.empty_response",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"stop_reason", firstNonEmpty(stopReason, "end_turn"),
|
|
)
|
|
break
|
|
}
|
|
terminalEvents := normalizer.FinishCompleted(session, turnID)
|
|
terminalEvents = append(terminalEvents, standardACPRootProviderTurnCompletedEvent(session, turnID, activityshared.TurnOutcomeCompleted, map[string]any{
|
|
"stopReason": firstNonEmpty(stopReason, "end_turn"),
|
|
}))
|
|
emitEvents(terminalEvents)
|
|
}
|
|
break execLoop
|
|
}
|
|
finalEvents := snapshotEvents()
|
|
slog.Info("agent session ACP exec finished",
|
|
"event", "agent_session.acp.exec.finished",
|
|
"provider", a.config.provider,
|
|
"adapter", a.config.adapterName,
|
|
"room_id", session.RoomID,
|
|
"agent_session_id", session.AgentSessionID,
|
|
"provider_session_id", session.ProviderSessionID,
|
|
"turn_id", turnID,
|
|
"final_event_count", len(finalEvents),
|
|
"final_event_type_counts", activityEventTypeCounts(finalEvents),
|
|
)
|
|
return finalEvents, nil
|
|
}
|
|
|
|
func (a *standardACPAdapter) Cancel(ctx context.Context, session Session, _ string) ([]activityshared.Event, error) {
|
|
acpSession := a.getSession(session.AgentSessionID)
|
|
if acpSession == nil || acpSession.client == nil {
|
|
return nil, ErrSessionNoActiveTurn
|
|
}
|
|
activePrompt := a.beginStandardACPPromptCancelDelivery(acpSession)
|
|
if err := acpSession.client.Notify(ctx, acpMethodCancel, map[string]any{
|
|
"sessionId": acpSession.providerSessionID,
|
|
}); err != nil {
|
|
a.finishStandardACPPromptCancelDelivery(acpSession, activePrompt, false)
|
|
return nil, err
|
|
}
|
|
a.finishStandardACPPromptCancelDelivery(acpSession, activePrompt, true)
|
|
a.rejectPendingApprovals(session.AgentSessionID, errPermissionRequestCanceled)
|
|
if activePrompt == nil {
|
|
return nil, nil
|
|
}
|
|
if a.waitForStandardACPPromptDrain(ctx, activePrompt, standardACPCancelDrainTimeout) {
|
|
return nil, nil
|
|
}
|
|
|
|
// A provider that acknowledges session/cancel without settling the active
|
|
// session/prompt must not receive another prompt on the same process. Drop
|
|
// only the transport (never session/close, which could destroy resumable
|
|
// history); the next Host command will restore the provider session on a
|
|
// fresh process.
|
|
if err := a.releaseCanceledStandardACPSession(session, acpSession); err != nil {
|
|
return nil, err
|
|
}
|
|
_ = a.waitForStandardACPPromptDrain(context.Background(), activePrompt, acpCloseGraceTimeout)
|
|
return nil, nil
|
|
}
|
|
|
|
func (a *standardACPAdapter) beginStandardACPPrompt(session *standardACPSession) *standardACPActivePrompt {
|
|
active := &standardACPActivePrompt{done: make(chan struct{})}
|
|
a.mu.Lock()
|
|
if session != nil {
|
|
session.activePrompt = active
|
|
}
|
|
a.mu.Unlock()
|
|
return active
|
|
}
|
|
|
|
func (a *standardACPAdapter) finishStandardACPPrompt(session *standardACPSession, active *standardACPActivePrompt) {
|
|
if active == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
if session != nil && session.activePrompt == active {
|
|
session.activePrompt = nil
|
|
}
|
|
close(active.done)
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *standardACPAdapter) beginStandardACPPromptCancelDelivery(session *standardACPSession) *standardACPActivePrompt {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if session == nil || session.activePrompt == nil {
|
|
return nil
|
|
}
|
|
active := session.activePrompt
|
|
if active.cancelDeliveryDone == nil {
|
|
active.cancelDeliveryDone = make(chan struct{})
|
|
}
|
|
return active
|
|
}
|
|
|
|
func (a *standardACPAdapter) finishStandardACPPromptCancelDelivery(
|
|
session *standardACPSession,
|
|
active *standardACPActivePrompt,
|
|
accepted bool,
|
|
) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if session == nil || active == nil || session.activePrompt != active {
|
|
return
|
|
}
|
|
active.cancelRequested = accepted
|
|
if active.cancelDeliveryDone != nil {
|
|
close(active.cancelDeliveryDone)
|
|
active.cancelDeliveryDone = nil
|
|
}
|
|
}
|
|
|
|
func (a *standardACPAdapter) standardACPPromptCancelRequested(session *standardACPSession, active *standardACPActivePrompt) bool {
|
|
for {
|
|
a.mu.Lock()
|
|
if session == nil || session.activePrompt != active || active == nil {
|
|
a.mu.Unlock()
|
|
return false
|
|
}
|
|
deliveryDone := active.cancelDeliveryDone
|
|
cancelRequested := active.cancelRequested
|
|
a.mu.Unlock()
|
|
if deliveryDone == nil {
|
|
return cancelRequested
|
|
}
|
|
<-deliveryDone
|
|
}
|
|
}
|
|
|
|
func (*standardACPAdapter) waitForStandardACPPromptDrain(ctx context.Context, active *standardACPActivePrompt, timeout time.Duration) bool {
|
|
if active == nil {
|
|
return true
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-active.done:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-timer.C:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (a *standardACPAdapter) releaseCanceledStandardACPSession(session Session, acpSession *standardACPSession) error {
|
|
if acpSession == nil || acpSession.client == nil {
|
|
return nil
|
|
}
|
|
agentSessionID := strings.TrimSpace(session.AgentSessionID)
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] != acpSession {
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
acpSession.releasing = true
|
|
acpSession.client.SetMessageHandler(nil)
|
|
a.mu.Unlock()
|
|
|
|
if err := acpSession.client.Close(); err != nil {
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] == acpSession {
|
|
acpSession.releasing = false
|
|
acpSession.releaseFailed = true
|
|
}
|
|
a.mu.Unlock()
|
|
a.logACPCloseDiagnostics("cancel_drain.transport_close.failed", session, acpSession, err)
|
|
return err
|
|
}
|
|
acpSession.releaseLocalTools()
|
|
a.mu.Lock()
|
|
if a.sessions[agentSessionID] == acpSession {
|
|
delete(a.sessions, agentSessionID)
|
|
}
|
|
a.mu.Unlock()
|
|
a.logACPCloseDiagnostics("cancel_drain.transport_close.succeeded", session, acpSession, nil)
|
|
return nil
|
|
}
|
|
|
|
func standardACPRootProviderTurnStartedEvent(session Session, rootTurnID string) activityshared.Event {
|
|
rootTurnID = strings.TrimSpace(rootTurnID)
|
|
ctx, ok := activityEventContext(session, "standard-acp:provider-turn-started:"+rootTurnID, rootTurnID)
|
|
if !ok {
|
|
return activityshared.Event{}
|
|
}
|
|
return activityshared.NewRootProviderTurnStarted(ctx, rootTurnID, rootTurnID)
|
|
}
|
|
|
|
func standardACPRootProviderTurnCompletedEvent(
|
|
session Session,
|
|
rootTurnID string,
|
|
outcome activityshared.TurnOutcome,
|
|
metadata map[string]any,
|
|
) activityshared.Event {
|
|
rootTurnID = strings.TrimSpace(rootTurnID)
|
|
ctx, ok := activityEventContext(session, "standard-acp:provider-turn-completed:"+rootTurnID, rootTurnID)
|
|
if !ok {
|
|
return activityshared.Event{}
|
|
}
|
|
event := activityshared.NewRootProviderTurnCompleted(ctx, rootTurnID, rootTurnID, outcome)
|
|
event.Payload.Metadata = clonePayload(metadata)
|
|
return event
|
|
}
|
|
|
|
func (a *standardACPAdapter) submitPermissionOption(ctx context.Context, session Session, input PermissionOptionInput) (string, error) {
|
|
requestID := strings.TrimSpace(input.RequestID)
|
|
optionID := strings.TrimSpace(input.OptionID)
|
|
if requestID == "" {
|
|
return "", fmt.Errorf("%w: permission request id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
if optionID == "" {
|
|
return "", fmt.Errorf("%w: permission option id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
pending := a.getPendingApproval(session.AgentSessionID, input.TurnID, requestID)
|
|
if pending == nil {
|
|
return "", fmt.Errorf("%w: permission request %q", ErrInteractiveRequestNotLive, requestID)
|
|
}
|
|
if pending.callType != "approval" {
|
|
return "", fmt.Errorf("%w: request %q requires interactive submission", ErrInteractiveResponseInvalid, requestID)
|
|
}
|
|
resolvedOptionID, ok := pending.resolvePermissionOptionID(optionID)
|
|
if !ok {
|
|
return "", fmt.Errorf(
|
|
"%w: permission option %q is not available for request %q",
|
|
ErrInteractiveResponseInvalid,
|
|
optionID,
|
|
requestID,
|
|
)
|
|
}
|
|
if _, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{
|
|
optionID: resolvedOptionID,
|
|
result: acpPermissionResponseResult(resolvedOptionID),
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
if state, err := pending.waitForDisposition(ctx); err != nil {
|
|
return "", err
|
|
} else if state != pendingInteractiveRequestStateAnswered {
|
|
return "", interactiveDispositionError(requestID, state)
|
|
}
|
|
return resolvedOptionID, nil
|
|
}
|
|
|
|
func (a *standardACPAdapter) 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)
|
|
}
|
|
pending := a.getPendingApproval(session.AgentSessionID, 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, err := a.submitPermissionOption(ctx, session, PermissionOptionInput{
|
|
RoomID: input.RoomID,
|
|
AgentSessionID: input.AgentSessionID,
|
|
TurnID: turnID,
|
|
RequestID: requestID,
|
|
OptionID: optionID,
|
|
})
|
|
if err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Accepted: true,
|
|
OptionID: resolvedOptionID,
|
|
Disposition: InteractiveDispositionAnswered,
|
|
}, nil
|
|
}
|
|
optionID := strings.TrimSpace(input.OptionID)
|
|
action := strings.TrimSpace(input.Action)
|
|
payload := clonePayload(input.Payload)
|
|
if pending.providerMethod != "" {
|
|
result, resolvedOptionID, err := cursorNativeInteractiveResult(pending, action, optionID, payload)
|
|
if err != nil {
|
|
pending.supersede(err)
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Disposition: InteractiveDispositionSuperseded,
|
|
}, err
|
|
}
|
|
optionID = resolvedOptionID
|
|
if _, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{
|
|
optionID: optionID,
|
|
action: action,
|
|
payload: payload,
|
|
result: result,
|
|
}); 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: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Accepted: true,
|
|
OptionID: optionID,
|
|
Disposition: InteractiveDispositionAnswered,
|
|
}, nil
|
|
}
|
|
result := acpInteractiveResponseResult(action, optionID, payload)
|
|
if err := ctx.Err(); err != nil {
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
if pending.kind == "ask-user" && len(pending.options) > 0 {
|
|
resolvedOptionID, err := acpAskUserPermissionOptionID(pending, optionID, action, payload)
|
|
if err != nil {
|
|
err = fmt.Errorf("%w: %v", ErrInteractiveResponseInvalid, err)
|
|
pending.supersede(err)
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Disposition: InteractiveDispositionSuperseded,
|
|
}, err
|
|
}
|
|
optionID = resolvedOptionID
|
|
result = acpPermissionResponseResult(resolvedOptionID)
|
|
}
|
|
if _, err := pending.dispatchResponse(ctx, pendingInteractiveResponse{
|
|
optionID: optionID,
|
|
action: action,
|
|
payload: payload,
|
|
result: result,
|
|
}); 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: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Accepted: true,
|
|
Disposition: InteractiveDispositionAnswered,
|
|
}, nil
|
|
}
|
|
|
|
func (a *standardACPAdapter) InteractiveDisposition(session Session, turnID string, requestID string) InteractiveDisposition {
|
|
if pending := a.getPendingApproval(session.AgentSessionID, turnID, requestID); pending != nil {
|
|
return runtimeInteractiveDisposition(pending)
|
|
}
|
|
return a.terminalInteractiveDisposition(session.AgentSessionID, turnID, requestID)
|
|
}
|