Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docker/config.godmode.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion docker/config.restricted.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/EXTENDED_MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions internal/memory/extended/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions internal/memory/extended/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package extended
import (
"context"
"testing"
"time"

"github.com/BackendStack21/odek/internal/llm"
)

type dummyLLM struct{}
Expand Down Expand Up @@ -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)
}
}
Loading