diff --git a/docker/config.godmode.json b/docker/config.godmode.json index b362d16..d6facbb 100644 --- a/docker/config.godmode.json +++ b/docker/config.godmode.json @@ -62,7 +62,10 @@ "proactive_nudges_enabled": true, "nudge_max_per_day": 5, "nudge_cooldown_hours": 24, - "nudge_stale_goal_days": 7 + "nudge_stale_goal_days": 7, + "llm": { + "thinking": "disabled" + } } }, "dangerous": { diff --git a/docker/config.restricted.json b/docker/config.restricted.json index c93a59f..0b9549a 100644 --- a/docker/config.restricted.json +++ b/docker/config.restricted.json @@ -62,7 +62,10 @@ "proactive_nudges_enabled": true, "nudge_max_per_day": 5, "nudge_cooldown_hours": 24, - "nudge_stale_goal_days": 7 + "nudge_stale_goal_days": 7, + "llm": { + "thinking": "disabled" + } } }, "dangerous": { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 56b5637..d15e84d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -434,7 +434,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md] | `nudge_cooldown_hours` | `24` | `ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS` | — | Per-kind cooldown before a nudge of the same kind can fire again. | | `nudge_stale_goal_days` | `7` | `ODEK_MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS` | — | Days without activity before a goal/intent atom counts as stale for nudges. | | `nudge_open_question_min_age_hours` | `24` | `ODEK_MEMORY_EXTENDED_NUDGE_OPEN_QUESTION_MIN_AGE_HOURS` | — | Minimum age before a `question` atom may become a nudge candidate (younger questions are usually about to be answered). | -| `llm` | omitted | — | — | Dedicated memory LLM. If omitted, the main agent LLM is reused. A warning is emitted if that model has thinking enabled. | +| `llm` | omitted | — | — | Dedicated memory LLM. If omitted, the main agent LLM is reused. A warning is emitted if that model has thinking enabled. Fields left empty inside `llm` are inherited from the main agent LLM, so a partial override such as `"llm": {"thinking": "disabled"}` reuses the parent `base_url`/`api_key`/`model`/`max_tokens`/`temperature` while disabling reasoning for memory calls. | | `embedding` | omitted | — | — | Dedicated embedding backend for atoms. If omitted, inherits `memory.embedding` or the shared top-level `embedding`. | ### `embedding` — real semantic embeddings (optional) diff --git a/docs/EXTENDED_MEMORY.md b/docs/EXTENDED_MEMORY.md index bd3fcb6..51d450a 100644 --- a/docs/EXTENDED_MEMORY.md +++ b/docs/EXTENDED_MEMORY.md @@ -113,6 +113,8 @@ Extended Memory can use its own LLM, separate from the main agent. This is ideal If `memory.extended.llm` is omitted, the module **MUST** use the default global model. The default global model is the fully resolved main agent LLM after all config layers have been merged: top-level `model`, `base_url`, `api_key`, `thinking`, `max_tokens`, `temperature`, and `timeout` from `~/.odek/config.json`, `ODEK_*` environment variables, and CLI flags. Extended Memory does not read any of those values again; it reuses the exact `llm.Client` instance constructed for the main agent loop. +When `memory.extended.llm` is present, any field left empty (`base_url`, `api_key`, `model`, `thinking`, `max_tokens`, `temperature`) is inherited from that same main agent client, so a partial override reuses the parent connection while changing only what it sets. For example, `"llm": {"thinking": "disabled"}` keeps the main model and endpoint but disables reasoning for memory calls, and `"llm": {"model": "qwen2.5:7b"}` switches to a cheaper model on the same backend. + If the default global model has reasoning/thinking enabled, memory extraction and reranking may be expensive. In that case the operator should configure a dedicated `memory.extended.llm` for cost isolation; a warning is emitted when thinking is enabled and no dedicated memory LLM is configured. ### Memory LLM Responsibilities diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 0d50683..aba99ec 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -214,7 +214,10 @@ func Resolve(cfg Config) Config { // ResolveLLM returns the LLM client to use for Extended Memory. If cfg.LLM is // set, it builds a dedicated client; otherwise it returns the provided main -// client unchanged. When falling back to the main client and the main model +// client unchanged. Fields left empty in cfg.LLM are inherited from the main +// client (when it is an *llm.Client), so operators can override a single knob +// — e.g. `"llm": {"thinking": "disabled"}` — without duplicating base_url, +// api_key, and model. When falling back to the main client and the main model // has thinking enabled, a warning is logged because reasoning tokens are // wasted on memory-only calls. func ResolveLLM(cfg Config, mainLLM LLMClient, thinking string) LLMClient { @@ -224,7 +227,27 @@ func ResolveLLM(cfg Config, mainLLM LLMClient, thinking string) LLMClient { } return mainLLM } - lmc := cfg.LLM + lmc := *cfg.LLM + if main, ok := mainLLM.(*llm.Client); ok { + if lmc.BaseURL == "" { + lmc.BaseURL = main.BaseURL + } + if lmc.APIKey == "" { + lmc.APIKey = main.APIKey + } + if lmc.Model == "" { + lmc.Model = main.Model + } + if lmc.Thinking == "" { + lmc.Thinking = main.Thinking + } + if lmc.MaxTokens == 0 { + lmc.MaxTokens = main.MaxTokens + } + if lmc.Temperature == 0 { + lmc.Temperature = main.Temperature + } + } if lmc.BaseURL == "" || lmc.Model == "" { fmt.Fprintf(os.Stderr, "odek: warning: extended memory llm requires base_url and model; falling back to main LLM\n") return mainLLM diff --git a/internal/memory/extended/config_test.go b/internal/memory/extended/config_test.go index cb0612c..0a3ff63 100644 --- a/internal/memory/extended/config_test.go +++ b/internal/memory/extended/config_test.go @@ -3,6 +3,9 @@ package extended import ( "context" "testing" + "time" + + "github.com/BackendStack21/odek/internal/llm" ) type dummyLLM struct{} @@ -69,3 +72,58 @@ func TestResolveLLMWithTimeout(t *testing.T) { t.Error("expected dedicated LLM client") } } + +func TestResolveLLMInheritsMainSettings(t *testing.T) { + main := llm.NewWithMaxTokens("https://api.example.com/v1/", "main-key", "main-model", "enabled", 0, 4096, 10*time.Second) + main.Temperature = 0.7 + cfg := Config{ + LLM: &LLMConfig{Thinking: "disabled"}, // override thinking only + } + got := ResolveLLM(cfg, main, "enabled") + client, ok := got.(*llm.Client) + if !ok { + t.Fatalf("expected *llm.Client, got %T", got) + } + if client == main { + t.Fatal("expected a dedicated client, got the main client") + } + if client.BaseURL != main.BaseURL { + t.Errorf("BaseURL = %q, want inherited %q", client.BaseURL, main.BaseURL) + } + if client.APIKey != main.APIKey { + t.Errorf("APIKey = %q, want inherited %q", client.APIKey, main.APIKey) + } + if client.Model != main.Model { + t.Errorf("Model = %q, want inherited %q", client.Model, main.Model) + } + if client.Thinking != "disabled" { + t.Errorf("Thinking = %q, want %q", client.Thinking, "disabled") + } + if client.MaxTokens != main.MaxTokens { + t.Errorf("MaxTokens = %d, want inherited %d", client.MaxTokens, main.MaxTokens) + } + if client.Temperature != main.Temperature { + t.Errorf("Temperature = %v, want inherited %v", client.Temperature, main.Temperature) + } +} + +func TestResolveLLMPartialOverrideKeepsInheritedRest(t *testing.T) { + main := llm.New("https://api.example.com/v1", "main-key", "main-model", "enabled", 0, 0) + cfg := Config{ + LLM: &LLMConfig{Model: "cheap-model"}, // different model, same backend + } + got := ResolveLLM(cfg, main, "enabled") + client, ok := got.(*llm.Client) + if !ok { + t.Fatalf("expected *llm.Client, got %T", got) + } + if client.Model != "cheap-model" { + t.Errorf("Model = %q, want %q", client.Model, "cheap-model") + } + if client.BaseURL != main.BaseURL || client.APIKey != main.APIKey { + t.Errorf("expected inherited BaseURL/APIKey, got %q/%q", client.BaseURL, client.APIKey) + } + if client.Thinking != main.Thinking { + t.Errorf("Thinking = %q, want inherited %q", client.Thinking, main.Thinking) + } +}