feat: add plan_model config for smarter LLM during interview/review phases

Allow users to specify a separate model for plan phases (interviewing/review)
via plan_model and plan_model_fallbacks in defaults or per-agent config.
During execution phases, the normal model is used as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 05:52:36 +09:00
parent 2be7c34f71
commit 2759bee2e3
7 changed files with 435 additions and 3 deletions

View file

@ -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,

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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])
}
}

View file

@ -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"`

View file

@ -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)
}
}