feat: dynamic model switching and session status fix (#68)
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
# Kandev Backend - Next Steps
|
||||
|
||||
## Current State (2026-01-10)
|
||||
|
||||
The backend is a unified Go binary (`cmd/kandev/main.go`) that runs:
|
||||
- **Task Service**: Kanban board/column/task management (SQLite persistence)
|
||||
- **Agent Manager**: Docker container lifecycle for AI agents
|
||||
- **Orchestrator**: Coordinates task execution with agents
|
||||
|
||||
### Working Features
|
||||
|
||||
1. **SQLite Persistence**
|
||||
- Database file: `./kandev.db` (configurable via `KANDEV_DB_PATH`)
|
||||
- Stores boards, columns, and tasks with metadata
|
||||
- WAL mode enabled for concurrent reads
|
||||
- Data survives server restarts
|
||||
|
||||
2. **Augment Agent Integration**
|
||||
- Docker image: `kandev/augment-agent:latest`
|
||||
- Uses Auggie CLI with `--print --output-format json`
|
||||
- ACP (Agent Communication Protocol) message streaming
|
||||
- Session ID extraction and storage in task metadata
|
||||
|
||||
3. **Session Resumption**
|
||||
- Sessions persist in `~/.augment/sessions/` (mounted into containers)
|
||||
- Follow-up agents can resume with `AUGGIE_SESSION_ID` env var
|
||||
|
||||
4. **Real-time Streaming**
|
||||
- Container logs parsed for ACP JSON messages
|
||||
- Events published to in-memory event bus
|
||||
- WebSocket endpoints available for frontend
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
### 1. Agent Run History
|
||||
Track all agent executions:
|
||||
- [ ] Add `agent_runs` table to SQLite schema
|
||||
- [ ] Store task_id, session_id, started_at, completed_at, exit_code
|
||||
- [ ] Link runs to tasks for history viewing
|
||||
- [ ] Store ACP messages for replay
|
||||
|
||||
### 2. Frontend Integration
|
||||
- [ ] Create React/Vue frontend for Kanban board
|
||||
- [ ] WebSocket client for real-time agent progress
|
||||
- [ ] Task detail view with agent history and session resumption
|
||||
|
||||
### 3. Automatic Task Orchestration
|
||||
- [ ] Configure tasks with `agent_type` field
|
||||
- [ ] Auto-launch agents when tasks move to specific columns
|
||||
- [ ] Handle agent failures with retry logic
|
||||
|
||||
## API Reference
|
||||
|
||||
All communication happens over WebSocket at `ws://localhost:8080/ws`.
|
||||
See `docs/WEBSOCKET_API.md` for complete API documentation.
|
||||
|
||||
### Key Actions
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `board.create` | Create a new board |
|
||||
| `task.create` | Create a task |
|
||||
| `task.subscribe` | Subscribe to ACP streaming for a task |
|
||||
| `orchestrator.start` | Start executing a task with an agent |
|
||||
| `agent.prompt` | Send follow-up prompt to running agent |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd apps/backend && go build -o kandev ./cmd/kandev
|
||||
|
||||
# Run server
|
||||
./kandev
|
||||
|
||||
# Build Docker image
|
||||
cd apps/backend/dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest .
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `cmd/kandev/main.go` | Unified entry point, ACP handlers |
|
||||
| `internal/task/repository/interface.go` | Repository interface definition |
|
||||
| `internal/task/repository/sqlite.go` | SQLite implementation with schema |
|
||||
| `internal/task/repository/memory.go` | In-memory implementation (for testing) |
|
||||
| `internal/agent/lifecycle/manager.go` | Container lifecycle, mount templates |
|
||||
| `internal/agent/registry/defaults.go` | Agent type definitions |
|
||||
| `internal/agent/streaming/reader.go` | ACP message parsing from logs |
|
||||
| `dockerfiles/augment-agent/agent.sh` | Container entrypoint script |
|
||||
| `dockerfiles/augment-agent/Dockerfile` | Agent Docker image |
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
1. **SQLite with WAL mode** for simple, file-based persistence
|
||||
2. **In-memory event bus** instead of NATS for simpler development
|
||||
3. **One agent per task** constraint (prevents conflicts)
|
||||
4. **Session files mounted** from host for persistence across container runs
|
||||
5. **ACP protocol** for structured agent-to-server communication
|
||||
6. **Repository interface** allows swapping storage backends (SQLite, Memory, PostgreSQL)
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `KANDEV_DB_PATH` - SQLite database file path (default: `./kandev.db`)
|
||||
- `KANDEV_LOGGING_LEVEL` - Log level: debug, info, warn, error (default: info)
|
||||
- `KANDEV_CREDENTIALS_FILE` - Path to credentials file for agents
|
||||
@@ -70,6 +70,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
|
||||
Env: req.Env,
|
||||
ACPSessionID: req.ACPSessionID,
|
||||
Metadata: req.Metadata,
|
||||
ModelOverride: req.ModelOverride,
|
||||
// Worktree configuration for concurrent agent execution
|
||||
UseWorktree: req.UseWorktree,
|
||||
RepositoryID: req.RepositoryID,
|
||||
@@ -209,14 +210,41 @@ func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context,
|
||||
return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
func (a *lifecycleAdapter) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
config, err := a.registry.Get(agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.ToAPIType(), 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,
|
||||
AutoApprove: info.AutoApprove,
|
||||
DangerouslySkipPermissions: info.DangerouslySkipPermissions,
|
||||
Plan: info.Plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// orchestratorAdapter adapts the orchestrator.Service to the taskhandlers.OrchestratorService interface
|
||||
type orchestratorAdapter struct {
|
||||
svc *orchestrator.Service
|
||||
}
|
||||
|
||||
// PromptTask forwards to the orchestrator service and converts the result type
|
||||
func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID, taskSessionID, prompt string) (*taskhandlers.PromptResult, error) {
|
||||
result, err := a.svc.PromptTask(ctx, taskID, taskSessionID, prompt)
|
||||
func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID, taskSessionID, prompt, model string) (*taskhandlers.PromptResult, error) {
|
||||
result, err := a.svc.PromptTask(ctx, taskID, taskSessionID, prompt, model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -32,8 +32,12 @@ func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, op
|
||||
copy(cmd, agentConfig.Cmd)
|
||||
|
||||
// Append model flag if agent supports it and model is specified
|
||||
// ModelFlag supports {model} placeholder, e.g. "--model {model}" or "-c model=\"{model}\""
|
||||
if agentConfig.ModelFlag != "" && opts.Model != "" {
|
||||
cmd = append(cmd, agentConfig.ModelFlag, opts.Model)
|
||||
expanded := strings.ReplaceAll(agentConfig.ModelFlag, "{model}", opts.Model)
|
||||
// Split on first space to separate flag from value (if combined)
|
||||
parts := strings.SplitN(expanded, " ", 2)
|
||||
cmd = append(cmd, parts...)
|
||||
}
|
||||
|
||||
// Append session resume flag if:
|
||||
|
||||
@@ -497,6 +497,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
|
||||
model = profileInfo.Model
|
||||
autoApprove = profileInfo.AutoApprove
|
||||
}
|
||||
// Allow model override from request (for dynamic model switching)
|
||||
if req.ModelOverride != "" {
|
||||
model = req.ModelOverride
|
||||
}
|
||||
cmdOpts := CommandOptions{
|
||||
Model: model,
|
||||
SessionID: req.ACPSessionID,
|
||||
@@ -595,13 +599,17 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err
|
||||
return fmt.Errorf("failed to resolve MCP config: %w", err)
|
||||
}
|
||||
|
||||
// Capture the original ACP session ID before initialization overwrites it.
|
||||
// This is used to determine if we're resuming an existing session or starting a new one.
|
||||
providedACPSessionID := execution.ACPSessionID
|
||||
|
||||
// Initialize ACP session
|
||||
if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil {
|
||||
m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error())
|
||||
return fmt.Errorf("failed to initialize ACP: %w", err)
|
||||
}
|
||||
|
||||
m.emitSessionStatusEvent(execution, agentConfig)
|
||||
m.emitSessionStatusEvent(execution, agentConfig, providedACPSessionID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -732,6 +740,16 @@ func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*regist
|
||||
return agentConfig, nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information.
|
||||
// This is exposed for external callers (like the orchestrator executor) to get profile info.
|
||||
// The profile's model is guaranteed to be non-empty as it's validated at creation time.
|
||||
func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
||||
if m.profileResolver == nil {
|
||||
return nil, fmt.Errorf("profile resolver not configured")
|
||||
}
|
||||
return m.profileResolver.ResolveProfile(ctx, profileID)
|
||||
}
|
||||
|
||||
// resolveMcpServers centralizes MCP resolution for a session:
|
||||
// - loads per-agent MCP config,
|
||||
// - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection,
|
||||
@@ -816,9 +834,11 @@ func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExec
|
||||
}
|
||||
|
||||
// emitSessionStatusEvent emits a session_status event indicating whether the session was resumed or new.
|
||||
func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig *registry.AgentTypeConfig) {
|
||||
// providedACPSessionID is the ACP session ID that was provided BEFORE initialization (for resumption).
|
||||
// This must be captured before initializeACPSession runs, as that overwrites execution.ACPSessionID.
|
||||
func (m *Manager) emitSessionStatusEvent(execution *AgentExecution, agentConfig *registry.AgentTypeConfig, providedACPSessionID string) {
|
||||
wasResumed := false
|
||||
if execution.ACPSessionID != "" {
|
||||
if providedACPSessionID != "" {
|
||||
if agentConfig.SessionConfig.ResumeViaACP {
|
||||
wasResumed = true
|
||||
} else if agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() {
|
||||
|
||||
@@ -83,6 +83,7 @@ type LaunchRequest struct {
|
||||
Env map[string]string // Additional env vars
|
||||
ACPSessionID string // ACP session ID to resume, if available
|
||||
Metadata map[string]interface{}
|
||||
ModelOverride string // If set, use this model instead of the profile's model
|
||||
|
||||
// Worktree configuration
|
||||
UseWorktree bool // Whether to use a Git worktree for isolation
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"],
|
||||
"protocol": "acp",
|
||||
"protocol_config": {},
|
||||
"model_flag": "--model",
|
||||
"model_flag": "--model {model}",
|
||||
"workspace_flag": "--workspace-root",
|
||||
"session_config": {
|
||||
"resume_via_acp": false,
|
||||
@@ -132,7 +132,7 @@
|
||||
"capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"],
|
||||
"protocol": "codex",
|
||||
"protocol_config": {},
|
||||
"model_flag": "--model",
|
||||
"model_flag": "-c model=\"{model}\"",
|
||||
"workspace_flag": "",
|
||||
"session_config": {
|
||||
"resume_via_acp": true,
|
||||
@@ -169,6 +169,7 @@
|
||||
{
|
||||
"id": "gpt-5.2-codex",
|
||||
"name": "GPT-5.2 Codex",
|
||||
"description": "Latest frontier agentic coding model",
|
||||
"provider": "openai",
|
||||
"context_window": 200000,
|
||||
"is_default": true
|
||||
@@ -176,6 +177,7 @@
|
||||
{
|
||||
"id": "gpt-5.1-codex-max",
|
||||
"name": "GPT-5.1 Codex Max",
|
||||
"description": "Codex-optimized flagship for deep and fast reasoning",
|
||||
"provider": "openai",
|
||||
"context_window": 200000,
|
||||
"is_default": false
|
||||
@@ -183,6 +185,7 @@
|
||||
{
|
||||
"id": "gpt-5.1-codex-mini",
|
||||
"name": "GPT-5.1 Codex Mini",
|
||||
"description": "Optimized for codex. Cheaper, faster, but less capable",
|
||||
"provider": "openai",
|
||||
"context_window": 200000,
|
||||
"is_default": false
|
||||
@@ -190,6 +193,7 @@
|
||||
{
|
||||
"id": "gpt-5.2",
|
||||
"name": "GPT-5.2",
|
||||
"description": "Latest frontier model with improvements across knowledge, reasoning and coding",
|
||||
"provider": "openai",
|
||||
"context_window": 200000,
|
||||
"is_default": false
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
ErrAgentProfileNotFound = errors.New("agent profile not found")
|
||||
ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session")
|
||||
ErrAgentMcpUnsupported = errors.New("mcp not supported by agent")
|
||||
ErrModelRequired = errors.New("model is required for agent profiles")
|
||||
)
|
||||
|
||||
const defaultAgentProfileName = "default"
|
||||
@@ -150,6 +151,7 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
displayNameByAgent := c.displayNameByAgent()
|
||||
defaultModelByAgent := c.defaultModelByAgent()
|
||||
for _, result := range results {
|
||||
if !result.Available {
|
||||
continue
|
||||
@@ -158,6 +160,10 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
|
||||
if !ok || displayName == "" {
|
||||
return fmt.Errorf("unknown agent display name: %s", result.Name)
|
||||
}
|
||||
defaultModel, ok := defaultModelByAgent[result.Name]
|
||||
if !ok || defaultModel == "" {
|
||||
return fmt.Errorf("unknown agent default model: %s", result.Name)
|
||||
}
|
||||
agent, err := c.repo.GetAgentByName(ctx, result.Name)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
@@ -197,7 +203,7 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
|
||||
defaultProfile := &models.AgentProfile{
|
||||
AgentID: agent.ID,
|
||||
Name: defaultAgentProfileName,
|
||||
Model: "",
|
||||
Model: defaultModel,
|
||||
Plan: "",
|
||||
AgentDisplayName: displayName,
|
||||
}
|
||||
@@ -399,6 +405,10 @@ type CreateProfileRequest struct {
|
||||
}
|
||||
|
||||
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
|
||||
// Validate that model is provided
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return nil, ErrModelRequired
|
||||
}
|
||||
agent, err := c.repo.GetAgent(ctx, req.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -441,6 +451,10 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest
|
||||
profile.Name = *req.Name
|
||||
}
|
||||
if req.Model != nil {
|
||||
// Validate that model is not empty
|
||||
if strings.TrimSpace(*req.Model) == "" {
|
||||
return nil, ErrModelRequired
|
||||
}
|
||||
profile.Model = *req.Model
|
||||
}
|
||||
if req.AutoApprove != nil {
|
||||
@@ -542,3 +556,15 @@ func (c *Controller) mustDisplayName(agentName string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Controller) defaultModelByAgent() map[string]string {
|
||||
definitions := c.discovery.Definitions()
|
||||
mapped := make(map[string]string, len(definitions))
|
||||
for _, def := range definitions {
|
||||
if def.Name == "" || def.ModelConfig.DefaultModel == "" {
|
||||
continue
|
||||
}
|
||||
mapped[def.Name] = def.ModelConfig.DefaultModel
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (r *sqliteRepository) initSchema() error {
|
||||
agent_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
agent_display_name TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
model TEXT NOT NULL CHECK(model != ''),
|
||||
auto_approve INTEGER NOT NULL DEFAULT 0,
|
||||
dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
|
||||
plan TEXT DEFAULT '',
|
||||
|
||||
@@ -458,6 +458,36 @@ func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
func (s *SimulatedAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
agentTypes, _ := s.ListAgentTypes(ctx)
|
||||
for _, at := range agentTypes {
|
||||
if at.ID == agentID {
|
||||
return at, nil
|
||||
}
|
||||
}
|
||||
// Return a default agent type for testing
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Simulated Agent",
|
||||
Description: "Simulated agent for testing",
|
||||
DockerImage: "test/agent",
|
||||
DockerTag: "test",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
return &executor.AgentProfileInfo{
|
||||
ProfileID: profileID,
|
||||
ProfileName: "Simulated Profile",
|
||||
AgentID: "augment-agent",
|
||||
AgentName: "Augment Agent",
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORCHESTRATOR TEST SERVER
|
||||
// ============================================
|
||||
|
||||
@@ -139,7 +139,7 @@ func (c *Controller) StopTask(ctx context.Context, req dto.StopTaskRequest) (dto
|
||||
|
||||
// PromptTask sends a prompt to a running task
|
||||
func (c *Controller) PromptTask(ctx context.Context, req dto.PromptTaskRequest) (dto.PromptTaskResponse, error) {
|
||||
result, err := c.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt)
|
||||
result, err := c.service.PromptTask(ctx, req.TaskID, req.TaskSessionID, req.Prompt, req.Model)
|
||||
if err != nil {
|
||||
return dto.PromptTaskResponse{}, err
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ type PromptTaskRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskSessionID string `json:"session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"` // Optional: switch to this model before processing prompt
|
||||
}
|
||||
|
||||
// PromptTaskResponse is the response for orchestrator.prompt
|
||||
|
||||
@@ -4,6 +4,7 @@ package executor
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -67,6 +68,24 @@ type AgentManagerClient interface {
|
||||
// CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it.
|
||||
// This is used when we detect the agent process has stopped but the execution is still tracked.
|
||||
CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error)
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
|
||||
}
|
||||
|
||||
// AgentProfileInfo contains resolved profile information
|
||||
type AgentProfileInfo struct {
|
||||
ProfileID string
|
||||
ProfileName string
|
||||
AgentID string
|
||||
AgentName string
|
||||
Model string
|
||||
AutoApprove bool
|
||||
DangerouslySkipPermissions bool
|
||||
Plan string
|
||||
}
|
||||
|
||||
// LaunchAgentRequest contains parameters for launching an agent
|
||||
@@ -82,6 +101,7 @@ type LaunchAgentRequest struct {
|
||||
Metadata map[string]interface{}
|
||||
Env map[string]string
|
||||
ACPSessionID string // ACP session ID to resume, if available
|
||||
ModelOverride string // If set, use this model instead of the profile's model
|
||||
|
||||
// Worktree configuration for concurrent agent execution
|
||||
UseWorktree bool // Whether to use a Git worktree for isolation
|
||||
@@ -286,19 +306,42 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
|
||||
req.WorktreeBranchPrefix = worktreeBranchPrefix
|
||||
}
|
||||
|
||||
// Resolve agent profile to get model and other settings for snapshot
|
||||
var agentProfileSnapshot map[string]interface{}
|
||||
if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil {
|
||||
agentProfileSnapshot = map[string]interface{}{
|
||||
"id": profileInfo.ProfileID,
|
||||
"name": profileInfo.ProfileName,
|
||||
"agent_id": profileInfo.AgentID,
|
||||
"agent_name": profileInfo.AgentName,
|
||||
"model": profileInfo.Model,
|
||||
"auto_approve": profileInfo.AutoApprove,
|
||||
"dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions,
|
||||
"plan": profileInfo.Plan,
|
||||
}
|
||||
e.logger.Debug("resolved agent profile for snapshot",
|
||||
zap.String("profile_id", profileInfo.ProfileID),
|
||||
zap.String("model", profileInfo.Model))
|
||||
} else if err != nil {
|
||||
e.logger.Warn("failed to resolve agent profile for snapshot, session will have empty snapshot",
|
||||
zap.String("agent_profile_id", agentProfileID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Create agent session in database before launch so worktree associations can persist.
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
session := &models.TaskSession{
|
||||
ID: sessionID,
|
||||
TaskID: task.ID,
|
||||
AgentProfileID: agentProfileID,
|
||||
RepositoryID: repositoryID,
|
||||
BaseBranch: baseBranch,
|
||||
State: models.TaskSessionStateCreated,
|
||||
Progress: 0,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: sessionID,
|
||||
TaskID: task.ID,
|
||||
AgentProfileID: agentProfileID,
|
||||
RepositoryID: repositoryID,
|
||||
BaseBranch: baseBranch,
|
||||
State: models.TaskSessionStateCreated,
|
||||
Progress: 0,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
AgentProfileSnapshot: agentProfileSnapshot,
|
||||
}
|
||||
if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" {
|
||||
session.ExecutorID = executorID
|
||||
@@ -780,6 +823,151 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt
|
||||
return e.agentManager.PromptAgent(ctx, session.AgentExecutionID, prompt)
|
||||
}
|
||||
|
||||
// SwitchModel stops the current agent, restarts it with a new model, and sends the prompt.
|
||||
// For agents that support session resume (can_recover: true), it attempts to resume context.
|
||||
// For agents that don't support resume (can_recover: false), a fresh session is started.
|
||||
func (e *Executor) SwitchModel(ctx context.Context, taskID, sessionID, newModel, prompt string) (*PromptResult, error) {
|
||||
e.logger.Info("switching model for session",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("new_model", newModel))
|
||||
|
||||
// Get the session
|
||||
session, err := e.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
if session.TaskID != taskID {
|
||||
return nil, fmt.Errorf("session %s does not belong to task %s", sessionID, taskID)
|
||||
}
|
||||
|
||||
// Get current execution ID
|
||||
oldAgentExecutionID := session.AgentExecutionID
|
||||
if oldAgentExecutionID == "" {
|
||||
return nil, ErrExecutionNotFound
|
||||
}
|
||||
|
||||
// Get the task for re-launching
|
||||
task, err := e.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get task: %w", err)
|
||||
}
|
||||
|
||||
// Get executor running info to check for resume token (ACP session ID)
|
||||
var acpSessionID string
|
||||
if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
|
||||
acpSessionID = running.ResumeToken
|
||||
}
|
||||
|
||||
// Stop the current agent
|
||||
e.logger.Info("stopping current agent for model switch",
|
||||
zap.String("agent_execution_id", oldAgentExecutionID))
|
||||
if err := e.agentManager.StopAgent(ctx, oldAgentExecutionID, false); err != nil {
|
||||
e.logger.Warn("failed to stop agent for model switch, continuing anyway",
|
||||
zap.Error(err),
|
||||
zap.String("agent_execution_id", oldAgentExecutionID))
|
||||
}
|
||||
|
||||
// Build a new launch request with the model override
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: task.ID,
|
||||
SessionID: sessionID, // Reuse the existing session ID
|
||||
TaskTitle: task.Title,
|
||||
AgentProfileID: session.AgentProfileID,
|
||||
TaskDescription: prompt,
|
||||
ModelOverride: newModel, // This is the key - use the new model
|
||||
ACPSessionID: acpSessionID,
|
||||
}
|
||||
|
||||
// Get repository info if available
|
||||
if session.RepositoryID != "" {
|
||||
repository, err := e.repo.GetRepository(ctx, session.RepositoryID)
|
||||
if err == nil && repository != nil {
|
||||
req.RepositoryURL = repository.LocalPath
|
||||
req.Branch = session.BaseBranch
|
||||
}
|
||||
}
|
||||
|
||||
// Get worktree info from running state
|
||||
if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
|
||||
if running.WorktreePath != "" {
|
||||
req.RepositoryURL = running.WorktreePath
|
||||
}
|
||||
}
|
||||
|
||||
req.Env = e.applyPreferredShellEnv(ctx, req.Env)
|
||||
|
||||
e.logger.Info("launching new agent with model override",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("model", newModel),
|
||||
zap.String("acp_session_id", acpSessionID))
|
||||
|
||||
// Launch the new agent
|
||||
resp, err := e.agentManager.LaunchAgent(ctx, req)
|
||||
if err != nil {
|
||||
e.logger.Error("failed to launch agent with new model",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to launch agent with new model: %w", err)
|
||||
}
|
||||
|
||||
// Update the session with the new execution ID
|
||||
session.AgentExecutionID = resp.AgentExecutionID
|
||||
session.ContainerID = resp.ContainerID
|
||||
session.State = models.TaskSessionStateStarting
|
||||
session.UpdatedAt = time.Now().UTC()
|
||||
|
||||
// Update the agent profile snapshot with the new model
|
||||
if session.AgentProfileSnapshot == nil {
|
||||
session.AgentProfileSnapshot = make(map[string]interface{})
|
||||
}
|
||||
session.AgentProfileSnapshot["model"] = newModel
|
||||
|
||||
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
|
||||
e.logger.Error("failed to update session after model switch",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Update executor running state
|
||||
if running, err := e.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
|
||||
running.AgentExecutionID = resp.AgentExecutionID
|
||||
running.ContainerID = resp.ContainerID
|
||||
running.Status = "starting"
|
||||
if err := e.repo.UpsertExecutorRunning(ctx, running); err != nil {
|
||||
e.logger.Warn("failed to update executor running after model switch",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Start the agent process (this also handles initialization and prompting)
|
||||
if err := e.agentManager.StartAgentProcess(ctx, resp.AgentExecutionID); err != nil {
|
||||
e.logger.Error("failed to start agent process after model switch",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("agent_execution_id", resp.AgentExecutionID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to start agent after model switch: %w", err)
|
||||
}
|
||||
|
||||
e.logger.Info("model switch complete, agent started",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("new_model", newModel),
|
||||
zap.String("agent_execution_id", resp.AgentExecutionID))
|
||||
|
||||
// The agent initialization and prompt are handled as part of StartAgentProcess
|
||||
// Return success - the actual prompt response will come via ACP events
|
||||
return &PromptResult{
|
||||
StopReason: "model_switched",
|
||||
AgentMessage: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RespondToPermission sends a response to a permission request for a session
|
||||
func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error {
|
||||
e.logger.Info("responding to permission request",
|
||||
@@ -1086,3 +1274,31 @@ func (m *MockAgentManagerClient) IsAgentRunningForSession(ctx context.Context, s
|
||||
func (m *MockAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentType mocks getting an agent type configuration
|
||||
func (m *MockAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
m.logger.Info("mock: getting agent type",
|
||||
zap.String("agent_id", agentID))
|
||||
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Mock Agent",
|
||||
Description: "Mock agent type for testing",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile mocks resolving an agent profile
|
||||
func (m *MockAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
||||
m.logger.Info("mock: resolving agent profile",
|
||||
zap.String("profile_id", profileID))
|
||||
|
||||
return &AgentProfileInfo{
|
||||
ProfileID: profileID,
|
||||
ProfileName: "Mock Profile",
|
||||
AgentID: "mock-agent",
|
||||
AgentName: "mock",
|
||||
Model: "mock-model",
|
||||
AutoApprove: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ type wsPromptTaskRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskSessionID string `json:"session_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -197,6 +198,7 @@ func (h *Handlers) wsPromptTask(ctx context.Context, msg *ws.Message) (*ws.Messa
|
||||
TaskID: req.TaskID,
|
||||
TaskSessionID: req.TaskSessionID,
|
||||
Prompt: req.Prompt,
|
||||
Model: req.Model,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to send prompt", zap.String("task_id", req.TaskID), zap.Error(err))
|
||||
|
||||
@@ -86,6 +86,24 @@ func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Mock Agent",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
return &executor.AgentProfileInfo{
|
||||
ProfileID: profileID,
|
||||
ProfileName: "Mock Profile",
|
||||
AgentID: "mock-agent",
|
||||
AgentName: "Mock Agent",
|
||||
Model: "mock-model",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testTaskRepository is an in-memory task repository for testing
|
||||
type testTaskRepository struct {
|
||||
tasks map[string]*v1.Task
|
||||
|
||||
@@ -621,7 +621,7 @@ type PromptResult struct {
|
||||
}
|
||||
|
||||
// PromptTask sends a follow-up prompt to a running agent for a task session.
|
||||
func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string) (*PromptResult, error) {
|
||||
func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string) (*PromptResult, error) {
|
||||
s.logger.Info("sending prompt to task agent",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
@@ -629,6 +629,38 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom
|
||||
if sessionID == "" {
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
// Check if model switching is requested
|
||||
if model != "" {
|
||||
// Get the current session to check current model
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session for model switch: %w", err)
|
||||
}
|
||||
|
||||
// Get current model from agent profile snapshot
|
||||
if session.AgentProfileSnapshot != nil {
|
||||
if currentModel, ok := session.AgentProfileSnapshot["model"].(string); ok && currentModel != model {
|
||||
// Model switch needed - delegate to executor
|
||||
s.logger.Info("model switch requested",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("current_model", currentModel),
|
||||
zap.String("new_model", model))
|
||||
|
||||
// SwitchModel will stop agent, rebuild with new model, restart, and send prompt
|
||||
switchResult, err := s.executor.SwitchModel(ctx, taskID, sessionID, model, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PromptResult{
|
||||
StopReason: switchResult.StopReason,
|
||||
AgentMessage: switchResult.AgentMessage,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.setSessionRunning(ctx, taskID, sessionID, nil)
|
||||
result, err := s.executor.Prompt(ctx, taskID, sessionID, prompt)
|
||||
if err != nil {
|
||||
@@ -1140,10 +1172,11 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID
|
||||
zap.String("new_state", string(nextState)))
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"old_state": string(oldState),
|
||||
"new_state": string(nextState),
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"old_state": string(oldState),
|
||||
"new_state": string(nextState),
|
||||
"agent_profile_id": session.AgentProfileID,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1293,9 +1326,10 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
map[string]interface{}{
|
||||
"task_id": data.TaskID,
|
||||
"session_id": session.ID,
|
||||
"new_state": string(session.State),
|
||||
"task_id": data.TaskID,
|
||||
"session_id": session.ID,
|
||||
"new_state": string(session.State),
|
||||
"agent_profile_id": session.AgentProfileID,
|
||||
"metadata": map[string]interface{}{
|
||||
"context_window": contextWindowData,
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ type PromptResult struct {
|
||||
|
||||
// OrchestratorService defines the interface for orchestrator operations
|
||||
type OrchestratorService interface {
|
||||
PromptTask(ctx context.Context, taskID, sessionID, prompt string) (*PromptResult, error)
|
||||
PromptTask(ctx context.Context, taskID, sessionID, prompt, model string) (*PromptResult, error)
|
||||
ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ type wsAddMessageRequest struct {
|
||||
TaskSessionID string `json:"session_id"`
|
||||
Content string `json:"content"`
|
||||
AuthorID string `json:"author_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -184,9 +185,10 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
taskID := req.TaskID
|
||||
sessionID := req.TaskSessionID
|
||||
content := req.Content
|
||||
model := req.Model
|
||||
go func() {
|
||||
promptCtx := context.WithoutCancel(ctx)
|
||||
_, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content)
|
||||
_, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model)
|
||||
if err != nil {
|
||||
if errors.Is(err, executor.ErrExecutionNotFound) {
|
||||
if resumeErr := h.orchestrator.ResumeTaskSession(promptCtx, taskID, sessionID); resumeErr != nil {
|
||||
@@ -197,7 +199,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
|
||||
} else {
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, err = h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content)
|
||||
_, err = h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchTaskSession,
|
||||
fetchTask,
|
||||
listAgents,
|
||||
listAvailableAgents,
|
||||
listRepositories,
|
||||
listTaskSessionMessages,
|
||||
listTaskSessions,
|
||||
@@ -32,13 +33,15 @@ export default async function SessionPage({
|
||||
}
|
||||
|
||||
task = await fetchTask(session.task_id, { cache: 'no-store' });
|
||||
const [snapshot, agents, repositories, allSessionsResponse] = await Promise.all([
|
||||
const [snapshot, agents, repositories, allSessionsResponse, availableAgentsResponse] = await Promise.all([
|
||||
fetchBoardSnapshot(task.board_id, { cache: 'no-store' }),
|
||||
listAgents({ cache: 'no-store' }),
|
||||
listRepositories(task.workspace_id, { cache: 'no-store' }),
|
||||
listTaskSessions(session.task_id, { cache: 'no-store' }),
|
||||
listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })),
|
||||
]);
|
||||
const allSessions = allSessionsResponse.sessions ?? [session];
|
||||
const availableAgents = availableAgentsResponse.agents ?? [];
|
||||
let messagesResponse: ListMessagesResponse | null = null;
|
||||
try {
|
||||
messagesResponse = await listTaskSessionMessages(
|
||||
@@ -129,6 +132,11 @@ export default async function SessionPage({
|
||||
executorsLoaded: false,
|
||||
environmentsLoaded: false,
|
||||
},
|
||||
availableAgents: {
|
||||
items: availableAgents,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -59,12 +59,12 @@ type AgentSetupFormProps = {
|
||||
onToastError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const createDraftProfile = (agentId: string, agentDisplayName: string): DraftProfile => ({
|
||||
const createDraftProfile = (agentId: string, agentDisplayName: string, defaultModel: string): DraftProfile => ({
|
||||
id: `draft-${generateUUID()}`,
|
||||
agent_id: agentId,
|
||||
name: '',
|
||||
agent_display_name: agentDisplayName,
|
||||
model: '',
|
||||
model: defaultModel,
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: false,
|
||||
plan: '',
|
||||
@@ -81,13 +81,13 @@ const cloneAgent = (agent: Agent): DraftAgent => ({
|
||||
profiles: agent.profiles.map((profile) => ({ ...profile })) as DraftProfile[],
|
||||
});
|
||||
|
||||
const ensureProfiles = (agent: DraftAgent, agentDisplayName: string): DraftAgent => {
|
||||
const ensureProfiles = (agent: DraftAgent, agentDisplayName: string, defaultModel: string): DraftAgent => {
|
||||
if (agent.profiles.length > 0) {
|
||||
return agent;
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
profiles: [createDraftProfile(agent.id, agentDisplayName)],
|
||||
profiles: [createDraftProfile(agent.id, agentDisplayName, defaultModel)],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -169,7 +169,7 @@ function AgentSetupForm({
|
||||
...current,
|
||||
profiles: [
|
||||
...current.profiles,
|
||||
{ ...createDraftProfile(current.id, resolveDisplayName(current.name)), id: draftId },
|
||||
{ ...createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model), id: draftId },
|
||||
],
|
||||
}));
|
||||
setNewProfileId(draftId);
|
||||
@@ -183,7 +183,7 @@ function AgentSetupForm({
|
||||
profiles:
|
||||
remaining.length > 0
|
||||
? remaining
|
||||
: [createDraftProfile(current.id, resolveDisplayName(current.name))],
|
||||
: [createDraftProfile(current.id, resolveDisplayName(current.name), currentAgentModelConfig.default_model)],
|
||||
};
|
||||
});
|
||||
if (newProfileId === profileId) {
|
||||
@@ -244,6 +244,10 @@ function AgentSetupForm({
|
||||
onToastError(new Error('Profile name is required.'));
|
||||
return;
|
||||
}
|
||||
if (draftAgent.profiles.some((profile) => !profile.model.trim())) {
|
||||
onToastError(new Error('Model is required for all profiles.'));
|
||||
return;
|
||||
}
|
||||
if (hasInvalidMcpConfig) {
|
||||
onToastError(new Error('Fix invalid MCP JSON before saving.'));
|
||||
return;
|
||||
@@ -312,7 +316,7 @@ function AgentSetupForm({
|
||||
});
|
||||
}
|
||||
upsertAgent(created);
|
||||
setDraftAgent(ensureProfiles(cloneAgent(created), resolveDisplayName(created.name)));
|
||||
setDraftAgent(ensureProfiles(cloneAgent(created), resolveDisplayName(created.name), currentAgentModelConfig.default_model));
|
||||
router.replace(`/settings/agents/${encodeURIComponent(created.name)}`);
|
||||
} else {
|
||||
const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
|
||||
@@ -379,7 +383,7 @@ function AgentSetupForm({
|
||||
profiles: nextProfiles,
|
||||
};
|
||||
upsertAgent(nextAgent);
|
||||
setDraftAgent(ensureProfiles(cloneAgent(nextAgent), resolveDisplayName(nextAgent.name)));
|
||||
setDraftAgent(ensureProfiles(cloneAgent(nextAgent), resolveDisplayName(nextAgent.name), currentAgentModelConfig.default_model));
|
||||
}
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
@@ -568,8 +572,10 @@ export default function AgentSetupPage() {
|
||||
if (!decodedKey) return null;
|
||||
const resolveDisplayName = (name: string) =>
|
||||
availableAgents.find((item) => item.name === name)?.display_name ?? '';
|
||||
const resolveDefaultModel = (name: string) =>
|
||||
availableAgents.find((item) => item.name === name)?.model_config?.default_model ?? '';
|
||||
if (savedAgent) {
|
||||
return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name));
|
||||
return ensureProfiles(cloneAgent(savedAgent), resolveDisplayName(savedAgent.name), resolveDefaultModel(savedAgent.name));
|
||||
}
|
||||
if (discoveryAgent) {
|
||||
const draft: DraftAgent = {
|
||||
@@ -583,7 +589,7 @@ export default function AgentSetupPage() {
|
||||
updated_at: new Date().toISOString(),
|
||||
isNew: true,
|
||||
};
|
||||
return ensureProfiles(draft, resolveDisplayName(draft.name));
|
||||
return ensureProfiles(draft, resolveDisplayName(draft.name), resolveDefaultModel(draft.name));
|
||||
}
|
||||
return null;
|
||||
}, [decodedKey, discoveryAgent, savedAgent, availableAgents]);
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function TaskPage({
|
||||
<TaskLayout
|
||||
workspaceId={task?.workspace_id ?? null}
|
||||
boardId={task?.board_id ?? null}
|
||||
sessionId={effectiveSessionId}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -69,6 +69,14 @@ function ProfileEditor({ agent, profile, modelConfig, initialMcpConfig }: Profil
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!draft.model.trim()) {
|
||||
toast({
|
||||
title: 'Model is required',
|
||||
description: 'Please select a model before saving.',
|
||||
variant: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSaveStatus('loading');
|
||||
try {
|
||||
const updated = await updateAgentProfileAction(draft.id, {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { IconCpu } from '@tabler/icons-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useAvailableAgents } from '@/hooks/use-available-agents';
|
||||
import { useSettingsData } from '@/hooks/use-settings-data';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ModelSelectorProps = {
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export function ModelSelector({ sessionId }: ModelSelectorProps) {
|
||||
// Ensure settings data (agents with profiles) is loaded
|
||||
useSettingsData(true);
|
||||
|
||||
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
||||
const taskSessions = useAppStore((state) => state.taskSessions.items);
|
||||
const pendingModels = useAppStore((state) => state.pendingModel.bySessionId);
|
||||
const activeModels = useAppStore((state) => state.activeModel.bySessionId);
|
||||
const setPendingModel = useAppStore((state) => state.setPendingModel);
|
||||
|
||||
// Ensure available agents are loaded (contains model_config)
|
||||
// Note: We don't block on this loading - it only affects the dropdown options
|
||||
const { items: availableAgents } = useAvailableAgents();
|
||||
|
||||
// Find the session from taskSessions
|
||||
const session = sessionId ? (taskSessions[sessionId] ?? null) : null;
|
||||
|
||||
// Get model from agent_profile_snapshot (guaranteed to be non-empty since model is required)
|
||||
const snapshotModel =
|
||||
session?.agent_profile_snapshot && typeof session.agent_profile_snapshot === 'object'
|
||||
? (session.agent_profile_snapshot as Record<string, unknown>).model
|
||||
: null;
|
||||
const snapshotModelStr = typeof snapshotModel === 'string' && snapshotModel ? snapshotModel : null;
|
||||
|
||||
// Resolve the agent profile from settings (for getting available models list)
|
||||
let sessionProfile: { profile: (typeof settingsAgents)[0]['profiles'][0]; agent: (typeof settingsAgents)[0] } | null = null;
|
||||
if (session?.agent_profile_id) {
|
||||
for (const agent of settingsAgents) {
|
||||
const profile = agent.profiles.find((p) => p.id === session.agent_profile_id);
|
||||
if (profile) {
|
||||
sessionProfile = { profile, agent };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available models from the agent discovery data
|
||||
let availableModels: { id: string; name: string; provider: string; context_window: number; is_default: boolean }[] = [];
|
||||
if (sessionProfile?.agent) {
|
||||
const agentName = sessionProfile.agent.name;
|
||||
const availableAgent = availableAgents.find((a) => a.name === agentName);
|
||||
if (availableAgent?.model_config?.available_models) {
|
||||
availableModels = availableAgent.model_config.available_models;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority: activeModel (after switch) > snapshot model
|
||||
// Profile model fallback removed - all profiles now have models
|
||||
const activeModel = sessionId ? (activeModels[sessionId] || null) : null;
|
||||
const currentModel = activeModel || snapshotModelStr;
|
||||
const pendingModel = sessionId ? (pendingModels[sessionId] || null) : null;
|
||||
const displayedModel = pendingModel || currentModel || '';
|
||||
const hasPendingChange = pendingModel && pendingModel !== currentModel;
|
||||
|
||||
// Ensure current model is included in the list (even if not in available models)
|
||||
const modelOptions = [...availableModels];
|
||||
if (currentModel && !modelOptions.some((m) => m.id === currentModel)) {
|
||||
modelOptions.unshift({
|
||||
id: currentModel,
|
||||
name: currentModel,
|
||||
provider: 'unknown',
|
||||
context_window: 0,
|
||||
is_default: false,
|
||||
});
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId: string) => {
|
||||
if (!sessionId) return;
|
||||
setPendingModel(sessionId, modelId);
|
||||
};
|
||||
|
||||
// If no session or no model available, show disabled placeholder
|
||||
if (!sessionId || !currentModel) {
|
||||
return (
|
||||
<Select disabled>
|
||||
<SelectTrigger className="w-[180px] cursor-not-allowed opacity-50">
|
||||
<SelectValue placeholder="No model" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={displayedModel} onValueChange={handleModelChange}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'w-[180px] cursor-pointer',
|
||||
hasPendingChange && 'border-primary/60 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconCpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select model" />
|
||||
{hasPendingChange && (
|
||||
<span className="ml-auto text-[10px] font-medium text-primary">pending</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type TaskCenterPanelProps = {
|
||||
openFileRequest: OpenFileTab | null;
|
||||
onDiffPathHandled: () => void;
|
||||
onFileOpenHandled: () => void;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
@@ -28,6 +29,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
openFileRequest,
|
||||
onDiffPathHandled,
|
||||
onFileOpenHandled,
|
||||
sessionId = null,
|
||||
}: TaskCenterPanelProps) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const [leftTab, setLeftTab] = useState('chat');
|
||||
@@ -152,7 +154,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
|
||||
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
|
||||
{activeTaskId ? (
|
||||
<TaskChatPanel agents={AGENTS} />
|
||||
<TaskChatPanel agents={AGENTS} sessionId={sessionId} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No task selected
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IconBrain, IconCpu, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
|
||||
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kandev/ui/dropdown-menu';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
@@ -31,25 +31,20 @@ import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display';
|
||||
import { TodoSummary } from '@/components/task/chat/todo-summary';
|
||||
import { SessionsDropdown } from '@/components/task/sessions-dropdown';
|
||||
import { useSessionContextWindow } from '@/hooks/use-session-context-window';
|
||||
|
||||
type AgentOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
import { ModelSelector } from '@/components/task/model-selector';
|
||||
|
||||
type TaskChatPanelProps = {
|
||||
agents: AgentOption[];
|
||||
/** @deprecated No longer used - model selection is now handled by ModelSelector */
|
||||
agents?: { id: string; label: string }[];
|
||||
onSend?: (message: string) => void;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export function TaskChatPanel({
|
||||
agents,
|
||||
onSend,
|
||||
sessionId = null,
|
||||
}: TaskChatPanelProps) {
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
|
||||
const [planModeEnabled, setPlanModeEnabled] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -84,6 +79,12 @@ export function TaskChatPanel({
|
||||
|
||||
const sessionModel = sessionProfile?.model ?? null;
|
||||
|
||||
// Get pending model state for this session
|
||||
const pendingModels = useAppStore((state) => state.pendingModel.bySessionId);
|
||||
const clearPendingModel = useAppStore((state) => state.clearPendingModel);
|
||||
const setActiveModel = useAppStore((state) => state.setActiveModel);
|
||||
const pendingModel = resolvedSessionId ? pendingModels[resolvedSessionId] : null;
|
||||
|
||||
// Fetch context window usage for this session
|
||||
const contextWindow = useSessionContextWindow(resolvedSessionId);
|
||||
|
||||
@@ -248,12 +249,27 @@ export function TaskChatPanel({
|
||||
}
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
// Include pending model in the request if one is selected
|
||||
const modelToSend = pendingModel && pendingModel !== sessionModel ? pendingModel : undefined;
|
||||
|
||||
await client.request(
|
||||
'message.add',
|
||||
{ task_id: taskId, session_id: resolvedSessionId, content: message },
|
||||
{
|
||||
task_id: taskId,
|
||||
session_id: resolvedSessionId,
|
||||
content: message,
|
||||
...(modelToSend && { model: modelToSend }),
|
||||
},
|
||||
10000
|
||||
);
|
||||
}, [resolvedSessionId, taskId]);
|
||||
|
||||
// Move pending model to active model after sending (it's now the model in use)
|
||||
if (modelToSend && resolvedSessionId) {
|
||||
setActiveModel(resolvedSessionId, modelToSend);
|
||||
clearPendingModel(resolvedSessionId);
|
||||
}
|
||||
}, [resolvedSessionId, taskId, pendingModel, sessionModel, setActiveModel, clearPendingModel]);
|
||||
|
||||
// Cancels the current agent turn without terminating the agent process,
|
||||
// allowing the user to interrupt and send a new prompt.
|
||||
@@ -341,17 +357,10 @@ export function TaskChatPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session info - shows agent state and model */}
|
||||
{/* Session info - shows agent state */}
|
||||
{session?.state && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RunningIndicator state={session.state} />
|
||||
{/* Model indicator */}
|
||||
{sessionModel && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-muted/50 text-xs text-muted-foreground border border-border/50">
|
||||
<IconCpu className="h-3 w-3" />
|
||||
<span className="font-medium">{sessionModel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,18 +381,7 @@ export function TaskChatPanel({
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
|
||||
<SelectTrigger className="w-[160px] cursor-pointer">
|
||||
<SelectValue placeholder="Select agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector sessionId={resolvedSessionId} />
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -14,11 +14,13 @@ const DEFAULT_HORIZONTAL_LAYOUT: [number, number, number] = [18, 57, 25];
|
||||
type TaskLayoutProps = {
|
||||
workspaceId: string | null;
|
||||
boardId: string | null;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export const TaskLayout = memo(function TaskLayout({
|
||||
workspaceId,
|
||||
boardId,
|
||||
sessionId = null,
|
||||
}: TaskLayoutProps) {
|
||||
const [horizontalLayout, setHorizontalLayout] = useState<[number, number, number]>(
|
||||
getLocalStorage('task-layout-horizontal-v2', DEFAULT_HORIZONTAL_LAYOUT)
|
||||
@@ -64,6 +66,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
openFileRequest={openFileRequest}
|
||||
onDiffPathHandled={() => setSelectedDiffPath(null)}
|
||||
onFileOpenHandled={() => setOpenFileRequest(null)}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-px" />
|
||||
|
||||
@@ -259,6 +259,14 @@ export type SessionAgentctlState = {
|
||||
itemsBySessionId: Record<string, SessionAgentctlStatus>;
|
||||
};
|
||||
|
||||
export type PendingModelState = {
|
||||
bySessionId: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ActiveModelState = {
|
||||
bySessionId: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
kanban: KanbanState;
|
||||
workspaces: WorkspaceState;
|
||||
@@ -290,6 +298,8 @@ export type AppState = {
|
||||
sessionAgentctl: SessionAgentctlState;
|
||||
worktrees: WorktreesState;
|
||||
sessionWorktreesBySessionId: SessionWorktreesState;
|
||||
pendingModel: PendingModelState;
|
||||
activeModel: ActiveModelState;
|
||||
hydrate: (state: Partial<AppState>) => void;
|
||||
setActiveWorkspace: (workspaceId: string | null) => void;
|
||||
setWorkspaces: (workspaces: WorkspaceState['items']) => void;
|
||||
@@ -352,6 +362,9 @@ export type AppState = {
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
bumpAgentProfilesVersion: () => void;
|
||||
setPendingModel: (sessionId: string, modelId: string) => void;
|
||||
clearPendingModel: (sessionId: string) => void;
|
||||
setActiveModel: (sessionId: string, modelId: string) => void;
|
||||
};
|
||||
|
||||
export type AppStore = ReturnType<typeof createAppStore>;
|
||||
@@ -402,6 +415,8 @@ const defaultState: AppState = {
|
||||
sessionAgentctl: { itemsBySessionId: {} },
|
||||
worktrees: { items: {} },
|
||||
sessionWorktreesBySessionId: { itemsBySessionId: {} },
|
||||
pendingModel: { bySessionId: {} },
|
||||
activeModel: { bySessionId: {} },
|
||||
hydrate: () => undefined,
|
||||
setActiveWorkspace: () => undefined,
|
||||
setWorkspaces: () => undefined,
|
||||
@@ -450,6 +465,9 @@ const defaultState: AppState = {
|
||||
clearGitStatus: () => undefined,
|
||||
setContextWindow: () => undefined,
|
||||
bumpAgentProfilesVersion: () => undefined,
|
||||
setPendingModel: () => undefined,
|
||||
clearPendingModel: () => undefined,
|
||||
setActiveModel: () => undefined,
|
||||
};
|
||||
|
||||
function mergeInitialState(
|
||||
@@ -504,6 +522,9 @@ function mergeInitialState(
|
||||
| 'clearGitStatus'
|
||||
| 'setContextWindow'
|
||||
| 'bumpAgentProfilesVersion'
|
||||
| 'setPendingModel'
|
||||
| 'clearPendingModel'
|
||||
| 'setActiveModel'
|
||||
> {
|
||||
if (!initialState) return defaultState;
|
||||
return {
|
||||
@@ -543,6 +564,8 @@ function mergeInitialState(
|
||||
...defaultState.sessionWorktreesBySessionId,
|
||||
...initialState.sessionWorktreesBySessionId,
|
||||
},
|
||||
pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel },
|
||||
activeModel: { ...defaultState.activeModel, ...initialState.activeModel },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -842,7 +865,11 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
}),
|
||||
setTaskSession: (session) =>
|
||||
set((draft) => {
|
||||
draft.taskSessions.items[session.id] = session;
|
||||
// Merge with existing session data to preserve fields like agent_profile_id
|
||||
const existingSession = draft.taskSessions.items[session.id];
|
||||
draft.taskSessions.items[session.id] = existingSession
|
||||
? { ...existingSession, ...session }
|
||||
: session;
|
||||
if (session.worktree_id) {
|
||||
draft.worktrees.items[session.worktree_id] = {
|
||||
id: session.worktree_id,
|
||||
@@ -909,6 +936,18 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
set((draft) => {
|
||||
draft.agentProfiles.version += 1;
|
||||
}),
|
||||
setPendingModel: (sessionId, modelId) =>
|
||||
set((draft) => {
|
||||
draft.pendingModel.bySessionId[sessionId] = modelId;
|
||||
}),
|
||||
clearPendingModel: (sessionId) =>
|
||||
set((draft) => {
|
||||
delete draft.pendingModel.bySessionId[sessionId];
|
||||
}),
|
||||
setActiveModel: (sessionId, modelId) =>
|
||||
set((draft) => {
|
||||
draft.activeModel.bySessionId[sessionId] = modelId;
|
||||
}),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export type TaskSessionStateChangedPayload = {
|
||||
session_id: string;
|
||||
old_state?: string;
|
||||
new_state: string;
|
||||
agent_profile_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,13 +18,15 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
if (sessionId) {
|
||||
const existingSession = store.getState().taskSessions.items[sessionId];
|
||||
if (existingSession) {
|
||||
// Update existing session
|
||||
// Update existing session - preserve all existing fields
|
||||
store.getState().setTaskSession({
|
||||
...existingSession,
|
||||
state: newState,
|
||||
});
|
||||
} else {
|
||||
// Create partial session object
|
||||
// Include agent_profile_id from the payload if provided
|
||||
const agentProfileId = payload.agent_profile_id;
|
||||
store.getState().setTaskSession({
|
||||
id: sessionId,
|
||||
task_id: taskId,
|
||||
@@ -32,6 +34,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
progress: 0,
|
||||
started_at: '',
|
||||
updated_at: '',
|
||||
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,6 +42,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
if (sessionsByTask) {
|
||||
const hasSession = sessionsByTask.some((session) => session.id === sessionId);
|
||||
if (!hasSession) {
|
||||
const agentProfileId = payload.agent_profile_id;
|
||||
store.getState().setTaskSessionsForTask(taskId, [...sessionsByTask, {
|
||||
id: sessionId,
|
||||
task_id: taskId,
|
||||
@@ -46,6 +50,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
progress: 0,
|
||||
started_at: '',
|
||||
updated_at: '',
|
||||
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
|
||||
}]);
|
||||
} else {
|
||||
const nextSessions = sessionsByTask.map((session) =>
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
# Dynamic Model Switching Feature Analysis
|
||||
|
||||
> **Status**: Design Document
|
||||
> **Created**: 2026-01-21
|
||||
> **Scope**: Mid-session model switching within the Kandev application
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document analyzes the implementation of dynamic model switching, allowing users to change the AI model mid-session without starting a new session. The feature requires UI changes to expose model selection, backend logic to detect model changes and restart agents, and careful handling of session continuity.
|
||||
|
||||
## Current Architecture Overview
|
||||
|
||||
### Session Lifecycle (Current Flow)
|
||||
|
||||
```
|
||||
User clicks "Start Session"
|
||||
→ Frontend sends orchestrator.start with agentProfileId
|
||||
→ Orchestrator.StartTask → Executor.ExecuteWithProfile
|
||||
→ Lifecycle Manager resolves profile → gets model from AgentProfileInfo
|
||||
→ CommandBuilder.BuildCommand adds --model flag
|
||||
→ Agent process started with fixed model
|
||||
→ ACP session initialized
|
||||
→ User sends prompts via session/prompt
|
||||
```
|
||||
|
||||
**Key Insight**: Model is determined at session start via `AgentProfileInfo.Model` and baked into the agent command. There's no current mechanism to change the model mid-session.
|
||||
|
||||
### Relevant Components
|
||||
|
||||
| Component | Path | Role |
|
||||
|-----------|------|------|
|
||||
| TaskChatPanel | `apps/web/components/task/task-chat-panel.tsx` | Chat UI, displays current model |
|
||||
| SessionsDropdown | `apps/web/components/task/sessions-dropdown.tsx` | Session switcher |
|
||||
| Orchestrator Service | `apps/backend/internal/orchestrator/service.go` | PromptTask, StartTask, ResumeTaskSession |
|
||||
| Executor | `apps/backend/internal/orchestrator/executor/executor.go` | Agent launch, prompt forwarding |
|
||||
| Lifecycle Manager | `apps/backend/internal/agent/lifecycle/manager.go` | Agent process management |
|
||||
| CommandBuilder | `apps/backend/internal/agent/lifecycle/command.go` | Builds CLI args including --model |
|
||||
| ProfileResolver | `apps/backend/internal/agent/lifecycle/profile_resolver.go` | Resolves profile → model |
|
||||
| SessionManager | `apps/backend/internal/agent/lifecycle/session.go` | ACP session init/prompt |
|
||||
| TaskSession Model | `apps/backend/internal/task/models/models.go` | Session persistence |
|
||||
| Message Service | `apps/backend/internal/task/service/service.go` | CreateMessage |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Breakdown
|
||||
|
||||
### UI Requirements
|
||||
|
||||
1. **Model Selector in Session View**: Replace existing agent selector with model dropdown
|
||||
2. **Deferred Application**: Selection does NOT take effect immediately
|
||||
3. **Apply on Prompt**: Selected model applied when user sends next prompt
|
||||
4. **Persistent State**: All subsequent prompts use new model until changed again
|
||||
|
||||
### Backend Requirements
|
||||
|
||||
1. **Model Change Detection**: Compare current model vs requested model on prompt
|
||||
2. **System Message Insertion**: "Model switching to [model name]" message
|
||||
3. **Agent Restart**: Stop current agent, restart with new `--model` flag
|
||||
4. **Session Resumption**: Attempt to resume ACP session after restart (if supported)
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Frontend Model Selector
|
||||
|
||||
#### 1.1 Store State for Pending Model
|
||||
|
||||
Add to the Zustand store a per-session "pending model" that tracks what model the user wants to switch to.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/lib/state/store.ts` - Add `pendingModelBySessionId: Record<string, string>`
|
||||
- `apps/web/lib/types/http.ts` - Add model-related types if needed
|
||||
|
||||
#### 1.2 Model Selector Component
|
||||
|
||||
Create a model dropdown in the chat panel that:
|
||||
- Shows available models for the current agent type
|
||||
- Shows current model (from session's agent profile)
|
||||
- Allows selection of a different model
|
||||
- Stores selection in `pendingModelBySessionId[sessionId]`
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector component
|
||||
- May create new component: `apps/web/components/task/model-selector.tsx`
|
||||
|
||||
**Data source for available models:**
|
||||
- From `AvailableAgent.model_config.available_models` in the agent discovery store
|
||||
- Need to resolve which agent the session is using via `session.agent_profile_id`
|
||||
|
||||
#### 1.3 Prompt Submission with Model Override
|
||||
|
||||
When user submits a prompt, check if `pendingModelBySessionId[sessionId]` differs from current model:
|
||||
- If different: include `model` field in WebSocket prompt payload
|
||||
- Clear pending model after submission (backend will handle the switch)
|
||||
|
||||
**WebSocket payload change:**
|
||||
|
||||
```typescript
|
||||
// Current
|
||||
{ action: "orchestrator.prompt", payload: { taskId, sessionId, content } }
|
||||
|
||||
// Proposed
|
||||
{ action: "orchestrator.prompt", payload: { taskId, sessionId, content, model?: string } }
|
||||
```
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/lib/ws/actions.ts` - Add `model` to prompt action payload
|
||||
- `apps/web/components/task/task-chat-panel.tsx` - Include model in prompt submission
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Backend Model Change Detection
|
||||
|
||||
#### 2.1 Extend Prompt Handling
|
||||
|
||||
Modify the orchestrator's `PromptTask` to accept an optional model parameter.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/orchestrator/service.go` - `PromptTask` method
|
||||
- `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model from WS payload
|
||||
|
||||
**Logic flow:**
|
||||
```
|
||||
PromptTask(taskId, sessionId, content, model)
|
||||
→ If model != currentExecution.Model:
|
||||
→ Insert system message "Switching to {model}..."
|
||||
→ Stop current agent (graceful shutdown)
|
||||
→ Update session's effective model
|
||||
→ Restart agent with new --model flag
|
||||
→ Resume or reinitialize ACP session
|
||||
→ Forward prompt to agent
|
||||
```
|
||||
|
||||
#### 2.2 Executor Model Switch
|
||||
|
||||
The executor needs a new method to handle model switching:
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/orchestrator/executor/executor.go`
|
||||
- Add `SwitchModel(sessionId, newModel)` method
|
||||
- Coordinates stop → rebuild → restart → resume
|
||||
|
||||
**Implementation sketch:**
|
||||
```go
|
||||
func (e *Executor) SwitchModel(ctx context.Context, sessionId, newModel string) error {
|
||||
exec, ok := e.running[sessionId]
|
||||
if !ok {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
// 1. Stop current agent
|
||||
e.lifecycleManager.StopAgent(ctx, exec.AgentID)
|
||||
|
||||
// 2. Update profile with new model
|
||||
newProfileInfo := exec.ProfileInfo
|
||||
newProfileInfo.Model = newModel
|
||||
|
||||
// 3. Restart agent with new command
|
||||
newAgentID, err := e.lifecycleManager.Launch(ctx, LaunchOpts{
|
||||
Model: newModel,
|
||||
// ... other opts from exec
|
||||
})
|
||||
|
||||
// 4. Attempt session resume
|
||||
e.lifecycleManager.InitializeSession(ctx, newAgentID, InitOpts{
|
||||
IsResume: true,
|
||||
ResumeToken: exec.ResumeToken,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 System Message for Model Switch
|
||||
|
||||
Insert a visible message in the chat indicating the model change.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/task/service/service.go` - Use `CreateMessage` with type `status`
|
||||
|
||||
**Message format:**
|
||||
```json
|
||||
{
|
||||
"type": "status",
|
||||
"content": "Switching model to Claude Opus 4.5...",
|
||||
"metadata": {
|
||||
"status_type": "model_switch",
|
||||
"from_model": "sonnet4.5",
|
||||
"to_model": "opus4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Session Resumption After Restart
|
||||
|
||||
#### 3.1 Agent-Specific Resumption
|
||||
|
||||
Different agents handle resumption differently (from `agents.json`):
|
||||
|
||||
| Agent | `resume_via_acp` | `resume_flag` | `can_recover` |
|
||||
|-------|------------------|---------------|---------------|
|
||||
| auggie-agent | false | `--resume` | false |
|
||||
| codex-agent | true | (none) | true |
|
||||
|
||||
**For Codex-style agents (resume_via_acp: true):**
|
||||
- Use ACP `session/load` with the stored `ResumeToken`
|
||||
- Session history is preserved by the agent
|
||||
|
||||
**For Auggie-style agents (resume_via_acp: false, can_recover: false):**
|
||||
- Cannot truly resume; must reinitialize with fresh ACP session
|
||||
- **Decision**: Accept context loss, display system message: "Model switched. A new session was started."
|
||||
|
||||
#### 3.2 Handling Resume Failures
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/agent/lifecycle/session.go` - `InitializeSession` error handling
|
||||
|
||||
**Fallback strategy:**
|
||||
1. Attempt resume with stored token
|
||||
2. If resume fails → start new session
|
||||
3. Emit warning message to user: "Session could not be fully resumed after model switch"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### WebSocket Integration
|
||||
|
||||
| Layer | Change Required |
|
||||
|-------|-----------------|
|
||||
| Client (`apps/web/lib/ws/client.ts`) | No changes |
|
||||
| Actions (`apps/web/lib/ws/actions.ts`) | Add `model` field to prompt action |
|
||||
| Handler (`apps/backend/internal/ws/handlers/orchestrator.go`) | Parse `model` from payload |
|
||||
|
||||
**Wire format example:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "request",
|
||||
"action": "orchestrator.prompt",
|
||||
"payload": {
|
||||
"task_id": "task-uuid",
|
||||
"session_id": "session-uuid",
|
||||
"content": "User message",
|
||||
"model": "opus4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Store Integration
|
||||
|
||||
**New store slice additions:**
|
||||
|
||||
```typescript
|
||||
// In store.ts
|
||||
pendingModel: {
|
||||
bySessionId: Record<string, string>, // sessionId → pending model id
|
||||
setPendingModel: (sessionId: string, modelId: string) => void,
|
||||
clearPendingModel: (sessionId: string) => void,
|
||||
getPendingModel: (sessionId: string) => string | null,
|
||||
}
|
||||
```
|
||||
|
||||
**Store → Component data flow:**
|
||||
```
|
||||
ModelSelector reads: settingsAgents (for available models), session.agent_profile_id
|
||||
ModelSelector writes: pendingModel.bySessionId[sessionId]
|
||||
TaskChatPanel reads: pendingModel.bySessionId[sessionId] on prompt submit
|
||||
```
|
||||
|
||||
### Database Integration
|
||||
|
||||
**Decision: No schema change (runtime-only)**
|
||||
- Model switch is runtime-only; session's `agent_profile_id` remains unchanged
|
||||
- On backend restart, session resumes with original profile model
|
||||
- No migration required
|
||||
|
||||
---
|
||||
|
||||
## Challenges and Edge Cases
|
||||
|
||||
### 1. Agent Busy During Model Switch Request
|
||||
|
||||
**Scenario**: User selects new model while agent is processing a previous prompt.
|
||||
|
||||
**Solution options:**
|
||||
- **Queue the switch**: Store pending model, apply after current response completes
|
||||
- **Block selection**: Disable model selector while agent is busy
|
||||
- **Immediate abort**: Cancel current response, switch model (destructive)
|
||||
|
||||
**Recommendation**: Queue the switch; apply on next user-initiated prompt.
|
||||
|
||||
### 2. Rapid Model Changes
|
||||
|
||||
**Scenario**: User changes model multiple times before sending a prompt.
|
||||
|
||||
**Solution**: Only the final selection matters; each change overwrites `pendingModelBySessionId[sessionId]`.
|
||||
|
||||
### 3. Resume Failure After Switch
|
||||
|
||||
**Scenario**: Agent restarts with new model but ACP session/load fails.
|
||||
|
||||
**Handling:**
|
||||
1. Log error for debugging
|
||||
2. Fall back to new session initialization
|
||||
3. Show user message: "Model switched successfully but session context could not be fully restored"
|
||||
|
||||
### 4. Different Agent Types
|
||||
|
||||
**Scenario**: User tries to switch to a model not supported by current agent type.
|
||||
|
||||
**Prevention**: Model selector only shows models from `agent.model_config.available_models` for the session's agent type.
|
||||
|
||||
### 5. Session State Persistence
|
||||
|
||||
**Scenario**: Backend restarts mid-session; what model should resume use?
|
||||
|
||||
**Behavior**: Uses `agent_profile_snapshot.model` from session creation (original profile model). Runtime model switches are not persisted.
|
||||
|
||||
### 6. Model Availability Changes
|
||||
|
||||
**Scenario**: A model is removed from agent configuration while sessions are active.
|
||||
|
||||
**Handling**: Validate model against current `available_models` before sending to backend. If invalid, fall back to session's original model.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
### Alternative A: Runtime Model Switching via ACP
|
||||
|
||||
**Concept**: Send a hypothetical `session/switch_model` ACP call without restarting agent.
|
||||
|
||||
**Pros:**
|
||||
- No process restart; faster switch
|
||||
- Seamless context preservation
|
||||
|
||||
**Cons:**
|
||||
- Not supported by current agent implementations
|
||||
- Would require agent CLI changes upstream
|
||||
- Different agents would need different implementations
|
||||
|
||||
**Verdict**: Not viable without significant upstream work.
|
||||
|
||||
### Alternative B: Model as Separate Session Concept
|
||||
|
||||
**Concept**: Treat each model switch as a "sub-session" within the same UI session.
|
||||
|
||||
**Pros:**
|
||||
- Clean separation of concerns
|
||||
- Easy to track model history
|
||||
|
||||
**Cons:**
|
||||
- Complex UI implications
|
||||
- Breaks current session model significantly
|
||||
- Overhead for simple model changes
|
||||
|
||||
**Verdict**: Over-engineering for the requirement.
|
||||
|
||||
### Alternative C: Pre-select Model Before Session Start Only
|
||||
|
||||
**Concept**: Allow model selection only at session creation, not mid-session.
|
||||
|
||||
**Pros:**
|
||||
- Simplest implementation
|
||||
- No restart complexity
|
||||
|
||||
**Cons:**
|
||||
- Doesn't meet the stated requirement
|
||||
- Poor UX for iterative work
|
||||
|
||||
**Verdict**: Does not satisfy user requirements.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Frontend Model Selector (Est. 2-3 days)
|
||||
|
||||
**Goal**: Model dropdown appears in UI; selection stored locally.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/web/lib/state/store.ts` - Add pendingModel slice
|
||||
2. `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector
|
||||
3. Create `apps/web/components/task/model-selector.tsx` - New component
|
||||
|
||||
**Deliverable**: User can select a model, but it has no effect yet.
|
||||
|
||||
### Phase 2: Backend Prompt Extension (Est. 2-3 days)
|
||||
|
||||
**Goal**: Prompts can include model; backend detects change.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/web/lib/ws/actions.ts` - Add model to prompt payload
|
||||
2. `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model
|
||||
3. `apps/backend/internal/orchestrator/service.go` - PromptTask accepts model
|
||||
|
||||
**Deliverable**: Model parameter flows from UI to orchestrator.
|
||||
|
||||
### Phase 3: Agent Restart Logic (Est. 3-4 days)
|
||||
|
||||
**Goal**: Backend restarts agent with new model when detected.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/backend/internal/orchestrator/executor/executor.go` - SwitchModel method
|
||||
2. `apps/backend/internal/agent/lifecycle/manager.go` - Ensure clean restart
|
||||
3. `apps/backend/internal/task/service/service.go` - Insert status message
|
||||
|
||||
**Deliverable**: Model switch triggers agent restart; status message appears.
|
||||
|
||||
### Phase 4: Session Resumption (Est. 2-3 days)
|
||||
|
||||
**Goal**: Resume ACP session after agent restart (where supported).
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/backend/internal/agent/lifecycle/session.go` - Resume logic
|
||||
2. `apps/backend/internal/orchestrator/executor/executor.go` - Handle resume failures
|
||||
|
||||
**Deliverable**: Codex-style agents resume context; Auggie-style gracefully degrade.
|
||||
|
||||
### Phase 5: Testing & Edge Cases (Est. 2-3 days)
|
||||
|
||||
**Goal**: Verify all scenarios work correctly.
|
||||
|
||||
**Test scenarios:**
|
||||
- Model switch while agent idle
|
||||
- Model switch while agent busy (queued)
|
||||
- Rapid model changes
|
||||
- Resume failure fallback
|
||||
- Invalid model selection
|
||||
- Backend restart mid-session
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `apps/web/lib/state/store.ts` | Add pendingModel slice |
|
||||
| `apps/web/components/task/task-chat-panel.tsx` | Integrate ModelSelector, pass model on prompt |
|
||||
| `apps/web/components/task/model-selector.tsx` | New component |
|
||||
| `apps/web/lib/ws/actions.ts` | Add model field to prompt action |
|
||||
| `apps/backend/internal/ws/handlers/orchestrator.go` | Parse model from WS payload |
|
||||
| `apps/backend/internal/orchestrator/service.go` | Extend PromptTask signature |
|
||||
| `apps/backend/internal/orchestrator/executor/executor.go` | Add SwitchModel method |
|
||||
| `apps/backend/internal/agent/lifecycle/manager.go` | Ensure clean restart support |
|
||||
| `apps/backend/internal/agent/lifecycle/session.go` | Handle resume after restart |
|
||||
| `apps/backend/internal/task/service/service.go` | Create model switch status message |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Total Effort
|
||||
|
||||
| Phase | Estimate |
|
||||
|-------|----------|
|
||||
| Phase 1: Frontend Model Selector | 2-3 days |
|
||||
| Phase 2: Backend Prompt Extension | 2-3 days |
|
||||
| Phase 3: Agent Restart Logic | 3-4 days |
|
||||
| Phase 4: Session Resumption | 2-3 days |
|
||||
| Phase 5: Testing & Edge Cases | 2-3 days |
|
||||
| **Total** | **11-16 days** |
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
| Decision | Resolution |
|
||||
|----------|------------|
|
||||
| Database schema | Runtime-only, no schema changes |
|
||||
| Non-recoverable agents (Auggie) | Accept context loss, show "new session started" message |
|
||||
| Agent busy during switch | Not an issue - model applied on next user prompt |
|
||||
| UI placement | Replace existing agent selector with model selector |
|
||||
| Confirmation dialog | None required - seamless switch |
|
||||
|
||||
Reference in New Issue
Block a user