diff --git a/config/config.example.json b/config/config.example.json index ba49f4ad7..6a713cd78 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -6,6 +6,8 @@ "provider": "zhipu", "model": "glm-4.7", "model_fallbacks": ["openai/gpt-4o"], + "plan_model": "", + "plan_model_fallbacks": [], "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20, diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index b1c80dded..66ad51e5c 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -32,6 +32,9 @@ type AgentInstance struct { Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate + PlanModel string + PlanFallbacks []string + PlanCandidates []providers.FallbackCandidate // Interview staleness tracking: consecutive turns where MEMORY.md was not updated. interviewStaleCount int @@ -108,6 +111,18 @@ func NewAgentInstance( } candidates := providers.ResolveCandidates(modelCfg, defaults.Provider) + // Resolve plan model (for interviewing/review phases) + planModel := resolvePlanModel(agentCfg, defaults) + planFallbacks := resolvePlanFallbacks(agentCfg, defaults) + var planCandidates []providers.FallbackCandidate + if planModel != "" { + planModelCfg := providers.ModelConfig{ + Primary: planModel, + Fallbacks: planFallbacks, + } + planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider) + } + return &AgentInstance{ ID: agentID, Name: agentName, @@ -126,6 +141,9 @@ func NewAgentInstance( Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, + PlanModel: planModel, + PlanFallbacks: planFallbacks, + PlanCandidates: planCandidates, } } @@ -158,6 +176,22 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD return defaults.ModelFallbacks } +// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases). +func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { + if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" { + return strings.TrimSpace(agentCfg.PlanModel.Primary) + } + return defaults.PlanModel +} + +// resolvePlanFallbacks resolves the plan model fallbacks for an agent. +func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { + if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil { + return agentCfg.PlanModel.Fallbacks + } + return defaults.PlanModelFallbacks +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index fcc8e9bea..a6db8dc97 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -93,3 +93,110 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7) } } + +func TestNewAgentInstance_PlanModel_SetFromDefaults(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "zhipu", + Model: "glm-4.7", + PlanModel: "anthropic/claude-sonnet-4-6", + PlanModelFallbacks: []string{"openai/gpt-4o"}, + MaxTokens: 8192, + MaxToolIterations: 20, + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if agent.PlanModel != "anthropic/claude-sonnet-4-6" { + t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", agent.PlanModel) + } + if len(agent.PlanFallbacks) != 1 || agent.PlanFallbacks[0] != "openai/gpt-4o" { + t.Errorf("PlanFallbacks = %v, want [openai/gpt-4o]", agent.PlanFallbacks) + } + if len(agent.PlanCandidates) == 0 { + t.Fatal("PlanCandidates should not be empty when plan_model is set") + } + if agent.PlanCandidates[0].Model != "claude-sonnet-4-6" { + t.Errorf("PlanCandidates[0].Model = %q, want 'claude-sonnet-4-6'", agent.PlanCandidates[0].Model) + } +} + +func TestNewAgentInstance_PlanModel_NilWhenUnset(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, + MaxToolIterations: 5, + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if agent.PlanModel != "" { + t.Errorf("PlanModel = %q, want empty", agent.PlanModel) + } + if agent.PlanCandidates != nil { + t.Errorf("PlanCandidates = %v, want nil", agent.PlanCandidates) + } +} + +func TestNewAgentInstance_PlanModel_AgentOverridesDefaults(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "zhipu", + Model: "glm-4.7", + PlanModel: "default-plan-model", + PlanModelFallbacks: []string{"default-fallback"}, + MaxTokens: 8192, + MaxToolIterations: 20, + }, + }, + } + + agentCfg := &config.AgentConfig{ + ID: "custom", + PlanModel: &config.AgentModelConfig{ + Primary: "agent-plan-model", + Fallbacks: []string{"agent-fallback"}, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(agentCfg, &cfg.Agents.Defaults, cfg, provider) + + if agent.PlanModel != "agent-plan-model" { + t.Errorf("PlanModel = %q, want 'agent-plan-model'", agent.PlanModel) + } + if len(agent.PlanFallbacks) != 1 || agent.PlanFallbacks[0] != "agent-fallback" { + t.Errorf("PlanFallbacks = %v, want [agent-fallback]", agent.PlanFallbacks) + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0ad48839e..10c44c4c8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1623,8 +1623,18 @@ func (al *AgentLoop) runLLMIteration( } callLLM := func() (*providers.LLMResponse, error) { - if len(agent.Candidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, + // Plan model switching: use plan model during interviewing/review phases + candidates := agent.Candidates + primaryModel := agent.Model + if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" { + candidates = agent.PlanCandidates + primaryModel = agent.PlanModel + logger.InfoCF("agent", "Using plan model", + map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel}) + } + + if len(candidates) > 1 && al.fallback != nil { + fbResult, fbErr := al.fallback.Execute(ctx, candidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { p := al.resolveProvider(provider, model, agent.Provider) return doCall(ctx, p, model) @@ -1640,7 +1650,7 @@ func (al *AgentLoop) runLLMIteration( } return fbResult.Response, nil } - return doCall(ctx, agent.Provider, agent.Model) + return doCall(ctx, agent.Provider, primaryModel) } // Retry loop for context/token errors diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 43b46e84d..407d89e67 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -2348,3 +2349,158 @@ func TestConsumeStream_OnChunkWithRepetitionDetection(t *testing.T) { } _ = ctx } + +// modelCapturingMockProvider records which model was passed to Chat. +type modelCapturingMockProvider struct { + mu sync.Mutex + models []string + response string +} + +func (m *modelCapturingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools_ []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.mu.Lock() + m.models = append(m.models, model) + m.mu.Unlock() + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *modelCapturingMockProvider) GetDefaultModel() string { + return "mock-capture-model" +} + +func TestAgentLoop_PlanModel_UsedDuringInterviewing(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "normal-model", + PlanModel: "plan-model", + MaxTokens: 4096, + MaxToolIterations: 2, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &modelCapturingMockProvider{response: "Plan interview response"} + al := NewAgentLoop(cfg, msgBus, provider) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + // Write MEMORY.md with interviewing status to activate plan model + memoryDir := filepath.Join(tmpDir, "memory") + os.MkdirAll(memoryDir, 0o755) + memoryPath := filepath.Join(memoryDir, "MEMORY.md") + memoryContent := "# Active Plan\n\n> Task: Test plan model\n> Status: interviewing\n> Phase: 1\n" + if err := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); err != nil { + t.Fatalf("Failed to write MEMORY.md: %v", err) + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "Hello, plan model test", + "test-plan-session", + "test", + "test-chat", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + provider.mu.Lock() + defer provider.mu.Unlock() + + if len(provider.models) == 0 { + t.Fatal("Expected at least one Chat call") + } + // The first call should use the plan model since we're in interviewing state + if provider.models[0] != "plan-model" { + t.Errorf("Expected plan model 'plan-model' during interviewing, got %q", provider.models[0]) + } +} + +func TestAgentLoop_PlanModel_NotUsedDuringExecuting(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-exec-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "normal-model", + PlanModel: "plan-model", + MaxTokens: 4096, + MaxToolIterations: 2, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &modelCapturingMockProvider{response: "Executing response"} + al := NewAgentLoop(cfg, msgBus, provider) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + // Write MEMORY.md with executing status - should use normal model + memoryDir := filepath.Join(tmpDir, "memory") + os.MkdirAll(memoryDir, 0o755) + memoryPath := filepath.Join(memoryDir, "MEMORY.md") + memoryContent := `# Active Plan + +> Task: Test plan model +> Status: executing +> Phase: 1 + +## Phase 1: Build +- [ ] Run build +` + if err := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); err != nil { + t.Fatalf("Failed to write MEMORY.md: %v", err) + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "Hello, executing test", + "test-exec-session", + "test", + "test-chat", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + provider.mu.Lock() + defer provider.mu.Unlock() + + if len(provider.models) == 0 { + t.Fatal("Expected at least one Chat call") + } + // During executing phase, should use normal model, not plan model + if provider.models[0] != "normal-model" { + t.Errorf("Expected normal model 'normal-model' during executing, got %q", provider.models[0]) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index e9d9b68dd..003b2438a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -135,6 +135,7 @@ type AgentConfig struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` + PlanModel *AgentModelConfig `json:"plan_model,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } @@ -175,6 +176,8 @@ type AgentDefaults struct { ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"` + PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0898217d6..8fb64ec1f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -392,3 +392,123 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { t.Fatal("OpenAI codex web search should be false when disabled in config file") } } + +func TestAgentDefaults_PlanModel_StringParse(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "plan_model": "anthropic/claude-sonnet-4-6", + "plan_model_fallbacks": ["openai/gpt-4o"], + "max_tokens": 8192, + "max_tool_iterations": 20 + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" { + t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel) + } + if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 || cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" { + t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks) + } +} + +func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "main", + "plan_model": "anthropic/claude-sonnet-4-6" + }, + { + "id": "advanced", + "plan_model": { + "primary": "anthropic/claude-opus-4", + "fallbacks": ["anthropic/claude-sonnet-4-6"] + } + } + ] + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(cfg.Agents.List) != 2 { + t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) + } + + // String form + main := cfg.Agents.List[0] + if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" { + t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel) + } + + // Object form with fallbacks + adv := cfg.Agents.List[1] + if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" { + t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel) + } + if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" { + t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks) + } +} + +func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "plan_model": "default-plan-model", + "plan_model_fallbacks": ["default-fallback"], + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "custom", + "plan_model": { + "primary": "custom-plan-model", + "fallbacks": ["custom-fallback"] + } + } + ] + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // Agent-level plan_model should override defaults + custom := cfg.Agents.List[0] + if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" { + t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel) + } + if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" { + t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks) + } + + // Defaults should still be intact + if cfg.Agents.Defaults.PlanModel != "default-plan-model" { + t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel) + } +}