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:
damon 2026-03-06 22:46:12 +08:00
parent 1945436dd4
commit e0ece0652c
6 changed files with 403 additions and 80 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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