mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-06 15:07:58 +00:00
Require session ID throughout the store update API, constrain both in-memory and SQLite lookups, and strictly parse API message IDs. This prevents cross-session mutation and busy-lock bypass. Assisted-By: docker-agent
891 lines
31 KiB
Go
891 lines
31 KiB
Go
package runtime
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/docker/docker-agent/pkg/api"
|
|
"github.com/docker/docker-agent/pkg/chat"
|
|
"github.com/docker/docker-agent/pkg/config/latest"
|
|
"github.com/docker/docker-agent/pkg/effort"
|
|
"github.com/docker/docker-agent/pkg/session"
|
|
"github.com/docker/docker-agent/pkg/sessiontitle"
|
|
"github.com/docker/docker-agent/pkg/team"
|
|
"github.com/docker/docker-agent/pkg/tools"
|
|
"github.com/docker/docker-agent/pkg/tools/builtin/skills"
|
|
"github.com/docker/docker-agent/pkg/tools/mcp/oauthflow"
|
|
)
|
|
|
|
// RemoteRuntime implements the Runtime interface using a remote client.
|
|
// It works with any client that implements the RemoteClient interface,
|
|
// including both HTTP (Client) and Connect-RPC (ConnectRPCClient) clients.
|
|
type RemoteRuntime struct {
|
|
client RemoteClient
|
|
currentAgent string
|
|
agentFilename string
|
|
sessionID string
|
|
team *team.Team
|
|
pendingOAuthElicitation *ElicitationRequestEvent
|
|
|
|
// pendingModelOverride is the model ref to apply to the current agent
|
|
// on the next [RemoteRuntime.RunStream] call. It is set by
|
|
// [RemoteRuntime.SetAgentModel] and consumed once the override has
|
|
// been forwarded to the server, which persists it server-side as the
|
|
// session's per-agent override.
|
|
pendingMu sync.Mutex
|
|
pendingModelOverride string
|
|
|
|
// resolvedDefault caches the team's default agent name fetched from the
|
|
// server after a successful lookup, so [CurrentAgentName] stays an O(1)
|
|
// field read when no specific agent has been selected.
|
|
resolvedDefault string
|
|
resolvedDefaultMu sync.Mutex
|
|
}
|
|
|
|
// RemoteRuntimeOption is a function for configuring the RemoteRuntime
|
|
type RemoteRuntimeOption func(*RemoteRuntime)
|
|
|
|
// WithRemoteCurrentAgent sets the current agent name
|
|
func WithRemoteCurrentAgent(agentName string) RemoteRuntimeOption {
|
|
return func(r *RemoteRuntime) {
|
|
r.currentAgent = agentName
|
|
}
|
|
}
|
|
|
|
// WithRemoteAgentFilename sets the agent filename to use with the remote API
|
|
func WithRemoteAgentFilename(filename string) RemoteRuntimeOption {
|
|
return func(r *RemoteRuntime) {
|
|
r.agentFilename = filename
|
|
}
|
|
}
|
|
|
|
// NewRemoteRuntime creates a new remote runtime that implements the Runtime interface.
|
|
// It accepts any client that implements the RemoteClient interface.
|
|
func NewRemoteRuntime(client RemoteClient, opts ...RemoteRuntimeOption) (*RemoteRuntime, error) {
|
|
if client == nil {
|
|
return nil, errors.New("client cannot be nil")
|
|
}
|
|
|
|
r := &RemoteRuntime{
|
|
client: client,
|
|
agentFilename: "agent.yaml",
|
|
team: team.New(),
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(r)
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// resolvedAgent returns the active agent's name and config from the remote
|
|
// team. When no specific agent has been selected, both come from the team's
|
|
// first agent — the server owns the notion of "default agent" instead of the
|
|
// client hard-coding the historical "root" name.
|
|
func (r *RemoteRuntime) resolvedAgent(ctx context.Context) (string, latest.AgentConfig) {
|
|
cfg := r.readCurrentAgentConfig(ctx)
|
|
return cmp.Or(r.currentAgent, cfg.Name), cfg
|
|
}
|
|
|
|
// CurrentAgentName returns the name of the currently active agent.
|
|
// When no specific agent has been selected, it falls back to the first agent
|
|
// declared by the remote team config. The remote lookup happens at most once;
|
|
// the result is cached so subsequent calls are O(1).
|
|
func (r *RemoteRuntime) CurrentAgentName(ctx context.Context) string {
|
|
if r.currentAgent != "" {
|
|
return r.currentAgent
|
|
}
|
|
r.resolvedDefaultMu.Lock()
|
|
defer r.resolvedDefaultMu.Unlock()
|
|
if r.resolvedDefault != "" {
|
|
return r.resolvedDefault
|
|
}
|
|
// First successful call performs and caches the remote lookup; detach
|
|
// cancellation so a cancelled caller cannot poison the cached default.
|
|
name, _ := r.resolvedAgent(context.WithoutCancel(ctx))
|
|
if name != "" {
|
|
r.resolvedDefault = name
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (r *RemoteRuntime) CurrentAgentInfo(ctx context.Context) CurrentAgentInfo {
|
|
name, cfg := r.resolvedAgent(ctx)
|
|
return CurrentAgentInfo{
|
|
Name: name,
|
|
Description: cfg.Description,
|
|
Commands: cfg.Commands,
|
|
}
|
|
}
|
|
|
|
// SetCurrentAgent sets the currently active agent for subsequent user messages.
|
|
// It validates the name against the remote team config; an unknown agent is
|
|
// rejected so callers see the same behaviour as LocalRuntime. A failure to
|
|
// fetch the team config (network error, auth failure, missing remote) is
|
|
// propagated rather than silently accepted — the whole point of this check
|
|
// is closing that silent-breakage gap.
|
|
func (r *RemoteRuntime) SetCurrentAgent(ctx context.Context, agentName string) error {
|
|
cfg, err := r.client.GetAgent(ctx, r.agentFilename)
|
|
if err != nil {
|
|
return fmt.Errorf("validate agent %q against remote team: %w", agentName, err)
|
|
}
|
|
found := false
|
|
for _, a := range cfg.Agents {
|
|
if a.Name == agentName {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("agent %q not found in remote team", agentName)
|
|
}
|
|
r.currentAgent = agentName
|
|
slog.DebugContext(ctx, "Switched current agent (remote)", "agent", agentName)
|
|
return nil
|
|
}
|
|
|
|
// CurrentAgentTools returns the tools for the current agent from the session.
|
|
func (r *RemoteRuntime) CurrentAgentTools(ctx context.Context) ([]tools.Tool, error) {
|
|
if r.sessionID == "" {
|
|
return nil, nil
|
|
}
|
|
return r.client.GetSessionTools(ctx, r.sessionID)
|
|
}
|
|
|
|
// CurrentAgentToolsetStatuses is not implemented for remote runtimes; the
|
|
// remote server owns the toolset lifecycle. Returns an empty slice so
|
|
// callers (TUI) can show an explanatory empty state without erroring.
|
|
func (r *RemoteRuntime) CurrentAgentToolsetStatuses() []tools.ToolsetStatus {
|
|
return nil
|
|
}
|
|
|
|
// RestartToolset restarts a toolset on the remote server.
|
|
func (r *RemoteRuntime) RestartToolset(ctx context.Context, toolsetName string) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.RestartSessionToolset(ctx, r.sessionID, toolsetName)
|
|
}
|
|
|
|
// EmitStartupInfo emits initial agent, team, and toolset information
|
|
func (r *RemoteRuntime) EmitStartupInfo(ctx context.Context, _ *session.Session, events EventSink) {
|
|
agentName, cfg := r.resolvedAgent(ctx)
|
|
|
|
events.Emit(AgentInfo(agentName, cfg.Model, cfg.Description, cfg.WelcomeMessage))
|
|
events.Emit(TeamInfo(r.agentDetailsFromConfig(ctx), agentName))
|
|
|
|
// Emit a loading indicator while we fetch the real tool count from the server.
|
|
if len(cfg.Toolsets) > 0 {
|
|
events.Emit(ToolsetInfo(0, true, agentName))
|
|
}
|
|
|
|
toolCount, err := r.client.GetAgentToolCount(ctx, r.agentFilename, agentName)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Failed to get agent tool count", "error", err)
|
|
return
|
|
}
|
|
|
|
events.Emit(ToolsetInfo(toolCount, false, agentName))
|
|
}
|
|
|
|
// EmitAgentInfo emits agent and team info without re-fetching tool counts.
|
|
func (r *RemoteRuntime) EmitAgentInfo(ctx context.Context, events EventSink) {
|
|
agentName, cfg := r.resolvedAgent(ctx)
|
|
events.Emit(AgentInfo(agentName, cfg.Model, cfg.Description, cfg.WelcomeMessage))
|
|
events.Emit(TeamInfo(r.agentDetailsFromConfig(ctx), agentName))
|
|
}
|
|
|
|
func (r *RemoteRuntime) agentDetailsFromConfig(ctx context.Context) []AgentDetails {
|
|
cfg, err := r.client.GetAgent(ctx, r.agentFilename)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var details []AgentDetails
|
|
for _, agent := range cfg.Agents {
|
|
info := AgentDetails{
|
|
Name: agent.Name,
|
|
Description: agent.Description,
|
|
Commands: agent.Commands,
|
|
}
|
|
|
|
if provider, model, found := strings.Cut(agent.Model, "/"); found {
|
|
info.Provider = provider
|
|
info.Model = model
|
|
} else {
|
|
info.Model = agent.Model
|
|
}
|
|
|
|
details = append(details, info)
|
|
}
|
|
|
|
return details
|
|
}
|
|
|
|
// readCurrentAgentConfig fetches the active agent's config from the server.
|
|
// When no specific agent has been selected, it falls back to the first agent
|
|
// in the team — letting the server own the notion of "default agent" instead
|
|
// of hard-coding the historical "root" name.
|
|
func (r *RemoteRuntime) readCurrentAgentConfig(ctx context.Context) latest.AgentConfig {
|
|
cfg, err := r.client.GetAgent(ctx, r.agentFilename)
|
|
if err != nil || len(cfg.Agents) == 0 {
|
|
return latest.AgentConfig{}
|
|
}
|
|
|
|
if r.currentAgent == "" {
|
|
return cfg.Agents[0]
|
|
}
|
|
|
|
for _, agent := range cfg.Agents {
|
|
if agent.Name == r.currentAgent {
|
|
return agent
|
|
}
|
|
}
|
|
|
|
return latest.AgentConfig{}
|
|
}
|
|
|
|
// RunStream starts the agent's interaction loop and returns a channel of events
|
|
func (r *RemoteRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan Event {
|
|
slog.DebugContext(ctx, "Starting remote runtime stream", "agent", r.currentAgent, "session_id", r.sessionID)
|
|
events := make(chan Event, defaultEventChannelCapacity)
|
|
|
|
go func() {
|
|
defer close(events)
|
|
|
|
messages := r.convertSessionMessages(sess)
|
|
r.sessionID = sess.ID
|
|
|
|
// Snapshot the queued override but do NOT clear it yet: if the
|
|
// request fails before the server can persist it, clearing here
|
|
// would silently drop the user's switch. We only clear after the
|
|
// server has accepted the request (i.e. RunAgent returned a stream).
|
|
r.pendingMu.Lock()
|
|
model := r.pendingModelOverride
|
|
r.pendingMu.Unlock()
|
|
|
|
var streamChan <-chan Event
|
|
var err error
|
|
|
|
if r.currentAgent != "" {
|
|
streamChan, err = r.client.RunAgentWithAgentName(ctx, r.sessionID, r.agentFilename, r.currentAgent, messages, model)
|
|
} else {
|
|
streamChan, err = r.client.RunAgent(ctx, r.sessionID, r.agentFilename, messages, model)
|
|
}
|
|
|
|
if err != nil {
|
|
events <- Error(fmt.Sprintf("failed to start remote agent: %v", err))
|
|
return
|
|
}
|
|
|
|
// Server accepted the request, so the override (if any) has been
|
|
// forwarded; clear it but only if no concurrent SetAgentModel
|
|
// queued a newer ref while we were dispatching.
|
|
if model != "" {
|
|
r.pendingMu.Lock()
|
|
if r.pendingModelOverride == model {
|
|
r.pendingModelOverride = ""
|
|
}
|
|
r.pendingMu.Unlock()
|
|
}
|
|
|
|
// Consume events from the agent stream
|
|
for streamEvent := range streamChan {
|
|
if elicitationRequest, ok := streamEvent.(*ElicitationRequestEvent); ok {
|
|
r.pendingOAuthElicitation = elicitationRequest
|
|
}
|
|
events <- streamEvent
|
|
}
|
|
}()
|
|
|
|
return events
|
|
}
|
|
|
|
// Run starts the agent's interaction loop and returns the final messages
|
|
func (r *RemoteRuntime) Run(ctx context.Context, sess *session.Session) ([]session.Message, error) {
|
|
eventsChan := r.RunStream(ctx, sess)
|
|
|
|
for event := range eventsChan {
|
|
if errEvent, ok := event.(*ErrorEvent); ok {
|
|
return nil, fmt.Errorf("%s", errEvent.Error)
|
|
}
|
|
}
|
|
|
|
return sess.GetAllMessages(), nil
|
|
}
|
|
|
|
// Steer enqueues a user message for mid-turn injection into the running
|
|
// agent loop on the remote server.
|
|
func (r *RemoteRuntime) Steer(ctx context.Context, msg QueuedMessage) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.SteerSession(ctx, r.sessionID, []api.Message{
|
|
{Content: msg.Content, MultiContent: msg.MultiContent},
|
|
})
|
|
}
|
|
|
|
// FollowUp enqueues a message for end-of-turn processing on the remote server.
|
|
func (r *RemoteRuntime) FollowUp(ctx context.Context, msg QueuedMessage) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.FollowUpSession(ctx, r.sessionID, []api.Message{
|
|
{Content: msg.Content, MultiContent: msg.MultiContent},
|
|
})
|
|
}
|
|
|
|
func (r *RemoteRuntime) QueueStatus() QueueStatus {
|
|
return QueueStatus{}
|
|
}
|
|
|
|
// Resume allows resuming execution after user confirmation
|
|
func (r *RemoteRuntime) Resume(ctx context.Context, req ResumeRequest) {
|
|
slog.DebugContext(ctx, "Resuming remote runtime", "agent", r.currentAgent, "type", req.Type, "reason", req.Reason, "tool_name", req.ToolName, "session_id", r.sessionID)
|
|
|
|
if r.sessionID == "" {
|
|
slog.ErrorContext(ctx, "Cannot resume: no session ID available")
|
|
return
|
|
}
|
|
|
|
if err := r.client.ResumeSession(ctx, r.sessionID, string(req.Type), req.Reason, req.ToolName); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to resume remote session", "error", err, "session_id", r.sessionID)
|
|
}
|
|
}
|
|
|
|
// Summarize generates a summary for the session by compacting it server-side.
|
|
func (r *RemoteRuntime) Summarize(ctx context.Context, sess *session.Session, _ string, sink EventSink) {
|
|
if r.sessionID == "" {
|
|
sink.Emit(SessionSummary(sess.ID, "No active session to summarize", r.currentAgent, 0, 0, "", nil))
|
|
return
|
|
}
|
|
if err := r.client.CompactSession(ctx, r.sessionID); err != nil {
|
|
slog.WarnContext(ctx, "Failed to compact session", "error", err)
|
|
sink.Emit(SessionSummary(sess.ID, fmt.Sprintf("Compaction failed: %v", err), r.currentAgent, 0, 0, "", nil))
|
|
return
|
|
}
|
|
sink.Emit(SessionSummary(sess.ID, "Session compacted successfully", r.currentAgent, 0, 0, "", nil))
|
|
}
|
|
|
|
func (r *RemoteRuntime) convertSessionMessages(sess *session.Session) []api.Message {
|
|
sessionMessages := sess.GetAllMessages()
|
|
messages := make([]api.Message, 0, len(sessionMessages))
|
|
|
|
for i := range sessionMessages {
|
|
if sessionMessages[i].Message.Role == chat.MessageRoleUser || sessionMessages[i].Message.Role == chat.MessageRoleAssistant {
|
|
messages = append(messages, api.Message{
|
|
Role: sessionMessages[i].Message.Role,
|
|
Content: sessionMessages[i].Message.Content,
|
|
})
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
// ResumeElicitation sends an elicitation response back to a waiting elicitation request
|
|
func (r *RemoteRuntime) ResumeElicitation(ctx context.Context, action tools.ElicitationAction, content map[string]any, elicitationID ...string) error {
|
|
id := firstElicitationID(elicitationID)
|
|
slog.DebugContext(ctx, "Resuming remote runtime with elicitation response", "agent", r.currentAgent, "action", action, "session_id", r.sessionID, "elicitation_id", id)
|
|
|
|
err := r.handleOAuthElicitation(ctx, r.pendingOAuthElicitation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := r.client.ResumeElicitation(ctx, r.sessionID, action, content, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *RemoteRuntime) handleOAuthElicitation(ctx context.Context, req *ElicitationRequestEvent) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
|
|
slog.DebugContext(ctx, "Handling OAuth elicitation request", "server_url", req.Meta["docker-agent/server_url"])
|
|
|
|
serverURL, ok := req.Meta["docker-agent/server_url"].(string)
|
|
if !ok {
|
|
err := errors.New("server_url missing from elicitation metadata")
|
|
slog.ErrorContext(ctx, "Failed to extract server_url", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return err
|
|
}
|
|
|
|
authServerMetadata, ok := req.Meta["auth_server_metadata"].(map[string]any)
|
|
if !ok {
|
|
err := errors.New("auth_server_metadata missing from elicitation metadata")
|
|
slog.ErrorContext(ctx, "Failed to extract auth_server_metadata", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return err
|
|
}
|
|
|
|
var authMetadata oauthflow.AuthorizationServerMetadata
|
|
metadataBytes, err := json.Marshal(authServerMetadata)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to marshal auth_server_metadata", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to marshal auth_server_metadata: %w", err)
|
|
}
|
|
if err := json.Unmarshal(metadataBytes, &authMetadata); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to unmarshal auth_server_metadata", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to unmarshal auth_server_metadata: %w", err)
|
|
}
|
|
|
|
resourceIndicator := serverURL
|
|
if resourceMetadata, ok := req.Meta["resource_metadata"].(map[string]any); ok {
|
|
if resource, ok := resourceMetadata["resource"].(string); ok && resource != "" {
|
|
resourceIndicator = resource
|
|
}
|
|
}
|
|
|
|
slog.DebugContext(ctx, "Authorization server metadata extracted", "issuer", authMetadata.Issuer)
|
|
|
|
oauthCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
|
|
slog.DebugContext(ctx, "Creating OAuth callback server")
|
|
callbackServer, err := oauthflow.NewCallbackServer(ctx)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to create callback server", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to create callback server: %w", err)
|
|
}
|
|
defer func() {
|
|
// Detach from ctx's cancellation (the request may be done) but
|
|
// keep its trace context for the shutdown.
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer shutdownCancel()
|
|
if err := callbackServer.Shutdown(shutdownCtx); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to shutdown callback server", "error", err)
|
|
}
|
|
}()
|
|
|
|
if err := callbackServer.Start(); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to start callback server", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to start callback server: %w", err)
|
|
}
|
|
|
|
redirectURI := callbackServer.GetRedirectURI()
|
|
slog.DebugContext(ctx, "Callback server started", "redirect_uri", redirectURI)
|
|
|
|
var clientID, clientSecret string
|
|
if authMetadata.RegistrationEndpoint != "" {
|
|
slog.DebugContext(ctx, "Attempting dynamic client registration")
|
|
clientID, clientSecret, err = oauthflow.RegisterClient(oauthCtx, &authMetadata, redirectURI, nil)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Dynamic client registration failed", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to register client: %w", err)
|
|
}
|
|
slog.DebugContext(ctx, "Client registered successfully", "client_id", clientID)
|
|
} else {
|
|
err := errors.New("authorization server does not support dynamic client registration")
|
|
slog.ErrorContext(ctx, "Client registration not supported", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return err
|
|
}
|
|
|
|
state, err := oauthflow.GenerateState()
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to generate state", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to generate state: %w", err)
|
|
}
|
|
|
|
callbackServer.SetExpectedState(state)
|
|
verifier := oauthflow.GeneratePKCEVerifier()
|
|
|
|
authURL := oauthflow.BuildAuthorizationURL(
|
|
authMetadata.AuthorizationEndpoint,
|
|
clientID,
|
|
redirectURI,
|
|
state,
|
|
oauth2.S256ChallengeFromVerifier(verifier),
|
|
resourceIndicator,
|
|
nil,
|
|
)
|
|
|
|
slog.DebugContext(ctx, "Authorization URL built", "url", authURL)
|
|
|
|
slog.DebugContext(ctx, "Requesting authorization code")
|
|
code, receivedState, err := oauthflow.RequestAuthorizationCode(oauthCtx, authURL, callbackServer, state)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to get authorization code", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to get authorization code: %w", err)
|
|
}
|
|
|
|
if receivedState != state {
|
|
err := fmt.Errorf("state mismatch: expected %s, got %s", state, receivedState)
|
|
slog.ErrorContext(ctx, "State mismatch in authorization response", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return err
|
|
}
|
|
|
|
slog.DebugContext(ctx, "Authorization code received, exchanging for token")
|
|
|
|
token, err := oauthflow.ExchangeCodeForTokenWithResource(
|
|
oauthCtx,
|
|
authMetadata.TokenEndpoint,
|
|
code,
|
|
verifier,
|
|
clientID,
|
|
clientSecret,
|
|
redirectURI,
|
|
resourceIndicator,
|
|
)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "Failed to exchange code for token", "error", err)
|
|
_ = r.client.ResumeElicitation(ctx, r.sessionID, "decline", nil, req.ElicitationID)
|
|
return fmt.Errorf("failed to exchange code for token: %w", err)
|
|
}
|
|
|
|
slog.DebugContext(ctx, "Token obtained successfully", "token_type", token.TokenType)
|
|
|
|
tokenData := map[string]any{
|
|
"access_token": token.AccessToken,
|
|
"token_type": token.TokenType,
|
|
}
|
|
if token.ExpiresIn > 0 {
|
|
tokenData["expires_in"] = token.ExpiresIn
|
|
}
|
|
if token.RefreshToken != "" {
|
|
tokenData["refresh_token"] = token.RefreshToken
|
|
}
|
|
|
|
slog.DebugContext(ctx, "Sending token to server")
|
|
if err := r.client.ResumeElicitation(ctx, r.sessionID, tools.ElicitationActionAccept, tokenData, req.ElicitationID); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to send token to server", "error", err)
|
|
return fmt.Errorf("failed to send token to server: %w", err)
|
|
}
|
|
|
|
slog.DebugContext(ctx, "OAuth flow completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// SessionStore returns a RemoteSessionStore that wraps the remote client.
|
|
func (r *RemoteRuntime) SessionStore() session.Store {
|
|
return NewRemoteSessionStore(r.client)
|
|
}
|
|
|
|
// AvailableModels returns available models for the agent.
|
|
func (r *RemoteRuntime) AvailableModels(ctx context.Context) []ModelChoice {
|
|
models, err := r.client.GetAvailableModels(ctx)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Failed to get available models", "error", err)
|
|
return nil
|
|
}
|
|
choices := make([]ModelChoice, len(models))
|
|
for i, m := range models {
|
|
choices[i] = ModelChoice{Name: m, Ref: m}
|
|
}
|
|
return choices
|
|
}
|
|
|
|
// SetAgentModel queues modelRef as the override to apply on the session's
|
|
// current agent. The override is forwarded to the server on the next
|
|
// [RemoteRuntime.RunStream] call, where the server persists it as the
|
|
// per-agent model override (same effect as the historic dedicated endpoint,
|
|
// just without the extra round trip). A subsequent call before the next
|
|
// turn replaces the queued ref; an empty string clears it.
|
|
func (r *RemoteRuntime) SetAgentModel(_ context.Context, _, modelRef string) error {
|
|
r.pendingMu.Lock()
|
|
defer r.pendingMu.Unlock()
|
|
r.pendingModelOverride = modelRef
|
|
return nil
|
|
}
|
|
|
|
// CycleAgentThinkingLevel is unsupported on remote runtimes; the server owns
|
|
// model configuration including thinking-effort selection.
|
|
func (r *RemoteRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) {
|
|
return "", ErrUnsupported
|
|
}
|
|
|
|
// SetAgentThinkingLevel is unsupported on remote runtimes; the server owns
|
|
// model configuration including thinking-effort selection.
|
|
func (r *RemoteRuntime) SetAgentThinkingLevel(context.Context, string, effort.Level) (effort.Level, error) {
|
|
return "", ErrUnsupported
|
|
}
|
|
|
|
// SupportsModelSwitching returns true for remote runtimes (model switching is handled server-side).
|
|
func (r *RemoteRuntime) SupportsModelSwitching() bool {
|
|
return true
|
|
}
|
|
|
|
// PermissionsInfo returns nil for remote runtime since permissions are handled server-side.
|
|
func (r *RemoteRuntime) PermissionsInfo() *PermissionsInfo {
|
|
return nil
|
|
}
|
|
|
|
// ResetStartupInfo is a no-op for remote runtime.
|
|
func (r *RemoteRuntime) ResetStartupInfo() {
|
|
}
|
|
|
|
// CurrentAgentSkillsToolset returns nil for remote runtimes since skills are managed server-side.
|
|
func (r *RemoteRuntime) CurrentAgentSkillsToolset() *skills.ToolSet {
|
|
return nil
|
|
}
|
|
|
|
// RunSkillFork is unsupported on remote runtimes; the server owns skill
|
|
// execution.
|
|
func (r *RemoteRuntime) RunSkillFork(context.Context, *session.Session, skills.RunSkillArgs, EventSink) (*tools.ToolCallResult, error) {
|
|
return nil, fmt.Errorf("run skill fork: %w", ErrUnsupported)
|
|
}
|
|
|
|
// UpdateSessionTitle updates the title of the current session on the remote server.
|
|
func (r *RemoteRuntime) UpdateSessionTitle(ctx context.Context, sess *session.Session, title string) error {
|
|
sess.SetTitle(title)
|
|
if r.sessionID == "" {
|
|
return errors.New("cannot update session title: no session ID available")
|
|
}
|
|
return r.client.UpdateSessionTitle(ctx, r.sessionID, title)
|
|
}
|
|
|
|
// CurrentMCPPrompts returns available MCP prompts from the server.
|
|
func (r *RemoteRuntime) CurrentMCPPrompts(ctx context.Context) map[string]tools.PromptInfo {
|
|
if r.sessionID == "" {
|
|
return make(map[string]tools.PromptInfo)
|
|
}
|
|
prompts, err := r.client.GetSessionMCPPrompts(ctx, r.sessionID)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Failed to get MCP prompts", "error", err)
|
|
return make(map[string]tools.PromptInfo)
|
|
}
|
|
// The client decodes the JSON response into map[string]any, so each
|
|
// value is a map[string]any, never a concrete PromptInfo; round-trip
|
|
// through JSON to get typed values.
|
|
result := make(map[string]tools.PromptInfo)
|
|
for k, v := range prompts {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Failed to convert MCP prompt info", "prompt", k, "error", err)
|
|
continue
|
|
}
|
|
var info tools.PromptInfo
|
|
if err := json.Unmarshal(b, &info); err != nil {
|
|
slog.WarnContext(ctx, "Failed to convert MCP prompt info", "prompt", k, "error", err)
|
|
continue
|
|
}
|
|
result[k] = info
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ExecuteMCPPrompt executes an MCP prompt on the server.
|
|
func (r *RemoteRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, args map[string]string) (string, error) {
|
|
if r.sessionID == "" {
|
|
return "", errors.New("no active session")
|
|
}
|
|
return r.client.ExecuteSessionMCPPrompt(ctx, r.sessionID, promptName, args)
|
|
}
|
|
|
|
// TitleGenerator is not supported on remote runtimes (titles are generated server-side).
|
|
func (r *RemoteRuntime) TitleGenerator(context.Context) *sessiontitle.Generator {
|
|
return nil
|
|
}
|
|
|
|
// TogglePause pauses/resumes a session on the server.
|
|
func (r *RemoteRuntime) TogglePause(ctx context.Context) (bool, error) {
|
|
if r.sessionID == "" {
|
|
return false, errors.New("no active session")
|
|
}
|
|
return false, r.client.PauseSession(ctx, r.sessionID)
|
|
}
|
|
|
|
// OnToolsChanged is a no-op for remote runtimes; tool-list changes are
|
|
// observed server-side and surface through the run-stream events rather
|
|
// than via an out-of-band callback.
|
|
func (r *RemoteRuntime) OnToolsChanged(func(Event)) {}
|
|
|
|
// OnBackgroundEvent is a no-op for remote runtimes; background agent tasks
|
|
// run server-side and their events are not forwarded out-of-band.
|
|
func (r *RemoteRuntime) OnBackgroundEvent(func(Event)) {}
|
|
|
|
// OnElicitationRequest is a no-op for remote runtimes; elicitation requests
|
|
// (including from server-side background jobs) arrive as ElicitationRequestEvent
|
|
// values on the RunStream channel itself (see the RunStream forwarding loop
|
|
// above), so there is no separate out-of-band sink to register.
|
|
//
|
|
// RemoteRuntime deliberately does NOT implement
|
|
// LocalRuntime.MirrorsElicitationOnRunStream: embedders that forward
|
|
// RunStream events verbatim (e.g. pkg/app.App) rely on that capability check
|
|
// to tell that this runtime's RunStream copy is its ONLY delivery and must
|
|
// reach them unfiltered (#3584 review).
|
|
func (r *RemoteRuntime) OnElicitationRequest(func(Event)) {}
|
|
|
|
// Close is a no-op for remote runtimes.
|
|
func (r *RemoteRuntime) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// GetSnapshots retrieves available snapshots for the current session.
|
|
func (r *RemoteRuntime) GetSnapshots(ctx context.Context) ([]map[string]any, error) {
|
|
if r.sessionID == "" {
|
|
return nil, errors.New("no active session")
|
|
}
|
|
return r.client.GetSessionSnapshots(ctx, r.sessionID)
|
|
}
|
|
|
|
// Undo reverts to the previous snapshot on the remote server.
|
|
func (r *RemoteRuntime) Undo(ctx context.Context) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.UndoSession(ctx, r.sessionID)
|
|
}
|
|
|
|
// Reset resets the session to its initial state on the remote server.
|
|
func (r *RemoteRuntime) Reset(ctx context.Context) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.ResetSession(ctx, r.sessionID)
|
|
}
|
|
|
|
// AddMessageToSession adds a message to the current session on the remote server.
|
|
func (r *RemoteRuntime) AddMessageToSession(ctx context.Context, msg *session.Message) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.AddMessage(ctx, r.sessionID, msg)
|
|
}
|
|
|
|
// UpdateSessionMessage updates a message in the current session on the remote server.
|
|
func (r *RemoteRuntime) UpdateSessionMessage(ctx context.Context, msgID string, msg *session.Message) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.UpdateMessage(ctx, r.sessionID, msgID, msg)
|
|
}
|
|
|
|
// AddSessionSummary adds a summary item to the current session on the remote server.
|
|
func (r *RemoteRuntime) AddSessionSummary(ctx context.Context, item session.Item) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.AddSummary(ctx, r.sessionID, item)
|
|
}
|
|
|
|
// UpdateSessionTokens updates token counts for the current session on the remote server.
|
|
func (r *RemoteRuntime) UpdateSessionTokens(ctx context.Context, inputTokens, outputTokens int64, cost float64) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.UpdateSessionTokens(ctx, r.sessionID, inputTokens, outputTokens, cost)
|
|
}
|
|
|
|
// SetSessionStarred sets the starred status for the current session on the remote server.
|
|
func (r *RemoteRuntime) SetSessionStarred(ctx context.Context, starred bool) error {
|
|
if r.sessionID == "" {
|
|
return errors.New("no active session")
|
|
}
|
|
return r.client.SetSessionStarred(ctx, r.sessionID, starred)
|
|
}
|
|
|
|
var _ Runtime = (*RemoteRuntime)(nil)
|
|
|
|
// RemoteSessionStore wraps a RemoteClient to implement the session.Store interface.
|
|
type RemoteSessionStore struct {
|
|
client RemoteClient
|
|
}
|
|
|
|
// NewRemoteSessionStore creates a new RemoteSessionStore.
|
|
func NewRemoteSessionStore(client RemoteClient) *RemoteSessionStore {
|
|
return &RemoteSessionStore{client: client}
|
|
}
|
|
|
|
func (s *RemoteSessionStore) AddSession(context.Context, *session.Session) error {
|
|
return fmt.Errorf("add session: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) GetSession(context.Context, string) (*session.Session, error) {
|
|
return nil, fmt.Errorf("get session: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) GetSessionByOrigin(context.Context, string, string) (*session.Session, error) {
|
|
return nil, fmt.Errorf("get session by origin: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) GetSessions(ctx context.Context) ([]*session.Session, error) {
|
|
sessions, err := s.client.GetAllSessions(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*session.Session, len(sessions))
|
|
for i := range sessions {
|
|
result[i] = &sessions[i]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *RemoteSessionStore) GetSessionSummaries(context.Context) ([]session.Summary, error) {
|
|
return nil, fmt.Errorf("get session summaries: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) DeleteSession(ctx context.Context, id string) error {
|
|
return s.client.DeleteRemoteSession(ctx, id)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) UpdateSession(context.Context, *session.Session) error {
|
|
return fmt.Errorf("update session: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) SetSessionStarred(context.Context, string, bool) error {
|
|
return fmt.Errorf("set session starred: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) AddMessage(context.Context, string, *session.Message) (int64, error) {
|
|
return 0, fmt.Errorf("add message: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) UpdateMessage(context.Context, string, int64, *session.Message) error {
|
|
return fmt.Errorf("update message: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) AddSubSession(context.Context, string, *session.Session) error {
|
|
return fmt.Errorf("add sub session: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) PersistCompaction(context.Context, *session.Session, int64, int64, session.Item) error {
|
|
return fmt.Errorf("persist compaction: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) AddSummary(context.Context, string, session.Item) error {
|
|
return fmt.Errorf("add summary: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) AddError(context.Context, string, *session.Error) error {
|
|
return fmt.Errorf("add error: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) UpdateSessionTokens(context.Context, string, int64, int64, float64) error {
|
|
return fmt.Errorf("update session tokens: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) UpdateSessionTitle(context.Context, string, string) error {
|
|
return fmt.Errorf("update session title: %w", ErrUnsupported)
|
|
}
|
|
|
|
func (s *RemoteSessionStore) Close() error {
|
|
return nil
|
|
}
|
|
|
|
var _ session.Store = (*RemoteSessionStore)(nil)
|