From e0ece0652c8b76854c2b0030027aa6e82158886b Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 6 Mar 2026 22:46:12 +0800 Subject: [PATCH] feat(agent): add session-level thinking controls - Session: add ThinkingLevel to Session, GetThinkingLevel, SetThinkingLevel, persist - Config: add agents.defaults.thinking - Agent: session-resolved thinking (resolveThinkingLevel), reasoning_effort and thinking_level in LLM opts, thinking downgrade on provider error - Commands: /think, /thinking, /t and /show thinking with sessionKey; require session for session-scoped commands - Remove agent-level ThinkingLevel from instance; use session/model/defaults only Made-with: Cursor --- pkg/agent/instance.go | 8 -- pkg/agent/loop.go | 110 +++++++++++++++++++++-- pkg/agent/thinking.go | 176 ++++++++++++++++++++++++++++++------- pkg/agent/thinking_test.go | 139 +++++++++++++++++++++++------ pkg/config/config.go | 1 + pkg/session/manager.go | 49 +++++++++-- 6 files changed, 403 insertions(+), 80 deletions(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 97cf0fa05..e04c0652d 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -26,7 +26,6 @@ type AgentInstance struct { MaxIterations int MaxTokens int Temperature float64 - ThinkingLevel ThinkingLevel ContextWindow int SummarizeMessageThreshold int SummarizeTokenPercent int @@ -125,12 +124,6 @@ func NewAgentInstance( temperature = *defaults.Temperature } - var thinkingLevelStr string - if mc, err := cfg.GetModelConfig(model); err == nil { - thinkingLevelStr = mc.ThinkingLevel - } - thinkingLevel := parseThinkingLevel(thinkingLevelStr) - summarizeMessageThreshold := defaults.SummarizeMessageThreshold if summarizeMessageThreshold == 0 { summarizeMessageThreshold = 20 @@ -216,7 +209,6 @@ func NewAgentInstance( MaxIterations: maxIter, MaxTokens: maxTokens, Temperature: temperature, - ThinkingLevel: thinkingLevel, ContextWindow: maxTokens, SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeTokenPercent: summarizeTokenPercent, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 19d13b2bb..e24fa7917 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -586,7 +586,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) // Global commands (/help, /show, /switch) work even when routing fails; // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent); handled { + if response, handled := al.handleCommand(ctx, msg, agent, ""); handled { return response, nil } @@ -605,6 +605,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) scopeKey := resolveScopeKey(route, msg.SessionKey) sessionKey := scopeKey + if response, handled := al.handleCommand(ctx, msg, agent, sessionKey); handled { + return response, nil + } + logger.InfoCF("agent", "Routed message", map[string]any{ "agent_id": agent.ID, @@ -875,6 +879,8 @@ func (al *AgentLoop) runLLMIteration( iteration := 0 var finalContent string + thinkingLevel := resolveThinkingLevel(al.cfg, agent, opts.SessionKey) + // Determine effective model tier for this conversation turn. // selectCandidates evaluates routing once and the decision is sticky for // all tool-follow-up iterations within the same turn so that a multi-step @@ -904,6 +910,7 @@ func (al *AgentLoop) runLLMIteration( "tools_count": len(providerToolDefs), "max_tokens": agent.MaxTokens, "temperature": agent.Temperature, + "thinking_level": thinkingLevel, "system_prompt_len": len(messages[0].Content), }) @@ -924,14 +931,15 @@ func (al *AgentLoop) runLLMIteration( "temperature": agent.Temperature, "prompt_cache_key": agent.ID, } - // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, - // so checking != ThinkingOff is sufficient. - if agent.ThinkingLevel != ThinkingOff { + if effort := reasoningEffortForLevel(thinkingLevel); effort != "" { + llmOpts["reasoning_effort"] = effort + } + if thinkingLevel != "" && thinkingLevel != thinkingOff { if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - llmOpts["thinking_level"] = string(agent.ThinkingLevel) + llmOpts["thinking_level"] = thinkingLevel } else { logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)}) + map[string]any{"agent_id": agent.ID, "thinking_level": thinkingLevel}) } } @@ -960,8 +968,10 @@ func (al *AgentLoop) runLLMIteration( return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) } - // Retry loop for context/token errors + // Retry loop for context/token errors and thinking downgrade maxRetries := 2 + maxThinkingDowngrades := len(thinkingDowngradeOrder) + thinkingDowngrades := 0 for retry := 0; retry <= maxRetries; retry++ { response, err = callLLM() if err == nil { @@ -970,6 +980,41 @@ func (al *AgentLoop) runLLMIteration( errMsg := strings.ToLower(err.Error()) + // Thinking downgrade on provider error + if isThinkingUnsupportedError(errMsg) && thinkingLevel != "" && thinkingDowngrades < maxThinkingDowngrades { + supported := parseSupportedThinkingLevels(errMsg) + if nextLevel, ok := nextDowngradedThinkingLevel(thinkingLevel, supported); ok && nextLevel != thinkingLevel { + prevLevel := thinkingLevel + thinkingLevel = nextLevel + if effort := reasoningEffortForLevel(thinkingLevel); effort != "" { + llmOpts["reasoning_effort"] = effort + } else { + delete(llmOpts, "reasoning_effort") + } + if thinkingLevel != "" && thinkingLevel != thinkingOff { + llmOpts["thinking_level"] = thinkingLevel + } else { + delete(llmOpts, "thinking_level") + } + thinkingDowngrades++ + logger.WarnCF("agent", "Thinking level downgraded after provider error", map[string]any{ + "agent_id": agent.ID, "session_key": opts.SessionKey, + "from": prevLevel, "to": thinkingLevel, "provider_error": err.Error(), + }) + continue + } + if strings.Contains(errMsg, "not supported") && thinkingLevel != thinkingOff { + thinkingLevel = thinkingOff + delete(llmOpts, "reasoning_effort") + delete(llmOpts, "thinking_level") + thinkingDowngrades++ + logger.WarnCF("agent", "Thinking level forced to off after provider rejection", map[string]any{ + "agent_id": agent.ID, "session_key": opts.SessionKey, "provider_error": err.Error(), + }) + continue + } + } + // Check if this is a network/HTTP timeout — not a context window error. isTimeoutError := errors.Is(err, context.DeadlineExceeded) || strings.Contains(errMsg, "deadline exceeded") || @@ -1559,6 +1604,7 @@ func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, agent *AgentInstance, + sessionKey string, ) (string, bool) { if !commands.HasCommandPrefix(msg.Content) { return "", false @@ -1568,6 +1614,56 @@ func (al *AgentLoop) handleCommand( return "", false } + // Session-scoped /think and /show thinking (require sessionKey) + content := strings.TrimSpace(msg.Content) + parts := strings.Fields(content) + cmd := "" + if len(parts) > 0 { + cmd = parts[0] + } + args := parts[1:] + if cmd == "/think" || cmd == "/thinking" || cmd == "/t" { + if sessionKey == "" { + return "", false + } + if agent == nil { + return "No agent available for thinking control", true + } + if len(args) == 0 { + level := resolveThinkingLevel(al.cfg, agent, sessionKey) + if level == "" { + level = thinkingAdaptive + } + return fmt.Sprintf( + "Current thinking level: %s\nAvailable levels: off, minimal, low, medium, high, xhigh, adaptive", + level, + ), true + } + level, ok := normalizeThinkingLevel(args[0]) + if !ok { + return "Invalid thinking level. Use one of: off, minimal, low, medium, high, xhigh, adaptive", true + } + if level == thinkingXHigh && !supportsXHigh(agent.Model, al.cfg) { + return "xhigh is only supported by GPT-5.2 and Codex model series", true + } + agent.Sessions.SetThinkingLevel(sessionKey, level) + _ = agent.Sessions.Save(sessionKey) + return fmt.Sprintf("Thinking level for this session is now set to %s", level), true + } + if cmd == "/show" && len(args) >= 1 && args[0] == "thinking" { + if sessionKey == "" { + return "", false + } + if agent == nil { + return "No default agent configured", true + } + level := resolveThinkingLevel(al.cfg, agent, sessionKey) + if level == "" { + level = thinkingAdaptive + } + return fmt.Sprintf("Current thinking level: %s", level), true + } + rt := al.buildCommandsRuntime(agent) executor := commands.NewExecutor(al.cmdRegistry, rt) diff --git a/pkg/agent/thinking.go b/pkg/agent/thinking.go index 015b69282..41760d40f 100644 --- a/pkg/agent/thinking.go +++ b/pkg/agent/thinking.go @@ -1,39 +1,153 @@ package agent -import "strings" +import ( + "regexp" + "slices" + "strings" -// ThinkingLevel controls how the provider sends thinking parameters. -// -// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+) -// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models) -// - "off": disables thinking -type ThinkingLevel string - -const ( - ThinkingOff ThinkingLevel = "off" - ThinkingLow ThinkingLevel = "low" - ThinkingMedium ThinkingLevel = "medium" - ThinkingHigh ThinkingLevel = "high" - ThinkingXHigh ThinkingLevel = "xhigh" - ThinkingAdaptive ThinkingLevel = "adaptive" + "github.com/sipeed/picoclaw/pkg/config" ) -// parseThinkingLevel normalizes a config string to a ThinkingLevel. -// Case-insensitive and whitespace-tolerant for user-facing config values. -// Returns ThinkingOff for unknown or empty values. -func parseThinkingLevel(level string) ThinkingLevel { - switch strings.ToLower(strings.TrimSpace(level)) { - case "adaptive": - return ThinkingAdaptive - case "low": - return ThinkingLow - case "medium": - return ThinkingMedium - case "high": - return ThinkingHigh - case "xhigh": - return ThinkingXHigh +const ( + thinkingOff = "off" + thinkingMinimal = "minimal" + thinkingLow = "low" + thinkingMedium = "medium" + thinkingHigh = "high" + thinkingXHigh = "xhigh" + thinkingAdaptive = "adaptive" +) + +var ( + supportedThinkingLevels = []string{ + thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXHigh, thinkingAdaptive, + } + thinkingDowngradeOrder = []string{ + thinkingXHigh, thinkingHigh, thinkingMedium, thinkingLow, thinkingMinimal, thinkingOff, + } + supportedValuesRe = regexp.MustCompile(`(?i)supported values?\s*[:=]\s*([^\n]+)`) +) + +func normalizeThinkingLevel(raw string) (string, bool) { + level := strings.ToLower(strings.TrimSpace(raw)) + switch level { + case "none": + return thinkingOff, true + case "on", "enable", "enabled": + return thinkingLow, true + case "off", "disable", "disabled": + return thinkingOff, true + case thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXHigh, thinkingAdaptive: + return level, true default: - return ThinkingOff + return "", false } } + +func resolveThinkingLevel(cfg *config.Config, agent *AgentInstance, sessionKey string) string { + if sessionLevel, ok := normalizeThinkingLevel(agent.Sessions.GetThinkingLevel(sessionKey)); ok { + return sessionLevel + } + if modelLevel := modelThinkingLevel(cfg, agent.Model); modelLevel != "" { + return modelLevel + } + if cfg != nil { + if level, ok := normalizeThinkingLevel(cfg.Agents.Defaults.Thinking); ok { + return level + } + } + return "" +} + +func modelThinkingLevel(cfg *config.Config, modelAlias string) string { + if cfg == nil { + return "" + } + for i := range cfg.ModelList { + mc := cfg.ModelList[i] + if mc.ModelName != modelAlias { + continue + } + if level, ok := normalizeThinkingLevel(mc.ThinkingLevel); ok { + return level + } + } + return "" +} + +// reasoningEffortForLevel maps an internal thinking level to the +// reasoning_effort value sent to providers. "off" and "adaptive" return "" +// so that the field is omitted entirely — this avoids errors on providers +// that do not support the reasoning_effort parameter. +func reasoningEffortForLevel(level string) string { + switch level { + case thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXHigh: + return level + default: + return "" + } +} + +func supportsXHigh(modelAlias string, cfg *config.Config) bool { + if cfg == nil { + return false + } + for i := range cfg.ModelList { + mc := cfg.ModelList[i] + if mc.ModelName != modelAlias { + continue + } + model := strings.ToLower(strings.TrimSpace(mc.Model)) + if strings.Contains(model, "gpt-5.2") || strings.Contains(model, "codex") { + return true + } + } + return false +} + +func parseSupportedThinkingLevels(errMsg string) []string { + errMsg = strings.ToLower(errMsg) + match := supportedValuesRe.FindStringSubmatch(errMsg) + if len(match) == 0 { + return nil + } + segment := match[1] + candidates := []string{ + "none", "minimal", "low", "medium", "high", "xhigh", "adaptive", "off", + } + levels := make([]string, 0, len(candidates)) + for _, c := range candidates { + if !strings.Contains(segment, c) { + continue + } + if normalized, ok := normalizeThinkingLevel(c); ok && !slices.Contains(levels, normalized) { + levels = append(levels, normalized) + } + } + return levels +} + +func isThinkingUnsupportedError(errMsg string) bool { + msg := strings.ToLower(errMsg) + if strings.Contains(msg, "reasoning_effort") || strings.Contains(msg, "reasoning.effort") { + return true + } + return strings.Contains(msg, "thinking") && strings.Contains(msg, "not supported") +} + +func nextDowngradedThinkingLevel(current string, supported []string) (string, bool) { + if current == "" || current == thinkingAdaptive { + return thinkingOff, true + } + idx := slices.Index(thinkingDowngradeOrder, current) + if idx < 0 { + return thinkingOff, true + } + for i := idx + 1; i < len(thinkingDowngradeOrder); i++ { + candidate := thinkingDowngradeOrder[i] + if len(supported) == 0 || slices.Contains(supported, candidate) { + return candidate, true + } + } + return "", false +} diff --git a/pkg/agent/thinking_test.go b/pkg/agent/thinking_test.go index be3a68c33..ec75b9c50 100644 --- a/pkg/agent/thinking_test.go +++ b/pkg/agent/thinking_test.go @@ -1,35 +1,124 @@ package agent -import "testing" +import ( + "context" + "strings" + "testing" -func TestParseThinkingLevel(t *testing.T) { + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +func TestNormalizeThinkingLevel(t *testing.T) { tests := []struct { - name string input string - want ThinkingLevel + want string + ok bool }{ - {"off", "off", ThinkingOff}, - {"empty", "", ThinkingOff}, - {"low", "low", ThinkingLow}, - {"medium", "medium", ThinkingMedium}, - {"high", "high", ThinkingHigh}, - {"xhigh", "xhigh", ThinkingXHigh}, - {"adaptive", "adaptive", ThinkingAdaptive}, - {"unknown", "unknown", ThinkingOff}, - // Case-insensitive and whitespace-tolerant - {"upper_Medium", "Medium", ThinkingMedium}, - {"upper_HIGH", "HIGH", ThinkingHigh}, - {"mixed_Adaptive", "Adaptive", ThinkingAdaptive}, - {"leading_space", " high", ThinkingHigh}, - {"trailing_space", "low ", ThinkingLow}, - {"both_spaces", " medium ", ThinkingMedium}, + {input: "off", want: "off", ok: true}, + {input: "none", want: "off", ok: true}, + {input: "enable", want: "low", ok: true}, + {input: "adaptive", want: "adaptive", ok: true}, + {input: "invalid", ok: false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := parseThinkingLevel(tt.input); got != tt.want { - t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want) - } - }) + got, ok := normalizeThinkingLevel(tt.input) + if ok != tt.ok || got != tt.want { + t.Fatalf("normalizeThinkingLevel(%q) = (%q,%v), want (%q,%v)", tt.input, got, ok, tt.want, tt.ok) + } } } + +func TestParseSupportedThinkingLevels(t *testing.T) { + msg := `bad request: supported values: ["none","low","medium","high"]` + got := parseSupportedThinkingLevels(msg) + want := []string{"off", "low", "medium", "high"} + if len(got) != len(want) { + t.Fatalf("parseSupportedThinkingLevels len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parseSupportedThinkingLevels[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestProcessMessage_ThinkCommandSetsSessionLevel(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "u1", + ChatID: "c1", + Content: "/think high", + } + resp, err := al.processMessage(context.Background(), msg) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(resp, "high") { + t.Fatalf("response = %q, want contains high", resp) + } + + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: msg.Channel, + }) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("default agent is nil") + } + if got := agent.Sessions.GetThinkingLevel(route.SessionKey); got != "high" { + t.Fatalf("session thinking = %q, want %q", got, "high") + } +} + +func TestProcessMessage_ThinkXHighRejectedForNonWhitelistModel(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "u1", + ChatID: "c1", + Content: "/think xhigh", + } + resp, err := al.processMessage(context.Background(), msg) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if !strings.Contains(strings.ToLower(resp), "xhigh") { + t.Fatalf("response = %q, want xhigh hint", resp) + } +} + +func TestResolveThinkingLevelFromConfig(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Thinking: "medium", + }, + }, + ModelList: []config.ModelConfig{ + {ModelName: "test-model", ThinkingLevel: "high"}, + }, + } + agent := &AgentInstance{ + Model: "test-model", + Sessions: sessionManagerWithLevel(t, "", ""), + } + if got := resolveThinkingLevel(cfg, agent, "s1"); got != "high" { + t.Fatalf("resolveThinkingLevel() = %q, want %q", got, "high") + } +} + +func sessionManagerWithLevel(t *testing.T, key, level string) *session.SessionManager { + t.Helper() + sm := session.NewSessionManager(t.TempDir()) + if key != "" { + sm.SetThinkingLevel(key, level) + } + return sm +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 72af3e2fb..6f171ed04 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -187,6 +187,7 @@ type AgentDefaults struct { ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` + Thinking string `json:"thinking,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_THINKING"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 08f0b0ad2..b9ef7faa8 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -12,11 +12,12 @@ import ( ) type Session struct { - Key string `json:"key"` - Messages []providers.Message `json:"messages"` - Summary string `json:"summary,omitempty"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Key string `json:"key"` + Messages []providers.Message `json:"messages"` + Summary string `json:"summary,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } type SessionManager struct { @@ -122,6 +123,35 @@ func (sm *SessionManager) SetSummary(key string, summary string) { } } +func (sm *SessionManager) GetThinkingLevel(key string) string { + sm.mu.RLock() + defer sm.mu.RUnlock() + + session, ok := sm.sessions[key] + if !ok { + return "" + } + return session.ThinkingLevel +} + +func (sm *SessionManager) SetThinkingLevel(key, level string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, ok := sm.sessions[key] + if !ok { + session = &Session{ + Key: key, + Messages: []providers.Message{}, + Created: time.Now(), + } + sm.sessions[key] = session + } + + session.ThinkingLevel = level + session.Updated = time.Now() +} + func (sm *SessionManager) TruncateHistory(key string, keepLast int) { sm.mu.Lock() defer sm.mu.Unlock() @@ -178,10 +208,11 @@ func (sm *SessionManager) Save(key string) error { } snapshot := Session{ - Key: stored.Key, - Summary: stored.Summary, - Created: stored.Created, - Updated: stored.Updated, + Key: stored.Key, + Summary: stored.Summary, + ThinkingLevel: stored.ThinkingLevel, + Created: stored.Created, + Updated: stored.Updated, } if len(stored.Messages) > 0 { snapshot.Messages = make([]providers.Message, len(stored.Messages))