refactor(lifecycle): extract manager into focused components (#20)

This commit is contained in:
João Salavisa
2026-01-16 02:24:58 +00:00
committed by GitHub
parent 34b86cfab2
commit 0599800c40
10 changed files with 1195 additions and 125 deletions
@@ -0,0 +1,81 @@
// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"os"
"strings"
"github.com/kandev/kandev/internal/agent/registry"
)
// CommandBuilder builds agent commands from registry configuration
type CommandBuilder struct{}
// NewCommandBuilder creates a new CommandBuilder
func NewCommandBuilder() *CommandBuilder {
return &CommandBuilder{}
}
// CommandOptions contains options for building a command
type CommandOptions struct {
Model string // Model to use (appended via ModelFlag if set)
SessionID string // Session ID to resume (appended via SessionConfig.ResumeFlag if not ResumeViaACP)
}
// BuildCommand builds a command slice from agent config and options
// Returns the command as a string slice ready for execution
func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, opts CommandOptions) []string {
// Start with base command from config
cmd := make([]string, len(agentConfig.Cmd))
copy(cmd, agentConfig.Cmd)
// Append model flag if agent supports it and model is specified
if agentConfig.ModelFlag != "" && opts.Model != "" {
cmd = append(cmd, agentConfig.ModelFlag, opts.Model)
}
// Append session resume flag if:
// 1. Session ID is provided
// 2. Agent does NOT use ACP for session resumption (uses CLI flag instead)
// 3. Agent has a ResumeFlag configured
if opts.SessionID != "" && !agentConfig.SessionConfig.ResumeViaACP && agentConfig.SessionConfig.ResumeFlag != "" {
cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, opts.SessionID)
}
return cmd
}
// BuildCommandString builds a command as a single string (for standalone mode)
func (cb *CommandBuilder) BuildCommandString(agentConfig *registry.AgentTypeConfig, opts CommandOptions) string {
cmd := cb.BuildCommand(agentConfig, opts)
return strings.Join(cmd, " ")
}
// ExpandSessionDir expands the session directory template from SessionConfig
// Replaces {home} with the user's home directory
// Returns empty string if no session directory is configured
func (cb *CommandBuilder) ExpandSessionDir(agentConfig *registry.AgentTypeConfig) string {
template := agentConfig.SessionConfig.SessionDirTemplate
if template == "" {
return ""
}
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "/tmp"
}
result := strings.ReplaceAll(template, "{home}", homeDir)
// Ensure the directory exists
_ = os.MkdirAll(result, 0755)
return result
}
// GetSessionDirTarget returns the container path for session directory mount
// Returns empty string if no session directory is configured
func (cb *CommandBuilder) GetSessionDirTarget(agentConfig *registry.AgentTypeConfig) string {
return agentConfig.SessionConfig.SessionDirTarget
}
@@ -0,0 +1,258 @@
// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"context"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/logger"
)
// ContainerConfig holds configuration for launching a Docker container
type ContainerConfig struct {
AgentConfig *registry.AgentTypeConfig
WorkspacePath string
TaskID string
TaskDescription string
Model string
SessionID string
Credentials map[string]string
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
}
// ContainerManager handles Docker container lifecycle operations
type ContainerManager struct {
dockerClient *docker.Client
commandBuilder *CommandBuilder
logger *logger.Logger
networkName string
}
// NewContainerManager creates a new ContainerManager
func NewContainerManager(dockerClient *docker.Client, networkName string, log *logger.Logger) *ContainerManager {
return &ContainerManager{
dockerClient: dockerClient,
commandBuilder: NewCommandBuilder(),
logger: log.WithFields(zap.String("component", "container-manager")),
networkName: networkName,
}
}
// LaunchContainer creates and starts a Docker container for an agent
// Returns the container ID and agentctl client
func (cm *ContainerManager) LaunchContainer(ctx context.Context, config ContainerConfig) (string, *agentctl.Client, error) {
// Build container config
containerCfg, err := cm.buildContainerConfig(config)
if err != nil {
return "", nil, fmt.Errorf("failed to build container config: %w", err)
}
// Create the container
containerID, err := cm.dockerClient.CreateContainer(ctx, containerCfg)
if err != nil {
return "", nil, fmt.Errorf("failed to create container: %w", err)
}
// Start the container
if err := cm.dockerClient.StartContainer(ctx, containerID); err != nil {
_ = cm.dockerClient.RemoveContainer(ctx, containerID, true)
return "", nil, fmt.Errorf("failed to start container: %w", err)
}
// Get container IP for agentctl communication
containerIP, err := cm.dockerClient.GetContainerIP(ctx, containerID)
if err != nil {
cm.logger.Warn("failed to get container IP, trying localhost",
zap.String("container_id", containerID),
zap.Error(err))
containerIP = "127.0.0.1"
}
// Create agentctl client pointing to the container
agentctlClient := agentctl.NewClient(containerIP, AgentCtlPort, cm.logger)
cm.logger.Info("docker container launched",
zap.String("container_id", containerID),
zap.String("container_ip", containerIP),
zap.String("instance_id", config.InstanceID))
return containerID, agentctlClient, nil
}
// StopContainer stops and removes a Docker container
func (cm *ContainerManager) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error {
if containerID == "" {
return nil
}
if err := cm.dockerClient.StopContainer(ctx, containerID, timeout); err != nil {
cm.logger.Warn("failed to stop container gracefully, forcing removal",
zap.String("container_id", containerID),
zap.Error(err))
}
if err := cm.dockerClient.RemoveContainer(ctx, containerID, true); err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
cm.logger.Info("container stopped and removed",
zap.String("container_id", containerID))
return nil
}
// buildContainerConfig builds the Docker container configuration
func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker.ContainerConfig, error) {
agentConfig := config.AgentConfig
// Build image name with tag
imageName := agentConfig.Image
if agentConfig.Tag != "" {
imageName = fmt.Sprintf("%s:%s", agentConfig.Image, agentConfig.Tag)
}
// Build command using CommandBuilder
cmdOpts := CommandOptions{
Model: config.Model,
SessionID: config.SessionID,
}
cmd := cm.commandBuilder.BuildCommand(agentConfig, cmdOpts)
// Expand mounts
mounts := cm.expandMounts(agentConfig.Mounts, config.WorkspacePath, agentConfig)
// Add main repo .git directory mount for worktrees
if config.MainRepoGitDir != "" {
mounts = append(mounts, docker.MountConfig{
Source: config.MainRepoGitDir,
Target: config.MainRepoGitDir, // Same path inside container
ReadOnly: false,
})
cm.logger.Debug("added main repo .git directory mount for worktree",
zap.String("path", config.MainRepoGitDir))
}
// Build environment variables
env := cm.buildEnvVars(config)
// Calculate resource limits
memoryBytes := agentConfig.ResourceLimits.MemoryMB * 1024 * 1024
cpuQuota := int64(agentConfig.ResourceLimits.CPUCores * 100000) // Docker CPU quota
containerName := fmt.Sprintf("kandev-agent-%s", config.InstanceID[:8])
return docker.ContainerConfig{
Name: containerName,
Image: imageName,
Cmd: cmd,
Env: env,
WorkingDir: agentConfig.WorkingDir,
Mounts: mounts,
NetworkMode: cm.networkName,
Memory: memoryBytes,
CPUQuota: cpuQuota,
Labels: map[string]string{
"kandev.managed": "true",
"kandev.instance_id": config.InstanceID,
"kandev.task_id": config.TaskID,
},
AutoRemove: false, // We manage cleanup ourselves
}, nil
}
// expandMounts expands mount templates with actual paths
func (cm *ContainerManager) expandMounts(templates []registry.MountTemplate, workspacePath string, agentConfig *registry.AgentTypeConfig) []docker.MountConfig {
mounts := make([]docker.MountConfig, 0, len(templates)+1) // +1 for potential session dir
for _, mt := range templates {
// Skip workspace mounts if no workspace path is provided
if strings.Contains(mt.Source, "{workspace}") && workspacePath == "" {
cm.logger.Debug("skipping workspace mount - no workspace path provided",
zap.String("target", mt.Target))
continue
}
source := cm.expandMountSource(mt.Source, workspacePath)
mounts = append(mounts, docker.MountConfig{
Source: source,
Target: mt.Target,
ReadOnly: mt.ReadOnly,
})
}
// Add session directory mount from SessionConfig
sessionDirSource := cm.commandBuilder.ExpandSessionDir(agentConfig)
sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(agentConfig)
if sessionDirSource != "" && sessionDirTarget != "" {
mounts = append(mounts, docker.MountConfig{
Source: sessionDirSource,
Target: sessionDirTarget,
ReadOnly: false,
})
cm.logger.Debug("added session directory mount",
zap.String("source", sessionDirSource),
zap.String("target", sessionDirTarget))
}
return mounts
}
// expandMountSource expands template variables in mount source paths
func (cm *ContainerManager) expandMountSource(source, workspacePath string) string {
result := source
result = strings.ReplaceAll(result, "{workspace}", workspacePath)
return result
}
// buildEnvVars builds environment variables for the container
func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string {
agentConfig := config.AgentConfig
env := make([]string, 0)
// Add default env from agent config
for k, v := range agentConfig.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add standard kandev env vars
env = append(env,
fmt.Sprintf("KANDEV_TASK_ID=%s", config.TaskID),
fmt.Sprintf("KANDEV_INSTANCE_ID=%s", config.InstanceID),
)
// Pass protocol to agentctl inside the container
if agentConfig.Protocol != "" {
env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", agentConfig.Protocol))
}
// Configure Git to trust the workspace directory
// This is needed because the container runs as root but files are owned by host user
// Uses Git's environment-based config (GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n)
env = append(env,
"GIT_CONFIG_COUNT=1",
"GIT_CONFIG_KEY_0=safe.directory",
"GIT_CONFIG_VALUE_0=*", // Trust all directories in the container
)
// Inject credentials from the provided credentials map
for k, v := range config.Credentials {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
// Add profile-specific label if available
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
env = append(env, fmt.Sprintf("KANDEV_AGENT_PROFILE_ID=%s", config.ProfileInfo.ProfileID))
}
return env
}
@@ -0,0 +1,353 @@
// Package lifecycle manages agent instance lifecycles including tracking,
// state transitions, and cleanup.
package lifecycle
import (
"context"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
)
// EventPublisher handles publishing agent lifecycle and session events to the event bus.
type EventPublisher struct {
eventBus bus.EventBus
logger *logger.Logger
}
// NewEventPublisher creates a new EventPublisher with the given event bus and logger.
func NewEventPublisher(eventBus bus.EventBus, log *logger.Logger) *EventPublisher {
return &EventPublisher{
eventBus: eventBus,
logger: log.WithFields(zap.String("component", "event-publisher")),
}
}
// PublishAgentEvent publishes an agent lifecycle event (started, stopped, ready, completed, failed).
func (p *EventPublisher) PublishAgentEvent(ctx context.Context, eventType string, instance *AgentInstance) {
if p.eventBus == nil {
return
}
data := map[string]interface{}{
"instance_id": instance.ID,
"task_id": instance.TaskID,
"agent_profile_id": instance.AgentProfileID,
"container_id": instance.ContainerID,
"status": string(instance.Status),
"started_at": instance.StartedAt,
"progress": instance.Progress,
"error_message": instance.ErrorMessage,
}
if instance.FinishedAt != nil {
data["finished_at"] = *instance.FinishedAt
}
if instance.ExitCode != nil {
data["exit_code"] = *instance.ExitCode
}
event := bus.NewEvent(eventType, "agent-manager", data)
if err := p.eventBus.Publish(ctx, eventType, event); err != nil {
p.logger.Error("failed to publish event",
zap.String("event_type", eventType),
zap.String("instance_id", instance.ID),
zap.Error(err))
} else {
p.logger.Debug("published agent event",
zap.String("event_type", eventType),
zap.String("instance_id", instance.ID))
}
}
// PublishACPSessionCreated publishes an event when an ACP session is created.
func (p *EventPublisher) PublishACPSessionCreated(instance *AgentInstance, sessionID string) {
if p.eventBus == nil || sessionID == "" {
return
}
data := map[string]interface{}{
"task_id": instance.TaskID,
"agent_instance_id": instance.ID,
"acp_session_id": sessionID,
}
event := bus.NewEvent(events.AgentACPSessionCreated, "agent-manager", data)
if err := p.eventBus.Publish(context.Background(), events.AgentACPSessionCreated, event); err != nil {
p.logger.Error("failed to publish ACP session event",
zap.String("event_type", events.AgentACPSessionCreated),
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// PublishSessionUpdate publishes a session update to the event bus for WebSocket streaming.
func (p *EventPublisher) PublishSessionUpdate(instance *AgentInstance, update agentctl.SessionUpdate) {
if p.eventBus == nil {
return
}
// Build the update data - our SessionUpdate type marshals cleanly
updateData := map[string]interface{}{
"type": update.Type,
}
if update.SessionID != "" {
updateData["session_id"] = update.SessionID
}
if update.Text != "" {
updateData["text"] = update.Text
}
if update.ToolCallID != "" {
updateData["tool_call_id"] = update.ToolCallID
}
if update.ToolName != "" {
updateData["tool_name"] = update.ToolName
}
if update.ToolTitle != "" {
updateData["tool_title"] = update.ToolTitle
}
if update.ToolStatus != "" {
updateData["tool_status"] = update.ToolStatus
}
if update.ToolArgs != nil {
updateData["tool_args"] = update.ToolArgs
}
if update.ToolResult != nil {
updateData["tool_result"] = update.ToolResult
}
if update.Error != "" {
updateData["error"] = update.Error
}
if update.Data != nil {
updateData["data"] = update.Data
}
// Build ACP message data
data := map[string]interface{}{
"type": "session/update",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"session_id": update.SessionID,
"data": updateData,
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish session update",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// PublishGitStatus publishes a git status update to the event bus.
func (p *EventPublisher) PublishGitStatus(instance *AgentInstance, update *agentctl.GitStatusUpdate) {
if p.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": instance.TaskID,
"agent_id": instance.ID,
"branch": update.Branch,
"remote_branch": update.RemoteBranch,
"modified": update.Modified,
"added": update.Added,
"deleted": update.Deleted,
"untracked": update.Untracked,
"renamed": update.Renamed,
"ahead": update.Ahead,
"behind": update.Behind,
"files": update.Files,
"timestamp": update.Timestamp.Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.GitStatusUpdated, "agent-manager", data)
subject := events.BuildGitStatusSubject(instance.TaskID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish git status event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// PublishFileChange publishes a file change notification to the event bus.
func (p *EventPublisher) PublishFileChange(instance *AgentInstance, notification *agentctl.FileChangeNotification) {
if p.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": instance.TaskID,
"agent_id": instance.ID,
"path": notification.Path,
"operation": notification.Operation,
"timestamp": notification.Timestamp.Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.FileChangeNotified, "agent-manager", data)
subject := events.BuildFileChangeSubject(instance.TaskID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish file change event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// PublishPromptComplete publishes a prompt_complete event when an agent finishes responding.
// This is used to save the agent's response as a comment on the task.
func (p *EventPublisher) PublishPromptComplete(instance *AgentInstance, agentMessage, reasoning, summary string) {
if p.eventBus == nil {
return
}
// Only publish if there's actual content
if agentMessage == "" {
return
}
data := map[string]interface{}{
"type": "prompt_complete",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"agent_message": agentMessage,
}
if reasoning != "" {
data["reasoning"] = reasoning
}
if summary != "" {
data["summary"] = summary
}
event := bus.NewEvent(events.PromptComplete, "agent-manager", data)
// Publish on task-specific subject so orchestrator can subscribe
subject := events.PromptComplete + "." + instance.TaskID
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish prompt_complete event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// PublishToolCall publishes a tool call start event (to be saved as a comment).
func (p *EventPublisher) PublishToolCall(instance *AgentInstance, toolCallID, title, status string, args map[string]interface{}) {
if p.eventBus == nil {
return
}
data := map[string]interface{}{
"type": "tool_call",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"tool_call_id": toolCallID,
"title": title,
"status": status,
}
if args != nil {
data["args"] = args
}
event := bus.NewEvent(events.ToolCallStarted, "agent-manager", data)
subject := events.ToolCallStarted + "." + instance.TaskID
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish tool_call event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
p.logger.Debug("published tool_call event",
zap.String("task_id", instance.TaskID),
zap.String("tool_call_id", toolCallID),
zap.String("title", title))
}
}
// PublishToolCallComplete publishes a tool call completion event from a SessionUpdate.
func (p *EventPublisher) PublishToolCallComplete(instance *AgentInstance, update agentctl.SessionUpdate) {
if p.eventBus == nil {
return
}
data := map[string]interface{}{
"type": "tool_call_complete",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"tool_call_id": update.ToolCallID,
"status": update.ToolStatus,
}
event := bus.NewEvent(events.ToolCallComplete, "agent-manager", data)
subject := events.ToolCallComplete + "." + instance.TaskID
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish tool_call_complete event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
}
}
// PublishPermissionRequest publishes a permission request event.
func (p *EventPublisher) PublishPermissionRequest(instance *AgentInstance, notification *agentctl.PermissionNotification) {
if p.eventBus == nil {
return
}
// Convert options to a serializable format
options := make([]map[string]interface{}, len(notification.Options))
for i, opt := range notification.Options {
options[i] = map[string]interface{}{
"option_id": opt.OptionID,
"name": opt.Name,
"kind": opt.Kind,
}
}
data := map[string]interface{}{
"type": "permission_request",
"task_id": instance.TaskID,
"agent_instance_id": instance.ID,
"pending_id": notification.PendingID,
"session_id": notification.SessionID,
"tool_call_id": notification.ToolCallID,
"title": notification.Title,
"options": options,
"created_at": notification.CreatedAt,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish permission_request event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
p.logger.Info("published permission_request event",
zap.String("task_id", instance.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
}
}
+101 -118
View File
@@ -120,6 +120,13 @@ type Manager struct {
agentCfg config.AgentConfig
standaloneCtl *agentctl.StandaloneCtl // Client for standalone agentctl control API
// Refactored components for separation of concerns
commandBuilder *CommandBuilder // Builds agent commands from registry config
sessionManager *SessionManager // Handles ACP session initialization
streamManager *StreamManager // Manages WebSocket streams
eventPublisher *EventPublisher // Publishes lifecycle events
containerManager *ContainerManager // Manages Docker containers
// Shell stream starter for auto-starting shell streams
shellStreamStarter ShellStreamStarter
@@ -143,19 +150,49 @@ func NewManager(
agentCfg config.AgentConfig,
log *logger.Logger,
) *Manager {
mgr := &Manager{
docker: dockerClient,
registry: reg,
eventBus: eventBus,
agentCfg: agentCfg,
logger: log.WithFields(zap.String("component", "lifecycle-manager")),
instances: make(map[string]*AgentInstance),
byTask: make(map[string]string),
byContainer: make(map[string]string),
cleanupInterval: 30 * time.Second,
stopCh: make(chan struct{}),
componentLogger := log.WithFields(zap.String("component", "lifecycle-manager"))
// Initialize command builder
commandBuilder := NewCommandBuilder()
// Initialize session manager
sessionManager := NewSessionManager(log)
// Initialize event publisher
eventPublisher := NewEventPublisher(eventBus, log)
// Initialize container manager (only if docker client is available)
// Note: Network configuration is not used currently - containers use default Docker networking
var containerManager *ContainerManager
if dockerClient != nil {
containerManager = NewContainerManager(dockerClient, "", log)
}
mgr := &Manager{
docker: dockerClient,
registry: reg,
eventBus: eventBus,
agentCfg: agentCfg,
logger: componentLogger,
commandBuilder: commandBuilder,
sessionManager: sessionManager,
eventPublisher: eventPublisher,
containerManager: containerManager,
instances: make(map[string]*AgentInstance),
byTask: make(map[string]string),
byContainer: make(map[string]string),
cleanupInterval: 30 * time.Second,
stopCh: make(chan struct{}),
}
// Initialize stream manager with callbacks that delegate to manager methods
mgr.streamManager = NewStreamManager(log, StreamCallbacks{
OnSessionUpdate: mgr.handleSessionUpdate,
OnPermission: mgr.handlePermissionNotification,
OnGitStatus: mgr.handleGitStatusUpdate,
OnFileChange: mgr.handleFileChangeNotification,
})
// Initialize standalone agentctl client if in standalone mode
if agentCfg.Runtime == "standalone" {
mgr.standaloneCtl = agentctl.NewStandaloneCtl(
@@ -537,7 +574,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
m.publishEvent(ctx, events.AgentStarted, instance)
// 10. Wait for agentctl to be ready and start the agent process
go m.initializeAgent(instance, req.TaskDescription)
go m.initializeAgent(instance, agentConfig, req.TaskDescription)
m.logger.Info("agent launched successfully",
zap.String("instance_id", instanceID),
@@ -612,23 +649,17 @@ func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *
// Build environment variables for the agent
env := m.buildStandaloneEnv(instanceID, req, agentConfig)
// Build agent command from registry config
// If Cmd is specified, join it; otherwise use default auggie command
agentCommand := ""
if len(agentConfig.Cmd) > 0 {
agentCommand = strings.Join(agentConfig.Cmd, " ")
// Build agent command using CommandBuilder (configuration-driven, no hardcoded agent names)
model := ""
if profileInfo != nil {
model = profileInfo.Model
}
// Append model flag if agent supports it and profile has a model configured
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
agentCommand = agentCommand + " " + agentConfig.ModelFlag + " " + profileInfo.Model
cmdOpts := CommandOptions{
Model: model,
SessionID: req.ACPSessionID, // CommandBuilder handles resume flag based on SessionConfig
}
if req.ACPSessionID != "" && profileInfo != nil && profileInfo.AgentName == "auggie" {
m.logger.Info("adding auggie resume flag to command (standalone)",
zap.String("task_id", req.TaskID),
zap.String("session_id", req.ACPSessionID))
agentCommand = agentCommand + " --resume " + req.ACPSessionID
}
// If empty, agentctl will use its default (auggie --acp)
agentCommand := m.commandBuilder.BuildCommandString(agentConfig, cmdOpts)
// If empty, agentctl will use its default
// Create instance via control API
createReq := &agentctl.CreateInstanceRequest{
@@ -754,7 +785,7 @@ func (m *Manager) getOrCreateWorktree(ctx context.Context, req *LaunchRequest) (
}
// initializeAgent waits for agentctl to be ready and initializes the agent
func (m *Manager) initializeAgent(instance *AgentInstance, taskDescription string) {
func (m *Manager) initializeAgent(instance *AgentInstance, agentConfig *registry.AgentTypeConfig, taskDescription string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
@@ -786,8 +817,8 @@ func (m *Manager) initializeAgent(instance *AgentInstance, taskDescription strin
// Give the agent process a moment to initialize
time.Sleep(500 * time.Millisecond)
// Initialize ACP session
if err := m.initializeACPSession(ctx, instance, taskDescription); err != nil {
// Initialize ACP session using configuration-driven SessionManager
if err := m.initializeACPSession(ctx, instance, agentConfig, taskDescription); err != nil {
m.logger.Error("failed to initialize ACP session",
zap.String("instance_id", instance.ID),
zap.Error(err))
@@ -796,94 +827,43 @@ func (m *Manager) initializeAgent(instance *AgentInstance, taskDescription strin
}
}
// initializeACPSession sends the ACP initialization messages using the acp-go-sdk
func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInstance, taskDescription string) error {
// initializeACPSession sends the ACP initialization messages using the SessionManager
// The agentConfig is used for configuration-driven session resumption behavior
func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInstance, agentConfig *registry.AgentTypeConfig, taskDescription string) error {
m.logger.Info("initializing ACP session",
zap.String("instance_id", instance.ID),
zap.String("agentctl_url", instance.agentctl.BaseURL()))
zap.String("agentctl_url", instance.agentctl.BaseURL()),
zap.String("agent_type", agentConfig.ID))
// 1. Send initialize request
m.logger.Info("sending ACP initialize request",
zap.String("instance_id", instance.ID))
agentInfo, err := instance.agentctl.Initialize(ctx, "kandev", "1.0.0")
// Use SessionManager for configuration-driven session initialization
// This replaces the hardcoded auggie-specific logic with registry-based configuration
result, err := m.sessionManager.InitializeSession(
ctx,
instance.agentctl,
agentConfig,
instance.ACPSessionID, // Existing session ID to resume (if any)
instance.WorkspacePath,
)
if err != nil {
m.logger.Error("ACP initialize failed",
m.logger.Error("session initialization failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("initialize failed: %w", err)
return err
}
agentName := "unknown"
agentVersion := "unknown"
if agentInfo != nil {
agentName = agentInfo.Name
agentVersion = agentInfo.Version
}
m.logger.Info("ACP initialize response received",
m.logger.Info("ACP session initialized",
zap.String("instance_id", instance.ID),
zap.String("agent_name", agentName),
zap.String("agent_version", agentVersion))
zap.String("agent_name", result.AgentName),
zap.String("agent_version", result.AgentVersion),
zap.String("session_id", result.SessionID))
// 2. Create or resume ACP session
sessionID := ""
if instance.ACPSessionID != "" && agentName != "auggie" {
m.logger.Info("sending ACP session/load request",
zap.String("instance_id", instance.ID),
zap.String("session_id", instance.ACPSessionID))
instance.ACPSessionID = result.SessionID
m.publishACPSessionCreated(instance, result.SessionID)
if err := instance.agentctl.LoadSession(ctx, instance.ACPSessionID); err != nil {
m.logger.Error("ACP session/load failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("session/load failed: %w", err)
}
sessionID = instance.ACPSessionID
m.logger.Info("ACP session loaded",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID))
} else {
if instance.ACPSessionID != "" && agentName == "auggie" {
m.logger.Info("skipping ACP session/load for auggie (resume handled via CLI)",
zap.String("instance_id", instance.ID),
zap.String("session_id", instance.ACPSessionID))
}
m.logger.Info("sending ACP session/new request",
zap.String("instance_id", instance.ID),
zap.String("workspace_path", instance.WorkspacePath))
newSessionID, err := instance.agentctl.NewSession(ctx, instance.WorkspacePath)
if err != nil {
m.logger.Error("ACP session/new failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("session/new failed: %w", err)
}
sessionID = newSessionID
m.logger.Info("ACP session created",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID))
}
instance.ACPSessionID = sessionID
m.logger.Info("ACP session ready",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID),
zap.String("agent_name", agentName))
m.publishACPSessionCreated(instance, sessionID)
// 3. Set up updates stream to receive session notifications
// Use a ready channel to signal when the stream is connected
// Set up WebSocket streams using StreamManager
// Use a ready channel to signal when the updates stream is connected
updatesReady := make(chan struct{})
go m.handleUpdatesStream(instance, updatesReady)
// 4. Set up permission stream to receive permission requests
go m.handlePermissionStream(instance)
// 5. Set up git status stream to track workspace changes
go m.handleGitStatusStream(instance)
// 6. Set up file changes stream to track filesystem changes
go m.handleFileChangesStream(instance)
m.streamManager.ConnectAll(instance, updatesReady)
// Note: Shell stream is started on-demand when a client sends shell.subscribe
// This ensures the buffered output (including initial prompt) is received
@@ -902,7 +882,7 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
if taskDescription != "" {
m.logger.Info("sending ACP prompt",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID),
zap.String("session_id", instance.ACPSessionID),
zap.String("task_description", taskDescription))
// Clear buffers before starting prompt
@@ -1354,6 +1334,11 @@ func (m *Manager) handleFileChangesStream(instance *AgentInstance) {
zap.Int("max_retries", maxRetries))
}
// handleFileChangeNotification processes file change notifications from the workspace tracker
func (m *Manager) handleFileChangeNotification(instance *AgentInstance, notification *agentctl.FileChangeNotification) {
m.publishFileChange(instance, notification)
}
// publishFileChange publishes a file change notification to the event bus
func (m *Manager) publishFileChange(instance *AgentInstance, notification *agentctl.FileChangeNotification) {
if m.eventBus == nil {
@@ -1621,18 +1606,16 @@ func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, ag
containerName := fmt.Sprintf("kandev-agent-%s", instanceID[:8])
// Build command, appending model flag if agent supports it and profile has a model
cmd := make([]string, len(agentConfig.Cmd))
copy(cmd, agentConfig.Cmd)
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
cmd = append(cmd, agentConfig.ModelFlag, profileInfo.Model)
// Build command using CommandBuilder (configuration-driven, no hardcoded agent names)
model := ""
if profileInfo != nil {
model = profileInfo.Model
}
if req.ACPSessionID != "" && profileInfo != nil && profileInfo.AgentName == "auggie" {
m.logger.Info("adding auggie resume flag to command",
zap.String("task_id", req.TaskID),
zap.String("session_id", req.ACPSessionID))
cmd = append(cmd, "--resume", req.ACPSessionID)
cmdOpts := CommandOptions{
Model: model,
SessionID: req.ACPSessionID, // CommandBuilder handles resume flag based on SessionConfig
}
cmd := m.commandBuilder.BuildCommand(agentConfig, cmdOpts)
return docker.ContainerConfig{
Name: containerName,
@@ -42,4 +42,3 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str
Plan: profile.Plan,
}, nil
}
@@ -11,10 +11,10 @@ import (
// MockRepository implements store.Repository for testing
type MockRepository struct {
GetAgentFn func(ctx context.Context, id string) (*models.Agent, error)
GetAgentByNameFn func(ctx context.Context, name string) (*models.Agent, error)
GetAgentProfileFn func(ctx context.Context, id string) (*models.AgentProfile, error)
ListAgentsFn func(ctx context.Context) ([]*models.Agent, error)
GetAgentFn func(ctx context.Context, id string) (*models.Agent, error)
GetAgentByNameFn func(ctx context.Context, name string) (*models.Agent, error)
GetAgentProfileFn func(ctx context.Context, id string) (*models.AgentProfile, error)
ListAgentsFn func(ctx context.Context) ([]*models.Agent, error)
ListAgentProfilesFn func(ctx context.Context, agentID string) ([]*models.AgentProfile, error)
}
@@ -214,4 +214,3 @@ func TestStoreProfileResolver_ResolveProfile_AgentNotFound(t *testing.T) {
t.Errorf("expected error message to start with '%s', got '%s'", expectedMsg, err.Error())
}
}
@@ -0,0 +1,169 @@
package lifecycle
import (
"context"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/logger"
)
// SessionManager handles ACP session initialization and management
type SessionManager struct {
logger *logger.Logger
}
// NewSessionManager creates a new SessionManager
func NewSessionManager(log *logger.Logger) *SessionManager {
return &SessionManager{
logger: log,
}
}
// InitializeResult contains the result of session initialization
type InitializeResult struct {
AgentName string
AgentVersion string
SessionID string
}
// InitializeSession initializes an ACP session with the agent.
// It handles the initialize handshake and session creation/loading based on config.
//
// Session behavior:
// - If agentConfig.SessionConfig.ResumeViaACP is true AND existingSessionID is provided: use session/load
// - If agentConfig.SessionConfig.ResumeViaACP is false (CLI handles resume): always use session/new
// - Otherwise: use session/new
func (sm *SessionManager) InitializeSession(
ctx context.Context,
client *agentctl.Client,
agentConfig *registry.AgentTypeConfig,
existingSessionID string,
workspacePath string,
) (*InitializeResult, error) {
sm.logger.Info("initializing ACP session",
zap.String("agent_type", agentConfig.ID),
zap.String("workspace_path", workspacePath),
zap.Bool("resume_via_acp", agentConfig.SessionConfig.ResumeViaACP),
zap.String("existing_session_id", existingSessionID))
// Step 1: Send initialize request
sm.logger.Info("sending ACP initialize request",
zap.String("agent_type", agentConfig.ID))
agentInfo, err := client.Initialize(ctx, "kandev", "1.0.0")
if err != nil {
sm.logger.Error("ACP initialize failed",
zap.String("agent_type", agentConfig.ID),
zap.Error(err))
return nil, fmt.Errorf("initialize failed: %w", err)
}
result := &InitializeResult{
AgentName: "unknown",
AgentVersion: "unknown",
}
if agentInfo != nil {
result.AgentName = agentInfo.Name
result.AgentVersion = agentInfo.Version
}
sm.logger.Info("ACP initialize response received",
zap.String("agent_type", agentConfig.ID),
zap.String("agent_name", result.AgentName),
zap.String("agent_version", result.AgentVersion))
// Step 2: Create or resume ACP session based on configuration
sessionID, err := sm.createOrLoadSession(ctx, client, agentConfig, existingSessionID, workspacePath)
if err != nil {
return nil, err
}
result.SessionID = sessionID
return result, nil
}
// createOrLoadSession determines whether to create a new session or load an existing one
// based on the agent configuration and whether an existing session ID is provided.
func (sm *SessionManager) createOrLoadSession(
ctx context.Context,
client *agentctl.Client,
agentConfig *registry.AgentTypeConfig,
existingSessionID string,
workspacePath string,
) (string, error) {
// Use session/load only when:
// 1. ResumeViaACP is true (agent supports ACP-based resume)
// 2. An existing session ID is provided
shouldLoadSession := agentConfig.SessionConfig.ResumeViaACP && existingSessionID != ""
if shouldLoadSession {
return sm.loadSession(ctx, client, agentConfig, existingSessionID)
}
// Log why we're creating a new session when an existing session ID was provided
if existingSessionID != "" && !agentConfig.SessionConfig.ResumeViaACP {
sm.logger.Info("skipping ACP session/load (resume handled via CLI)",
zap.String("agent_type", agentConfig.ID),
zap.String("session_id", existingSessionID),
zap.String("resume_flag", agentConfig.SessionConfig.ResumeFlag))
}
return sm.createNewSession(ctx, client, agentConfig, workspacePath)
}
// loadSession loads an existing session via ACP session/load
func (sm *SessionManager) loadSession(
ctx context.Context,
client *agentctl.Client,
agentConfig *registry.AgentTypeConfig,
sessionID string,
) (string, error) {
sm.logger.Info("sending ACP session/load request",
zap.String("agent_type", agentConfig.ID),
zap.String("session_id", sessionID))
if err := client.LoadSession(ctx, sessionID); err != nil {
sm.logger.Error("ACP session/load failed",
zap.String("agent_type", agentConfig.ID),
zap.String("session_id", sessionID),
zap.Error(err))
return "", fmt.Errorf("session/load failed: %w", err)
}
sm.logger.Info("ACP session loaded successfully",
zap.String("agent_type", agentConfig.ID),
zap.String("session_id", sessionID))
return sessionID, nil
}
// createNewSession creates a new session via ACP session/new
func (sm *SessionManager) createNewSession(
ctx context.Context,
client *agentctl.Client,
agentConfig *registry.AgentTypeConfig,
workspacePath string,
) (string, error) {
sm.logger.Info("sending ACP session/new request",
zap.String("agent_type", agentConfig.ID),
zap.String("workspace_path", workspacePath))
sessionID, err := client.NewSession(ctx, workspacePath)
if err != nil {
sm.logger.Error("ACP session/new failed",
zap.String("agent_type", agentConfig.ID),
zap.String("workspace_path", workspacePath),
zap.Error(err))
return "", fmt.Errorf("session/new failed: %w", err)
}
sm.logger.Info("ACP session created successfully",
zap.String("agent_type", agentConfig.ID),
zap.String("session_id", sessionID))
return sessionID, nil
}
@@ -0,0 +1,185 @@
package lifecycle
import (
"context"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/common/logger"
)
// StreamCallbacks defines callbacks for stream events
type StreamCallbacks struct {
OnSessionUpdate func(instance *AgentInstance, update agentctl.SessionUpdate)
OnPermission func(instance *AgentInstance, notification *agentctl.PermissionNotification)
OnGitStatus func(instance *AgentInstance, update *agentctl.GitStatusUpdate)
OnFileChange func(instance *AgentInstance, notification *agentctl.FileChangeNotification)
}
// StreamManager manages WebSocket streams to agent instances
type StreamManager struct {
logger *logger.Logger
callbacks StreamCallbacks
}
// NewStreamManager creates a new StreamManager
func NewStreamManager(log *logger.Logger, callbacks StreamCallbacks) *StreamManager {
return &StreamManager{
logger: log.WithFields(zap.String("component", "stream-manager")),
callbacks: callbacks,
}
}
// ConnectAll connects to all streams for an instance.
// If ready is non-nil, it will be closed when the updates stream is connected.
func (sm *StreamManager) ConnectAll(instance *AgentInstance, ready chan<- struct{}) {
go sm.connectUpdatesStream(instance, ready)
go sm.connectPermissionStream(instance)
go sm.connectGitStatusStream(instance)
go sm.connectFileChangesStream(instance)
}
// ReconnectAll reconnects to all streams (used after backend restart).
// This waits for agentctl to be ready before connecting to streams.
func (sm *StreamManager) ReconnectAll(instance *AgentInstance) {
sm.logger.Info("reconnecting to agent streams after recovery",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID))
// Wait a moment for any startup operations to settle
time.Sleep(500 * time.Millisecond)
// Check if agentctl is responsive
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := instance.agentctl.WaitForReady(ctx, 10*time.Second); err != nil {
sm.logger.Warn("agentctl not ready for stream reconnection",
zap.String("instance_id", instance.ID),
zap.Error(err))
// Don't return - still try to connect to streams
}
// Reconnect to WebSocket streams
sm.ConnectAll(instance, nil)
sm.logger.Info("agent streams reconnected",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID))
}
// connectUpdatesStream handles the updates WebSocket stream with ready signaling
func (sm *StreamManager) connectUpdatesStream(instance *AgentInstance, ready chan<- struct{}) {
ctx := context.Background()
err := instance.agentctl.StreamUpdates(ctx, func(update agentctl.SessionUpdate) {
if sm.callbacks.OnSessionUpdate != nil {
sm.callbacks.OnSessionUpdate(instance, update)
}
})
// Signal that the stream connection attempt is complete (success or failure)
// StreamUpdates returns immediately after establishing the WebSocket connection
// and starting the read goroutine, so this signals that we're ready to receive updates
if ready != nil {
close(ready)
}
if err != nil {
sm.logger.Error("failed to connect to updates stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// connectPermissionStream handles the permission WebSocket stream
func (sm *StreamManager) connectPermissionStream(instance *AgentInstance) {
ctx := context.Background()
err := instance.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) {
if sm.callbacks.OnPermission != nil {
sm.callbacks.OnPermission(instance, notification)
}
})
if err != nil {
sm.logger.Error("failed to connect to permission stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// connectGitStatusStream handles git status stream with retry logic
func (sm *StreamManager) connectGitStatusStream(instance *AgentInstance) {
ctx := context.Background()
// Retry connection with exponential backoff
maxRetries := 5
backoff := 1 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
err := instance.agentctl.StreamGitStatus(ctx, func(update *agentctl.GitStatusUpdate) {
if sm.callbacks.OnGitStatus != nil {
sm.callbacks.OnGitStatus(instance, update)
}
})
if err == nil {
// Connection closed normally
return
}
sm.logger.Debug("git status stream connection failed, retrying",
zap.String("instance_id", instance.ID),
zap.Int("attempt", attempt),
zap.Int("max_retries", maxRetries),
zap.Error(err))
if attempt < maxRetries {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
}
}
sm.logger.Error("failed to connect to git status stream after retries",
zap.String("instance_id", instance.ID),
zap.Int("max_retries", maxRetries))
}
// connectFileChangesStream handles file changes stream with retry logic
func (sm *StreamManager) connectFileChangesStream(instance *AgentInstance) {
ctx := context.Background()
// Retry connection with exponential backoff
maxRetries := 5
backoff := 1 * time.Second
for attempt := 1; attempt <= maxRetries; attempt++ {
err := instance.agentctl.StreamFileChanges(ctx, func(notification *agentctl.FileChangeNotification) {
if sm.callbacks.OnFileChange != nil {
sm.callbacks.OnFileChange(instance, notification)
}
})
if err == nil {
// Connection closed normally
return
}
sm.logger.Debug("file changes stream connection failed, retrying",
zap.String("instance_id", instance.ID),
zap.Int("attempt", attempt),
zap.Int("max_retries", maxRetries),
zap.Error(err))
if attempt < maxRetries {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
}
}
sm.logger.Error("failed to connect to file changes stream after retries",
zap.String("instance_id", instance.ID),
zap.Int("max_retries", maxRetries))
}
@@ -19,7 +19,6 @@ func DefaultAgents() []*AgentTypeConfig {
},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
{Source: "{augment_sessions}", Target: "/root/.augment/sessions", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
@@ -31,6 +30,12 @@ func DefaultAgents() []*AgentTypeConfig {
ModelFlag: "--model",
WorkspaceFlag: "--workspace-root",
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
ResumeViaACP: false, // Auggie handles resume via CLI flag
ResumeFlag: "--resume",
SessionDirTemplate: "{home}/.augment/sessions",
SessionDirTarget: "/root/.augment/sessions",
},
},
{
ID: "claude-agent",
@@ -54,6 +59,9 @@ func DefaultAgents() []*AgentTypeConfig {
Enabled: true,
ModelFlag: "--model",
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
ResumeViaACP: true, // Claude uses ACP session/load for resume
},
},
{
ID: "codex-agent",
@@ -77,6 +85,9 @@ func DefaultAgents() []*AgentTypeConfig {
Enabled: true,
ModelFlag: "--model",
Protocol: agent.ProtocolCodex,
SessionConfig: SessionConfig{
ResumeViaACP: true, // Codex uses ACP session/load for resume
},
},
{
ID: "gemini-agent",
@@ -100,6 +111,9 @@ func DefaultAgents() []*AgentTypeConfig {
Enabled: true,
ModelFlag: "--model",
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
ResumeViaACP: true, // Gemini uses ACP session/load for resume
},
},
{
ID: "opencode-agent",
@@ -123,6 +137,9 @@ func DefaultAgents() []*AgentTypeConfig {
Enabled: true,
ModelFlag: "--model",
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
ResumeViaACP: true, // OpenCode uses ACP session/load for resume
},
},
{
ID: "copilot-agent",
@@ -146,6 +163,9 @@ func DefaultAgents() []*AgentTypeConfig {
Enabled: true,
ModelFlag: "--model",
Protocol: agent.ProtocolACP,
SessionConfig: SessionConfig{
ResumeViaACP: true, // Copilot uses ACP session/load for resume
},
},
}
}
@@ -37,6 +37,29 @@ type AgentTypeConfig struct {
// Protocol configuration
Protocol agent.Protocol `json:"protocol,omitempty"` // Communication protocol: "acp", "rest", "mcp"
ProtocolConfig map[string]string `json:"protocol_config,omitempty"` // Protocol-specific settings (e.g., base_url for REST)
// Session resumption configuration
SessionConfig SessionConfig `json:"session_config,omitempty"` // How to handle session resumption
}
// SessionConfig defines how session resumption is handled for an agent type
type SessionConfig struct {
// ResumeViaACP indicates whether session resumption should use the ACP session/load method.
// If false, session resumption is handled via CLI flags (ResumeFlag).
ResumeViaACP bool `json:"resume_via_acp"`
// ResumeFlag is the CLI flag used for session resumption when ResumeViaACP is false.
// Example: "--resume" for auggie, empty for agents that use ACP session/load.
ResumeFlag string `json:"resume_flag,omitempty"`
// SessionDirTemplate is a template for the host directory where session data is stored.
// Supports variables: {home} (user home directory).
// Example: "{home}/.augment/sessions" for auggie.
SessionDirTemplate string `json:"session_dir_template,omitempty"`
// SessionDirTarget is the container path where the session directory is mounted.
// Example: "/root/.augment/sessions" for auggie.
SessionDirTarget string `json:"session_dir_target,omitempty"`
}
// MountTemplate defines a mount with template support