1073 lines
43 KiB
Go
1073 lines
43 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/registry"
|
|
runtimeapi "github.com/kandev/kandev/internal/agent/runtime"
|
|
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
runtimeenv "github.com/kandev/kandev/internal/agent/runtime/environment"
|
|
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"github.com/kandev/kandev/internal/clarification"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
githubsvc "github.com/kandev/kandev/internal/github"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
"github.com/kandev/kandev/internal/orchestrator/executor"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
|
taskservice "github.com/kandev/kandev/internal/task/service"
|
|
"github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// taskGetterRepo is the minimal interface needed by the scheduler adapter.
|
|
type taskGetterRepo interface {
|
|
GetTask(ctx context.Context, id string) (*models.Task, error)
|
|
}
|
|
|
|
// taskRepositoryAdapter adapts the task repository for the orchestrator's scheduler
|
|
type taskRepositoryAdapter struct {
|
|
repo taskGetterRepo
|
|
svc *taskservice.Service
|
|
}
|
|
|
|
// GetTask retrieves a task by ID and converts it to API type
|
|
func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
|
|
task, err := a.repo.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return task.ToAPI(), nil
|
|
}
|
|
|
|
// UpdateTaskState updates task state via the service
|
|
func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
|
|
_, err := a.svc.UpdateTaskState(ctx, taskID, state)
|
|
return err
|
|
}
|
|
|
|
// UpdateTaskStateIfCurrentIn transitions task state via the service when the
|
|
// current state is in allowed.
|
|
func (a *taskRepositoryAdapter) UpdateTaskStateIfCurrentIn(
|
|
ctx context.Context, taskID string, state v1.TaskState, allowed []v1.TaskState,
|
|
) (bool, error) {
|
|
return a.svc.UpdateTaskStateIfCurrentIn(ctx, taskID, state, allowed)
|
|
}
|
|
|
|
// UpdateTaskStateIfNotArchived is UpdateTaskStateIfCurrentIn without the
|
|
// prior-state constraint (see scheduler.TaskRepository doc).
|
|
func (a *taskRepositoryAdapter) UpdateTaskStateIfNotArchived(
|
|
ctx context.Context, taskID string, state v1.TaskState,
|
|
) (bool, error) {
|
|
return a.svc.UpdateTaskStateIfNotArchived(ctx, taskID, state)
|
|
}
|
|
|
|
func (a *taskRepositoryAdapter) UpdateTaskStateIfSessionState(
|
|
ctx context.Context,
|
|
taskID, sessionID string,
|
|
expectedSessionState models.TaskSessionState,
|
|
state v1.TaskState,
|
|
) (bool, error) {
|
|
return a.svc.UpdateTaskStateIfSessionState(ctx, taskID, sessionID, expectedSessionState, state)
|
|
}
|
|
|
|
func (a *taskRepositoryAdapter) UpdateTaskStateIfPrimarySessionState(
|
|
ctx context.Context,
|
|
taskID, sessionID string,
|
|
expectedSessionState models.TaskSessionState,
|
|
state v1.TaskState,
|
|
) (bool, error) {
|
|
return a.svc.UpdateTaskStateIfPrimarySessionState(
|
|
ctx, taskID, sessionID, expectedSessionState, state,
|
|
)
|
|
}
|
|
|
|
// lifecycleAdapter adapts the lifecycle manager as an AgentManagerClient
|
|
type lifecycleAdapter struct {
|
|
mgr *lifecycle.Manager
|
|
registry *registry.Registry
|
|
logger *logger.Logger
|
|
}
|
|
|
|
var _ interface {
|
|
OwnsPromptGeneration(sessionID, executionID string, generation uint64) bool
|
|
GetPromptGenerationForSession(ctx context.Context, sessionID string) (uint64, error)
|
|
} = (*lifecycleAdapter)(nil)
|
|
|
|
// newLifecycleAdapter creates a new lifecycle adapter
|
|
func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *logger.Logger) *lifecycleAdapter {
|
|
return &lifecycleAdapter{
|
|
mgr: mgr,
|
|
registry: reg,
|
|
logger: log.WithFields(zap.String("component", "lifecycle_adapter")),
|
|
}
|
|
}
|
|
|
|
// LaunchAgent creates a new agentctl instance for a task.
|
|
// Agent subprocess is NOT started - call StartAgentProcess() explicitly.
|
|
func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
|
|
// WorkspacePath wins when set (repo-less task with picked folder); otherwise
|
|
// fall back to RepositoryURL (legacy: this carries a local filesystem path
|
|
// for the workspace). Empty is also valid — lifecycle manager creates a
|
|
// scratch workspace.
|
|
workspacePath := req.WorkspacePath
|
|
if workspacePath == "" {
|
|
workspacePath = req.RepositoryURL
|
|
}
|
|
officeProfileID := req.OfficeAgentProfileID
|
|
if officeProfileID == "" {
|
|
officeProfileID = req.AgentProfileID
|
|
}
|
|
launchReq := buildLifecycleLaunchRequest(req, workspacePath, officeProfileID)
|
|
|
|
// Create the agentctl execution (does NOT start agent process)
|
|
execution, err := a.mgr.Launch(ctx, launchReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract worktree info from metadata if available
|
|
metadata := execution.MetadataSnapshot()
|
|
var worktreeID, worktreePath, worktreeBranch string
|
|
if metadata != nil {
|
|
if id, ok := metadata["worktree_id"].(string); ok {
|
|
worktreeID = id
|
|
}
|
|
if path, ok := metadata["worktree_path"].(string); ok {
|
|
worktreePath = path
|
|
}
|
|
if branch, ok := metadata["worktree_branch"].(string); ok {
|
|
worktreeBranch = branch
|
|
}
|
|
}
|
|
|
|
// Surface per-repo worktree results from the prepare step so the orchestrator
|
|
// can persist N TaskEnvironmentRepo / TaskSessionWorktree rows when multi-repo.
|
|
var worktrees []executor.RepoWorktreeResult
|
|
if execution.PrepareResult != nil && len(execution.PrepareResult.Worktrees) > 0 {
|
|
worktrees = make([]executor.RepoWorktreeResult, 0, len(execution.PrepareResult.Worktrees))
|
|
for _, w := range execution.PrepareResult.Worktrees {
|
|
worktrees = append(worktrees, executor.RepoWorktreeResult{
|
|
RepositoryID: w.RepositoryID,
|
|
BranchSlug: w.BranchSlug,
|
|
WorktreeID: w.WorktreeID,
|
|
WorktreeBranch: w.WorktreeBranch,
|
|
WorktreePath: w.WorktreePath,
|
|
MainRepoGitDir: w.MainRepoGitDir,
|
|
ErrorMessage: w.ErrorMessage,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &executor.LaunchAgentResponse{
|
|
AgentExecutionID: execution.ID,
|
|
ContainerID: execution.ContainerID,
|
|
Status: execution.Status,
|
|
WorktreeID: worktreeID,
|
|
WorktreePath: worktreePath,
|
|
WorktreeBranch: worktreeBranch,
|
|
WorkspacePath: execution.WorkspacePath,
|
|
Metadata: metadata,
|
|
PrepareResult: execution.PrepareResult,
|
|
Worktrees: worktrees,
|
|
}, nil
|
|
}
|
|
|
|
func buildLifecycleLaunchRequest(
|
|
req *executor.LaunchAgentRequest, workspacePath, officeProfileID string,
|
|
) *lifecycle.LaunchRequest {
|
|
launchReq := &lifecycle.LaunchRequest{
|
|
TaskID: req.TaskID,
|
|
WorkspaceID: req.WorkspaceID,
|
|
SessionID: req.SessionID,
|
|
TaskEnvironmentID: req.TaskEnvironmentID,
|
|
TaskTitle: req.TaskTitle,
|
|
AgentProfileID: officeProfileID,
|
|
ExecutionProfileID: req.AgentProfileID,
|
|
StartAgent: req.StartAgent,
|
|
WorkspacePath: workspacePath,
|
|
TaskDescription: req.TaskDescription,
|
|
Attachments: convertToLifecycleAttachments(req.Attachments),
|
|
Env: req.Env,
|
|
ApprovedSecretEnvKeys: append([]string(nil), req.ApprovedSecretEnvKeys...),
|
|
EnvironmentDefinitions: append([]runtimeenv.Definition(nil), req.EnvironmentDefinitions...),
|
|
EnvironmentResolutionRequired: req.EnvironmentResolutionRequired,
|
|
ACPSessionID: req.ACPSessionID,
|
|
Metadata: req.Metadata,
|
|
ModelOverride: req.ModelOverride,
|
|
ExecutorType: req.ExecutorType,
|
|
ExecutorConfig: req.ExecutorConfig,
|
|
PreviousExecutionID: req.PreviousExecutionID,
|
|
McpMode: req.McpMode,
|
|
McpProviders: req.McpProviders,
|
|
McpProfile: req.McpProfile,
|
|
IsEphemeral: req.IsEphemeral,
|
|
IsPassthrough: req.IsPassthrough,
|
|
SetupScript: req.SetupScript,
|
|
CopyFiles: req.CopyFiles,
|
|
UseWorktree: req.UseWorktree,
|
|
WorktreeID: req.WorktreeID,
|
|
RepositoryID: req.RepositoryID,
|
|
RepositoryPath: req.RepositoryPath,
|
|
BaseBranch: req.BaseBranch,
|
|
DefaultBranch: req.DefaultBranch,
|
|
CheckoutBranch: req.CheckoutBranch,
|
|
PRNumber: req.PRNumber,
|
|
RemoteContribution: req.RemoteContribution,
|
|
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
|
|
WorktreeBranchTemplate: req.WorktreeBranchTemplate,
|
|
WorktreeBranchTicket: req.WorktreeBranchTicket,
|
|
PullBeforeWorktree: req.PullBeforeWorktree,
|
|
RemoteSyncHandled: req.RemoteSyncHandled,
|
|
TaskDirName: req.TaskDirName,
|
|
RepoName: req.RepoName,
|
|
BranchSlug: req.BranchSlug,
|
|
BranchIdentitySlug: req.BranchIdentitySlug,
|
|
}
|
|
launchReq.WorkspaceFolders = lifecycleWorkspaceFolders(req.WorkspaceFolders)
|
|
launchReq.RouteOverride = lifecycleRouteOverride(req.RouteOverride)
|
|
launchReq.Repositories = lifecycleRepoLaunchSpecs(req.Repositories)
|
|
return launchReq
|
|
}
|
|
|
|
func lifecycleWorkspaceFolders(folders []executor.WorkspaceFolderSpec) []lifecycle.WorkspaceFolderSpec {
|
|
if len(folders) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]lifecycle.WorkspaceFolderSpec, 0, len(folders))
|
|
for _, f := range folders {
|
|
result = append(result, lifecycle.WorkspaceFolderSpec{Name: f.Name, LocalPath: f.LocalPath})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func lifecycleRouteOverride(override *executor.RouteOverride) *lifecycle.RouteOverride {
|
|
if override == nil {
|
|
return nil
|
|
}
|
|
return &lifecycle.RouteOverride{
|
|
ExecutionProfileID: override.ExecutionProfileID,
|
|
ProviderID: override.ProviderID,
|
|
Model: override.Model,
|
|
Tier: override.Tier,
|
|
Mode: override.Mode,
|
|
Flags: override.Flags,
|
|
Env: override.Env,
|
|
}
|
|
}
|
|
|
|
func lifecycleRepoLaunchSpecs(repos []executor.RepoSpec) []lifecycle.RepoLaunchSpec {
|
|
if len(repos) == 0 {
|
|
return nil
|
|
}
|
|
specs := make([]lifecycle.RepoLaunchSpec, 0, len(repos))
|
|
for _, r := range repos {
|
|
specs = append(specs, lifecycle.RepoLaunchSpec{
|
|
RepositoryID: r.RepositoryID,
|
|
RepositoryPath: r.RepositoryPath,
|
|
RepositoryURL: r.RepositoryURL,
|
|
RepoName: r.RepoName,
|
|
BaseBranch: r.BaseBranch,
|
|
DefaultBranch: r.DefaultBranch,
|
|
CheckoutBranch: r.CheckoutBranch,
|
|
PRNumber: r.PRNumber,
|
|
RemoteContribution: r.RemoteContribution,
|
|
WorktreeID: r.WorktreeID,
|
|
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
|
|
WorktreeBranchTemplate: r.WorktreeBranchTemplate,
|
|
WorktreeBranchTicket: r.WorktreeBranchTicket,
|
|
PullBeforeWorktree: r.PullBeforeWorktree,
|
|
RemoteSyncHandled: r.RemoteSyncHandled,
|
|
RepoSetupScript: r.RepoSetupScript,
|
|
RepoCleanupScript: r.RepoCleanupScript,
|
|
CopyFiles: r.CopyFiles,
|
|
BranchSlug: r.BranchSlug,
|
|
BranchIdentitySlug: r.BranchIdentitySlug,
|
|
})
|
|
}
|
|
return specs
|
|
}
|
|
|
|
// convertToLifecycleAttachments converts v1.MessageAttachment to lifecycle.MessageAttachment.
|
|
func convertToLifecycleAttachments(attachments []v1.MessageAttachment) []lifecycle.MessageAttachment {
|
|
if len(attachments) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]lifecycle.MessageAttachment, len(attachments))
|
|
for i, att := range attachments {
|
|
result[i] = lifecycle.MessageAttachment{
|
|
AttachmentID: att.AttachmentID,
|
|
Type: att.Type,
|
|
Data: att.Data,
|
|
MimeType: att.MimeType,
|
|
Name: att.Name,
|
|
SizeBytes: att.SizeBytes,
|
|
DeliveryMode: att.DeliveryMode,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetExecutionDescription updates the task description in an existing execution's metadata.
|
|
func (a *lifecycleAdapter) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error {
|
|
return a.mgr.SetExecutionDescription(ctx, agentExecutionID, description)
|
|
}
|
|
|
|
// RequiresCloneURL implements executor.ExecutorTypeCapabilities by delegating to
|
|
// the lifecycle manager. Without this, executor types like local_docker and
|
|
// sprites can't tell the orchestrator they need a clone URL.
|
|
func (a *lifecycleAdapter) RequiresCloneURL(executorType string) bool {
|
|
return a.mgr.RequiresCloneURL(executorType)
|
|
}
|
|
|
|
// ShouldApplyPreferredShell implements executor.ExecutorTypeCapabilities.
|
|
func (a *lifecycleAdapter) ShouldApplyPreferredShell(executorType string) bool {
|
|
return a.mgr.ShouldApplyPreferredShell(executorType)
|
|
}
|
|
|
|
// SetExecutionEnv updates per-run env vars in an existing execution.
|
|
func (a *lifecycleAdapter) SetExecutionEnv(ctx context.Context, agentExecutionID string, env map[string]string) error {
|
|
return a.mgr.SetExecutionEnv(ctx, agentExecutionID, env)
|
|
}
|
|
|
|
// SetMcpMode changes the MCP tool mode on an existing execution's agentctl instance.
|
|
func (a *lifecycleAdapter) SetMcpMode(ctx context.Context, executionID string, mode string) error {
|
|
return a.mgr.SetMcpMode(ctx, executionID, mode)
|
|
}
|
|
|
|
// StartAgentProcess starts the agent subprocess for an instance.
|
|
// The command is built internally based on the instance's agent profile.
|
|
func (a *lifecycleAdapter) StartAgentProcess(ctx context.Context, agentInstanceID string) error {
|
|
return a.mgr.StartAgentProcess(ctx, agentInstanceID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) IsAgentCommandConfigured(agentInstanceID string) bool {
|
|
return a.mgr.IsAgentCommandConfigured(agentInstanceID)
|
|
}
|
|
|
|
// StopAgent stops a running agent
|
|
func (a *lifecycleAdapter) StopAgent(ctx context.Context, agentInstanceID string, force bool) error {
|
|
return a.mgr.StopAgent(ctx, agentInstanceID, force)
|
|
}
|
|
|
|
// StopAgentWithReason stops a running agent and propagates the stop reason to runtime teardown.
|
|
// The lifecycle-tier not-found sentinel is normalized to the public runtime
|
|
// sentinel at this seam so orchestrator consumers depend only on
|
|
// runtimeapi.ErrNotFound and never import runtime/lifecycle.
|
|
func (a *lifecycleAdapter) StopAgentWithReason(ctx context.Context, agentInstanceID string, reason string, force bool) error {
|
|
return normalizeRuntimeStopError(a.mgr.StopAgentWithReason(ctx, agentInstanceID, reason, force))
|
|
}
|
|
|
|
// normalizeRuntimeStopError maps the backend lifecycle not-found sentinel onto
|
|
// the public runtime not-found sentinel while preserving the original error for
|
|
// diagnostics. Unrelated errors (and nil) pass through unchanged so a transient
|
|
// runtime failure is never mistaken for an absent runtime.
|
|
func normalizeRuntimeStopError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, lifecycle.ErrExecutionNotFound) {
|
|
return errors.Join(runtimeapi.ErrNotFound, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RowLiveness classifies the liveness of the OS process backing an
|
|
// executors_running row using the runtime-aware host-local probe. It is the
|
|
// orchestrator's window into the platform-split liveness check (kept in the
|
|
// lifecycle package) used by startup reconciliation
|
|
// (#1597 runtime-aware liveness). A local check never runs against a
|
|
// remote/SSH row — such rows return Unknown.
|
|
func (a *lifecycleAdapter) RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness {
|
|
return lifecycle.RowProcessLiveness(row)
|
|
}
|
|
|
|
// GetAgentStatus returns the status of an agent execution
|
|
func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID string) (*v1.AgentExecution, error) {
|
|
execution, found := a.mgr.GetExecution(agentInstanceID)
|
|
if !found {
|
|
return nil, fmt.Errorf("agent execution %q not found", agentInstanceID)
|
|
}
|
|
|
|
containerID := execution.ContainerID
|
|
now := time.Now()
|
|
result := &v1.AgentExecution{
|
|
ID: execution.ID,
|
|
TaskID: execution.TaskID,
|
|
AgentProfileID: execution.AgentProfileID,
|
|
ContainerID: &containerID,
|
|
Status: execution.Status,
|
|
StartedAt: &execution.StartedAt,
|
|
StoppedAt: execution.FinishedAt,
|
|
CreatedAt: execution.StartedAt,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if execution.ExitCode != nil {
|
|
result.ExitCode = execution.ExitCode
|
|
}
|
|
if execution.ErrorMessage != "" {
|
|
result.ErrorMessage = &execution.ErrorMessage
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ListAgentTypes returns available agent types
|
|
func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
|
|
configs := a.registry.List()
|
|
result := make([]*v1.AgentType, 0, len(configs))
|
|
for _, cfg := range configs {
|
|
result = append(result, registry.ToAPIType(cfg))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (a *lifecycleAdapter) WasSessionInitialized(executionID string) bool {
|
|
return a.mgr.WasSessionInitialized(executionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) OwnsPromptGeneration(sessionID, executionID string, generation uint64) bool {
|
|
return a.mgr.OwnsPromptGeneration(sessionID, executionID, generation)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) GetPromptGenerationForSession(ctx context.Context, sessionID string) (uint64, error) {
|
|
return a.mgr.GetPromptGenerationForSession(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) GetSessionAuthMethods(sessionID string) []streams.AuthMethodInfo {
|
|
return a.mgr.GetSessionAuthMethods(sessionID)
|
|
}
|
|
|
|
// PromptAgent sends a follow-up prompt to a running agent
|
|
// Attachments (images) are passed to the agent if provided
|
|
func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*executor.PromptResult, error) {
|
|
result, err := a.mgr.PromptAgent(ctx, agentInstanceID, prompt, attachments, dispatchOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &executor.PromptResult{
|
|
StopReason: result.StopReason,
|
|
AgentMessage: result.AgentMessage,
|
|
}, nil
|
|
}
|
|
|
|
func (a *lifecycleAdapter) PromptAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) {
|
|
result, err := a.mgr.PromptAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &executor.PromptResult{
|
|
StopReason: result.StopReason,
|
|
AgentMessage: result.AgentMessage,
|
|
}, nil
|
|
}
|
|
|
|
// Compile-time guard for the steer capability. The executor selects the steer
|
|
// path by asserting the agent manager to its unexported
|
|
// steerAgentWithDispatchCallback interface (internal/orchestrator/executor); that
|
|
// assertion fails silently at runtime if this method's signature drifts,
|
|
// disabling every production steer. Pin the exact shape here so drift is a build
|
|
// error, not a runtime regression.
|
|
var _ interface {
|
|
SteerAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*executor.PromptResult, error)
|
|
} = (*lifecycleAdapter)(nil)
|
|
|
|
// SteerAgentWithDispatchCallback forwards a mid-turn steer to the lifecycle
|
|
// manager. Without it the executor's steer path type-assertion fails and every
|
|
// eligible steer returns ErrPromptDispatchCallbackUnsupported, so the production
|
|
// agent-manager client must satisfy the steerAgentWithDispatchCallback capability
|
|
// exactly as it does the ordinary dispatch-callback prompt.
|
|
func (a *lifecycleAdapter) SteerAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) {
|
|
result, err := a.mgr.SteerAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &executor.PromptResult{
|
|
StopReason: result.StopReason,
|
|
AgentMessage: result.AgentMessage,
|
|
}, nil
|
|
}
|
|
|
|
// CancelAgent interrupts the current agent turn without terminating the process.
|
|
func (a *lifecycleAdapter) CancelAgent(ctx context.Context, sessionID string) error {
|
|
return a.mgr.CancelAgentBySessionID(ctx, sessionID)
|
|
}
|
|
|
|
// RestartAgentProcess stops the agent subprocess and starts a fresh one with a new ACP session.
|
|
func (a *lifecycleAdapter) RestartAgentProcess(ctx context.Context, agentExecutionID string) error {
|
|
return a.mgr.RestartAgentProcess(ctx, agentExecutionID)
|
|
}
|
|
|
|
// ResetAgentContext resets the agent's context using the fastest available strategy.
|
|
func (a *lifecycleAdapter) ResetAgentContext(ctx context.Context, agentExecutionID string) error {
|
|
return a.mgr.ResetAgentContext(ctx, agentExecutionID)
|
|
}
|
|
|
|
// SetSessionModelBySessionID attempts an in-place model switch via ACP session/set_model.
|
|
func (a *lifecycleAdapter) SetSessionModelBySessionID(ctx context.Context, sessionID, modelID string) error {
|
|
return a.mgr.SetSessionModelBySessionID(ctx, sessionID, modelID)
|
|
}
|
|
|
|
// SetSessionConfigOptionBySessionID applies an ACP dynamic session option.
|
|
// Workflow conditional configuration uses this optional seam so older agent
|
|
// manager test doubles and non-ACP providers can fail closed without widening
|
|
// the executor launch contract.
|
|
func (a *lifecycleAdapter) SetSessionConfigOptionBySessionID(ctx context.Context, sessionID, configID, value string) error {
|
|
return a.mgr.SetSessionConfigOptionBySessionID(ctx, sessionID, configID, value)
|
|
}
|
|
|
|
// SetSessionModeBySessionID applies a session permission mode via ACP session/set_mode.
|
|
func (a *lifecycleAdapter) SetSessionModeBySessionID(ctx context.Context, sessionID, modeID string) error {
|
|
return a.mgr.SetSessionModeBySessionID(ctx, sessionID, modeID)
|
|
}
|
|
|
|
// RespondToPermissionBySessionID sends a response to a permission request for a session
|
|
func (a *lifecycleAdapter) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error {
|
|
return a.mgr.RespondToPermissionBySessionID(sessionID, pendingID, optionID, cancelled)
|
|
}
|
|
|
|
// IsAgentRunningForSession checks if an agent is actually running for a session
|
|
// This probes the actual agent (Docker container or standalone process)
|
|
func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
|
return a.mgr.IsAgentRunningForSession(ctx, sessionID)
|
|
}
|
|
|
|
// IsAgentReadyForPrompt checks if the session can accept a prompt immediately.
|
|
func (a *lifecycleAdapter) IsAgentReadyForPrompt(ctx context.Context, sessionID string) bool {
|
|
return a.mgr.IsAgentReadyForPrompt(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) RecoverAgentPromptStream(ctx context.Context, sessionID string) error {
|
|
return a.mgr.RecoverAgentPromptStream(ctx, sessionID)
|
|
}
|
|
|
|
// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode.
|
|
func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
|
return a.mgr.IsPassthroughSession(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error {
|
|
return a.mgr.WritePassthroughStdin(ctx, sessionID, data)
|
|
}
|
|
|
|
// ResolvePassthroughConfig returns the resolved PassthroughConfig for a session's agent.
|
|
func (a *lifecycleAdapter) ResolvePassthroughConfig(ctx context.Context, sessionID string) (agents.PassthroughConfig, error) {
|
|
return a.mgr.ResolvePassthroughConfig(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) MarkPassthroughRunning(sessionID string) error {
|
|
return a.mgr.MarkPassthroughRunning(sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) PollRemoteStatusForRecords(ctx context.Context, records []executor.RemoteStatusPollRequest) {
|
|
lcRecords := make([]lifecycle.RemoteStatusPollRecord, len(records))
|
|
for i, r := range records {
|
|
lcRecords[i] = lifecycle.RemoteStatusPollRecord{
|
|
SessionID: r.SessionID,
|
|
Runtime: r.Runtime,
|
|
AgentExecutionID: r.AgentExecutionID,
|
|
ContainerID: r.ContainerID,
|
|
Metadata: r.Metadata,
|
|
}
|
|
}
|
|
a.mgr.PollRemoteStatusForRecords(ctx, lcRecords)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
|
return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error {
|
|
_, err := a.mgr.EnsureWorkspaceExecutionForSession(ctx, taskID, sessionID)
|
|
return err
|
|
}
|
|
|
|
func (a *lifecycleAdapter) GetExecutionIDForSession(ctx context.Context, sessionID string) (string, error) {
|
|
return a.mgr.GetExecutionIDForSession(ctx, sessionID)
|
|
}
|
|
|
|
func (a *lifecycleAdapter) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*executor.RemoteRuntimeStatus, error) {
|
|
status, ok := a.mgr.GetRemoteStatusBySessionID(ctx, sessionID)
|
|
if !ok || status == nil {
|
|
return nil, nil
|
|
}
|
|
return &executor.RemoteRuntimeStatus{
|
|
RuntimeName: status.RuntimeName,
|
|
RemoteName: status.RemoteName,
|
|
State: status.State,
|
|
CreatedAt: status.CreatedAt,
|
|
LastCheckedAt: status.LastCheckedAt,
|
|
ErrorMessage: status.ErrorMessage,
|
|
}, nil
|
|
}
|
|
|
|
// ResolveAgentProfile resolves an agent profile ID to profile information
|
|
func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
|
info, err := a.mgr.ResolveAgentProfile(ctx, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &executor.AgentProfileInfo{
|
|
ProfileID: info.ProfileID,
|
|
ProfileName: info.ProfileName,
|
|
AgentID: info.AgentID,
|
|
AgentName: info.AgentName,
|
|
Model: info.Model,
|
|
Mode: info.Mode,
|
|
ConfigOptions: info.ConfigOptions,
|
|
AutoApprove: info.AutoApprove,
|
|
DangerouslySkipPermissions: info.DangerouslySkipPermissions,
|
|
CLIPassthrough: info.CLIPassthrough,
|
|
SupportsMCP: info.SupportsMCP,
|
|
}, nil
|
|
}
|
|
|
|
// GetGitLog retrieves the git log for a session from baseCommit to HEAD.
|
|
// If targetBranch is provided, uses dynamic merge-base calculation for accurate filtering.
|
|
func (a *lifecycleAdapter) GetGitLog(ctx context.Context, sessionID, baseCommit string, limit int, targetBranch string) (*client.GitLogResult, error) {
|
|
execution, ok := a.mgr.GetExecutionBySessionID(sessionID)
|
|
if !ok {
|
|
return nil, nil // No execution, not an error
|
|
}
|
|
agentClient := execution.GetAgentCtlClient()
|
|
if agentClient == nil {
|
|
return nil, nil
|
|
}
|
|
return agentClient.GitLog(ctx, baseCommit, limit, targetBranch, "")
|
|
}
|
|
|
|
// GetCumulativeDiff retrieves the cumulative diff for a session from baseCommit to HEAD.
|
|
func (a *lifecycleAdapter) GetCumulativeDiff(ctx context.Context, sessionID, baseCommit string) (*client.CumulativeDiffResult, error) {
|
|
execution, ok := a.mgr.GetExecutionBySessionID(sessionID)
|
|
if !ok {
|
|
return nil, nil // No execution, not an error
|
|
}
|
|
agentClient := execution.GetAgentCtlClient()
|
|
if agentClient == nil {
|
|
return nil, nil
|
|
}
|
|
// Orchestrator-side cumulative diffs (snapshot tracking) anchor to the
|
|
// caller-provided base SHA — no dynamic merge-base recomputation. The
|
|
// live panel uses the WS-handler path, which passes targetBranch.
|
|
return agentClient.GetCumulativeDiff(ctx, baseCommit, "")
|
|
}
|
|
|
|
// GetGitStatus retrieves the current git status for a session.
|
|
func (a *lifecycleAdapter) GetGitStatus(ctx context.Context, sessionID string) (*client.GitStatusResult, error) {
|
|
execution, ok := a.mgr.GetExecutionBySessionID(sessionID)
|
|
if !ok {
|
|
return nil, nil // No execution, not an error
|
|
}
|
|
agentClient := execution.GetAgentCtlClient()
|
|
if agentClient == nil {
|
|
return nil, nil
|
|
}
|
|
return agentClient.GetGitStatus(ctx)
|
|
}
|
|
|
|
// GetGitStatusFresh retrieves a fresh (non-cached) git status for a session.
|
|
func (a *lifecycleAdapter) GetGitStatusFresh(ctx context.Context, sessionID string) (*client.GitStatusResult, error) {
|
|
execution, ok := a.mgr.GetExecutionBySessionID(sessionID)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
agentClient := execution.GetAgentCtlClient()
|
|
if agentClient == nil {
|
|
return nil, nil
|
|
}
|
|
return agentClient.GetGitStatusFresh(ctx)
|
|
}
|
|
|
|
// WaitForAgentctlReady waits for the agentctl HTTP server to be ready for a session.
|
|
func (a *lifecycleAdapter) WaitForAgentctlReady(ctx context.Context, sessionID string) error {
|
|
return a.mgr.WaitForAgentctlReadyForSession(ctx, sessionID)
|
|
}
|
|
|
|
// orchestratorWrapper wraps orchestrator.Service to implement taskhandlers.OrchestratorService.
|
|
// The wrapper only adapts ResumeTaskSession which returns *executor.TaskExecution that we don't need.
|
|
type orchestratorWrapper struct {
|
|
svc *orchestrator.Service
|
|
}
|
|
|
|
// PromptTask forwards directly to the orchestrator service.
|
|
// Attachments (images) are passed through to the agent.
|
|
func (w *orchestratorWrapper) PromptTask(ctx context.Context, taskID, taskSessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) {
|
|
return w.svc.PromptTask(ctx, taskID, taskSessionID, prompt, model, planMode, attachments, dispatchOnly)
|
|
}
|
|
|
|
// ResumeTaskSession forwards to the orchestrator service, discarding the TaskExecution result.
|
|
func (w *orchestratorWrapper) ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error {
|
|
_, err := w.svc.ResumeTaskSession(ctx, taskID, taskSessionID)
|
|
return err
|
|
}
|
|
|
|
// StartCreatedSession forwards to the orchestrator service, discarding the TaskExecution result.
|
|
func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error {
|
|
_, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt, skipMessageRecord, planMode, autoStart, attachments, references)
|
|
return err
|
|
}
|
|
|
|
type githubTaskIssueStoreAdapter struct {
|
|
svc *taskservice.Service
|
|
}
|
|
|
|
func (a githubTaskIssueStoreAdapter) GetTask(ctx context.Context, taskID string) (*models.Task, error) {
|
|
task, err := a.svc.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, wrapGitHubTaskIssueStoreError(err)
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (a githubTaskIssueStoreAdapter) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) {
|
|
task, err := a.svc.GetTask(ctx, taskID)
|
|
if err != nil {
|
|
return nil, wrapGitHubTaskIssueStoreError(err)
|
|
}
|
|
return task.Repositories, nil
|
|
}
|
|
|
|
func (a githubTaskIssueStoreAdapter) GetRepository(ctx context.Context, repositoryID string) (*models.Repository, error) {
|
|
return a.svc.GetRepository(ctx, repositoryID)
|
|
}
|
|
|
|
func (a githubTaskIssueStoreAdapter) UpdateTaskMetadata(ctx context.Context, taskID string, metadata map[string]interface{}) (*models.Task, error) {
|
|
task, err := a.svc.UpdateTask(ctx, taskID, &taskservice.UpdateTaskRequest{Metadata: metadata})
|
|
if err != nil {
|
|
return nil, wrapGitHubTaskIssueStoreError(err)
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func wrapGitHubTaskIssueStoreError(err error) error {
|
|
if errors.Is(err, taskrepo.ErrTaskNotFound) {
|
|
return fmt.Errorf("%w: %w", githubsvc.ErrTaskNotFound, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ProcessOnTurnStart forwards to the orchestrator service.
|
|
func (w *orchestratorWrapper) ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error {
|
|
return w.svc.ProcessOnTurnStart(ctx, taskID, sessionID)
|
|
}
|
|
|
|
// StepRequiresCompletionSignal forwards to the orchestrator service.
|
|
func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context, taskID string) bool {
|
|
return w.svc.StepRequiresCompletionSignal(ctx, taskID)
|
|
}
|
|
|
|
// ForegroundActivity forwards to the orchestrator service (ADR-0049).
|
|
func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.ForegroundActivity {
|
|
return w.svc.ForegroundActivity(sessionID)
|
|
}
|
|
|
|
func (w *orchestratorWrapper) SteerEligible(sessionID string, state models.TaskSessionState) bool {
|
|
return w.svc.SteerEligible(sessionID, state)
|
|
}
|
|
|
|
func (w *orchestratorWrapper) SteerTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error) {
|
|
return w.svc.SteerTask(ctx, taskID, sessionID, prompt, model, planMode, attachments)
|
|
}
|
|
|
|
// messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface
|
|
type messageCreatorAdapter struct {
|
|
svc *taskservice.Service
|
|
logger *logger.Logger
|
|
|
|
// sessionModelCache caches the resolved model per session to avoid repeated DB lookups.
|
|
// Invalidated via InvalidateModelCache when the model changes (e.g., model switch).
|
|
sessionModelMu sync.RWMutex
|
|
sessionModelCache map[string]string
|
|
}
|
|
|
|
// getSessionModel resolves the model from the session's agent profile snapshot.
|
|
// Results are cached per session ID to avoid repeated DB queries during streaming.
|
|
func (a *messageCreatorAdapter) getSessionModel(ctx context.Context, sessionID string) string {
|
|
// Check cache first
|
|
a.sessionModelMu.RLock()
|
|
if model, ok := a.sessionModelCache[sessionID]; ok {
|
|
a.sessionModelMu.RUnlock()
|
|
return model
|
|
}
|
|
a.sessionModelMu.RUnlock()
|
|
|
|
// Cache miss — fetch from DB
|
|
session, err := a.svc.GetTaskSession(ctx, sessionID)
|
|
if err != nil || session == nil || session.AgentProfileSnapshot == nil {
|
|
return ""
|
|
}
|
|
model, _ := session.AgentProfileSnapshot["model"].(string)
|
|
|
|
// Store in cache
|
|
a.sessionModelMu.Lock()
|
|
if a.sessionModelCache == nil {
|
|
a.sessionModelCache = make(map[string]string)
|
|
}
|
|
a.sessionModelCache[sessionID] = model
|
|
a.sessionModelMu.Unlock()
|
|
|
|
return model
|
|
}
|
|
|
|
// InvalidateModelCache clears the cached model for a session so the next message
|
|
// re-reads it from the DB. Called after model switches.
|
|
func (a *messageCreatorAdapter) InvalidateModelCache(sessionID string) {
|
|
a.sessionModelMu.Lock()
|
|
delete(a.sessionModelCache, sessionID)
|
|
a.sessionModelMu.Unlock()
|
|
}
|
|
|
|
// CreateAgentMessage creates a message with author_type="agent"
|
|
func (a *messageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error {
|
|
var metadata map[string]interface{}
|
|
if model := a.getSessionModel(ctx, agentSessionID); model != "" {
|
|
metadata = map[string]interface{}{"model": model}
|
|
}
|
|
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: content,
|
|
AuthorType: "agent",
|
|
Metadata: metadata,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// CreateUserMessage creates a message with author_type="user"
|
|
func (a *messageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error {
|
|
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: content,
|
|
AuthorType: "user",
|
|
Metadata: metadata,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// CreateToolCallMessage creates a message for a tool call
|
|
func (a *messageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error {
|
|
metadata := map[string]interface{}{
|
|
"tool_call_id": toolCallID,
|
|
"title": title,
|
|
"status": status,
|
|
}
|
|
// Add parent tool call ID for subagent nesting (if present)
|
|
if parentToolCallID != "" {
|
|
metadata["parent_tool_call_id"] = parentToolCallID
|
|
}
|
|
// Add normalized tool data to metadata for frontend consumption
|
|
if normalized != nil {
|
|
metadata["normalized"] = normalized
|
|
}
|
|
|
|
// Determine message type from the normalized tool kind
|
|
msgType := "tool_call"
|
|
if normalized != nil {
|
|
msgType = normalized.Kind().ToMessageType()
|
|
}
|
|
|
|
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: title,
|
|
AuthorType: "agent",
|
|
Type: msgType,
|
|
Metadata: metadata,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// UpdateToolCallMessage updates a tool call message's status.
|
|
// If the message doesn't exist, it creates it using taskID, turnID, and msgType.
|
|
func (a *messageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error {
|
|
return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType)
|
|
}
|
|
|
|
// CreateSessionMessage creates a message for non-chat session updates (status/progress/error/etc).
|
|
func (a *messageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
|
|
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: content,
|
|
AuthorType: "agent",
|
|
Type: messageType,
|
|
Metadata: metadata,
|
|
RequestsInput: requestsInput,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// CreatePermissionRequestMessage creates a message for a permission request
|
|
func (a *messageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
|
|
metadata := map[string]interface{}{
|
|
"pending_id": pendingID,
|
|
"tool_call_id": toolCallID,
|
|
"options": options,
|
|
"action_type": actionType,
|
|
"action_details": actionDetails,
|
|
}
|
|
|
|
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: sessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: title,
|
|
AuthorType: "agent",
|
|
Type: "permission_request",
|
|
Metadata: metadata,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return msg.ID, nil
|
|
}
|
|
|
|
// UpdatePermissionMessage updates a permission message's status
|
|
func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID string, status models.PermissionStatus) error {
|
|
return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status)
|
|
}
|
|
|
|
// CreateClarificationRequestMessages emits one chat message per question in a
|
|
// clarification request bundle, all sharing the given pending_id. The frontend
|
|
// renders them as a stacked group; only the last message sets RequestsInput=true
|
|
// so the chat scrolls to the bottom of the group when the bundle arrives.
|
|
// Returns the created message IDs in the same order as the input questions.
|
|
//
|
|
// If any individual CreateMessage call fails, every previously created message
|
|
// in the bundle is deleted so we don't leave a half-rendered group dangling in
|
|
// the chat. Best-effort: if cleanup itself fails the caller still receives the
|
|
// original error and the orphan messages stay (logged at warn-level).
|
|
func (a *messageCreatorAdapter) CreateClarificationRequestMessages(ctx context.Context, taskID, sessionID, pendingID string, questions []clarification.Question, clarificationContext string) ([]string, error) {
|
|
ids := make([]string, 0, len(questions))
|
|
total := len(questions)
|
|
for i, question := range questions {
|
|
options := make([]interface{}, len(question.Options))
|
|
for j, opt := range question.Options {
|
|
options[j] = map[string]interface{}{
|
|
"option_id": opt.ID,
|
|
"label": opt.Label,
|
|
"description": opt.Description,
|
|
}
|
|
}
|
|
|
|
questionData := map[string]interface{}{
|
|
"id": question.ID,
|
|
"title": question.Title,
|
|
"prompt": question.Prompt,
|
|
"options": options,
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"pending_id": pendingID,
|
|
"question_id": question.ID,
|
|
"question": questionData,
|
|
"question_index": i,
|
|
"question_total": total,
|
|
"context": clarificationContext,
|
|
"status": "pending",
|
|
}
|
|
|
|
// Only the last message marks the session as waiting for input so the
|
|
// chat scrolls to the end of the group when the bundle arrives.
|
|
requestsInput := i == total-1
|
|
|
|
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: sessionID,
|
|
TaskID: taskID,
|
|
Content: question.Prompt,
|
|
AuthorType: "agent",
|
|
Type: "clarification_request",
|
|
Metadata: metadata,
|
|
RequestsInput: requestsInput,
|
|
})
|
|
if err != nil {
|
|
a.rollbackPartialBundle(ctx, ids, pendingID)
|
|
return nil, err
|
|
}
|
|
ids = append(ids, msg.ID)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// rollbackPartialBundle removes the messages already created when later writes
|
|
// in a multi-question bundle fail. Best-effort — failures are logged but do
|
|
// not bubble up because the caller already has a more useful error.
|
|
func (a *messageCreatorAdapter) rollbackPartialBundle(ctx context.Context, ids []string, pendingID string) {
|
|
for _, id := range ids {
|
|
if err := a.svc.DeleteMessage(ctx, id); err != nil {
|
|
a.logger.Warn("failed to roll back partial clarification bundle message",
|
|
zap.String("pending_id", pendingID),
|
|
zap.String("message_id", id),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateClarificationMessage updates a clarification message's status and answer
|
|
// for a specific (pending_id, question_id) pair within the session.
|
|
func (a *messageCreatorAdapter) UpdateClarificationMessage(ctx context.Context, sessionID, pendingID, questionID, status string, answer *clarification.Answer) error {
|
|
return a.svc.UpdateClarificationMessageForQuestion(ctx, sessionID, pendingID, questionID, status, answer)
|
|
}
|
|
|
|
// CreateAgentMessageStreaming creates a new agent message with a pre-generated ID.
|
|
// This is used for real-time streaming where content arrives incrementally.
|
|
func (a *messageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
|
|
var metadata map[string]interface{}
|
|
if model := a.getSessionModel(ctx, agentSessionID); model != "" {
|
|
metadata = map[string]interface{}{"model": model}
|
|
}
|
|
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: content,
|
|
AuthorType: "agent",
|
|
Metadata: metadata,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// AppendAgentMessage appends additional content to an existing streaming message.
|
|
func (a *messageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error {
|
|
return a.svc.AppendMessageContent(ctx, messageID, additionalContent)
|
|
}
|
|
|
|
// CreateThinkingMessageStreaming creates a new thinking message with a pre-generated ID.
|
|
// This is used for real-time streaming of agent thinking/reasoning content.
|
|
func (a *messageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
|
|
metadata := map[string]interface{}{
|
|
"thinking": content,
|
|
}
|
|
if model := a.getSessionModel(ctx, agentSessionID); model != "" {
|
|
metadata["model"] = model
|
|
}
|
|
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
|
|
TaskSessionID: agentSessionID,
|
|
TaskID: taskID,
|
|
TurnID: turnID,
|
|
Content: "",
|
|
AuthorType: "agent",
|
|
Type: "thinking",
|
|
Metadata: metadata,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// AppendThinkingMessage appends additional content to an existing streaming thinking message.
|
|
func (a *messageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error {
|
|
return a.svc.AppendThinkingContent(ctx, messageID, additionalContent)
|
|
}
|