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
This commit is contained in:
parent
1945436dd4
commit
e0ece0652c
6 changed files with 403 additions and 80 deletions
|
|
@ -26,7 +26,6 @@ type AgentInstance struct {
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
ThinkingLevel ThinkingLevel
|
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
SummarizeMessageThreshold int
|
SummarizeMessageThreshold int
|
||||||
SummarizeTokenPercent int
|
SummarizeTokenPercent int
|
||||||
|
|
@ -125,12 +124,6 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
var thinkingLevelStr string
|
|
||||||
if mc, err := cfg.GetModelConfig(model); err == nil {
|
|
||||||
thinkingLevelStr = mc.ThinkingLevel
|
|
||||||
}
|
|
||||||
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
|
||||||
|
|
||||||
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
if summarizeMessageThreshold == 0 {
|
if summarizeMessageThreshold == 0 {
|
||||||
summarizeMessageThreshold = 20
|
summarizeMessageThreshold = 20
|
||||||
|
|
@ -216,7 +209,6 @@ func NewAgentInstance(
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
ThinkingLevel: thinkingLevel,
|
|
||||||
ContextWindow: maxTokens,
|
ContextWindow: maxTokens,
|
||||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
SummarizeTokenPercent: summarizeTokenPercent,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
|
|
|
||||||
|
|
@ -586,7 +586,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
// Global commands (/help, /show, /switch) work even when routing fails;
|
// Global commands (/help, /show, /switch) work even when routing fails;
|
||||||
// context-dependent commands check their own Runtime fields and report
|
// context-dependent commands check their own Runtime fields and report
|
||||||
// "unavailable" when the required capability is nil.
|
// "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
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -605,6 +605,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
||||||
sessionKey := scopeKey
|
sessionKey := scopeKey
|
||||||
|
|
||||||
|
if response, handled := al.handleCommand(ctx, msg, agent, sessionKey); handled {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
|
|
@ -875,6 +879,8 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
iteration := 0
|
iteration := 0
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
|
thinkingLevel := resolveThinkingLevel(al.cfg, agent, opts.SessionKey)
|
||||||
|
|
||||||
// Determine effective model tier for this conversation turn.
|
// Determine effective model tier for this conversation turn.
|
||||||
// selectCandidates evaluates routing once and the decision is sticky for
|
// selectCandidates evaluates routing once and the decision is sticky for
|
||||||
// all tool-follow-up iterations within the same turn so that a multi-step
|
// 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),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": agent.MaxTokens,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
|
"thinking_level": thinkingLevel,
|
||||||
"system_prompt_len": len(messages[0].Content),
|
"system_prompt_len": len(messages[0].Content),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -924,14 +931,15 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
"prompt_cache_key": agent.ID,
|
"prompt_cache_key": agent.ID,
|
||||||
}
|
}
|
||||||
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
if effort := reasoningEffortForLevel(thinkingLevel); effort != "" {
|
||||||
// so checking != ThinkingOff is sufficient.
|
llmOpts["reasoning_effort"] = effort
|
||||||
if agent.ThinkingLevel != ThinkingOff {
|
}
|
||||||
|
if thinkingLevel != "" && thinkingLevel != thinkingOff {
|
||||||
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||||
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
llmOpts["thinking_level"] = thinkingLevel
|
||||||
} else {
|
} else {
|
||||||
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
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)
|
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
|
maxRetries := 2
|
||||||
|
maxThinkingDowngrades := len(thinkingDowngradeOrder)
|
||||||
|
thinkingDowngrades := 0
|
||||||
for retry := 0; retry <= maxRetries; retry++ {
|
for retry := 0; retry <= maxRetries; retry++ {
|
||||||
response, err = callLLM()
|
response, err = callLLM()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -970,6 +980,41 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
|
|
||||||
errMsg := strings.ToLower(err.Error())
|
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.
|
// Check if this is a network/HTTP timeout — not a context window error.
|
||||||
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
|
||||||
strings.Contains(errMsg, "deadline exceeded") ||
|
strings.Contains(errMsg, "deadline exceeded") ||
|
||||||
|
|
@ -1559,6 +1604,7 @@ func (al *AgentLoop) handleCommand(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
sessionKey string,
|
||||||
) (string, bool) {
|
) (string, bool) {
|
||||||
if !commands.HasCommandPrefix(msg.Content) {
|
if !commands.HasCommandPrefix(msg.Content) {
|
||||||
return "", false
|
return "", false
|
||||||
|
|
@ -1568,6 +1614,56 @@ func (al *AgentLoop) handleCommand(
|
||||||
return "", false
|
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)
|
rt := al.buildCommandsRuntime(agent)
|
||||||
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,153 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
// ThinkingLevel controls how the provider sends thinking parameters.
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
//
|
|
||||||
// - "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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
const (
|
||||||
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
thinkingOff = "off"
|
||||||
// Returns ThinkingOff for unknown or empty values.
|
thinkingMinimal = "minimal"
|
||||||
func parseThinkingLevel(level string) ThinkingLevel {
|
thinkingLow = "low"
|
||||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
thinkingMedium = "medium"
|
||||||
case "adaptive":
|
thinkingHigh = "high"
|
||||||
return ThinkingAdaptive
|
thinkingXHigh = "xhigh"
|
||||||
case "low":
|
thinkingAdaptive = "adaptive"
|
||||||
return ThinkingLow
|
)
|
||||||
case "medium":
|
|
||||||
return ThinkingMedium
|
var (
|
||||||
case "high":
|
supportedThinkingLevels = []string{
|
||||||
return ThinkingHigh
|
thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXHigh, thinkingAdaptive,
|
||||||
case "xhigh":
|
}
|
||||||
return ThinkingXHigh
|
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:
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,124 @@
|
||||||
package agent
|
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 {
|
tests := []struct {
|
||||||
name string
|
|
||||||
input string
|
input string
|
||||||
want ThinkingLevel
|
want string
|
||||||
|
ok bool
|
||||||
}{
|
}{
|
||||||
{"off", "off", ThinkingOff},
|
{input: "off", want: "off", ok: true},
|
||||||
{"empty", "", ThinkingOff},
|
{input: "none", want: "off", ok: true},
|
||||||
{"low", "low", ThinkingLow},
|
{input: "enable", want: "low", ok: true},
|
||||||
{"medium", "medium", ThinkingMedium},
|
{input: "adaptive", want: "adaptive", ok: true},
|
||||||
{"high", "high", ThinkingHigh},
|
{input: "invalid", ok: false},
|
||||||
{"xhigh", "xhigh", ThinkingXHigh},
|
}
|
||||||
{"adaptive", "adaptive", ThinkingAdaptive},
|
for _, tt := range tests {
|
||||||
{"unknown", "unknown", ThinkingOff},
|
got, ok := normalizeThinkingLevel(tt.input)
|
||||||
// Case-insensitive and whitespace-tolerant
|
if ok != tt.ok || got != tt.want {
|
||||||
{"upper_Medium", "Medium", ThinkingMedium},
|
t.Fatalf("normalizeThinkingLevel(%q) = (%q,%v), want (%q,%v)", tt.input, got, ok, tt.want, tt.ok)
|
||||||
{"upper_HIGH", "HIGH", ThinkingHigh},
|
}
|
||||||
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
}
|
||||||
{"leading_space", " high", ThinkingHigh},
|
|
||||||
{"trailing_space", "low ", ThinkingLow},
|
|
||||||
{"both_spaces", " medium ", ThinkingMedium},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
func TestParseSupportedThinkingLevels(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
msg := `bad request: supported values: ["none","low","medium","high"]`
|
||||||
if got := parseThinkingLevel(tt.input); got != tt.want {
|
got := parseSupportedThinkingLevels(msg)
|
||||||
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ type AgentDefaults struct {
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
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
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
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"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ type Session struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Messages []providers.Message `json:"messages"`
|
Messages []providers.Message `json:"messages"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
|
|
@ -180,6 +210,7 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
snapshot := Session{
|
snapshot := Session{
|
||||||
Key: stored.Key,
|
Key: stored.Key,
|
||||||
Summary: stored.Summary,
|
Summary: stored.Summary,
|
||||||
|
ThinkingLevel: stored.ThinkingLevel,
|
||||||
Created: stored.Created,
|
Created: stored.Created,
|
||||||
Updated: stored.Updated,
|
Updated: stored.Updated,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue