Skip to content

Commit 892e755

Browse files
committed
feat(schedule): wire memory into headless scheduled jobs
runTaskHeadless built the agent without MemoryDir/MemoryConfig, so scheduled jobs could not analyze memory or past sessions - the prerequisite for memory-driven proactive messages. Mirrors the interactive-run wiring (cmd/odek/main.go). Test proves a pre-seeded extended-memory atom reaches the model context in a headless run.
1 parent 41d6301 commit 892e755

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

cmd/odek/schedule.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,11 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
720720
IterationCallback: func(info loop.IterationInfo) { lastInfo = info },
721721
Guard: injectionGuard,
722722
GuardConfig: resolved.Guard,
723+
// Scheduled jobs may analyze memory and past sessions (e.g. proactive
724+
// nudges over open goals), so they get the same memory wiring as
725+
// interactive runs (see cmd/odek/main.go).
726+
MemoryDir: expandHome("~/.odek/memory"),
727+
MemoryConfig: resolved.Memory,
723728
})
724729
if err != nil {
725730
return "", 0, err

cmd/odek/schedule_memory_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"sync/atomic"
11+
"testing"
12+
13+
"github.com/BackendStack21/odek/internal/config"
14+
"github.com/BackendStack21/odek/internal/memory/extended"
15+
)
16+
17+
// TestRunTaskHeadless_MemoryWired proves that scheduled (headless) jobs get
18+
// the same memory wiring as interactive runs: a pre-seeded extended-memory
19+
// atom must reach the model's context. Regression test for the gap where
20+
// runTaskHeadless built the agent without MemoryDir/MemoryConfig.
21+
func TestRunTaskHeadless_MemoryWired(t *testing.T) {
22+
home := t.TempDir()
23+
t.Setenv("HOME", home)
24+
25+
// Seed an extended-memory atom in the (temp) global memory dir.
26+
extCfg := extended.DefaultConfig()
27+
enabled := true
28+
extCfg.Enabled = &enabled
29+
em := extended.New(home+"/.odek/memory/extended", nil, extCfg)
30+
if err := em.AddAtom(context.Background(), extended.MemoryAtom{
31+
Text: "The user's favorite editor is Emacs.",
32+
SourceClass: extended.SourceUserApproved,
33+
Type: extended.TypePreference,
34+
}); err != nil {
35+
t.Fatalf("seed atom: %v", err)
36+
}
37+
38+
// Mock LLM: capture the chat request body, answer with a final response.
39+
var sawAtom atomic.Bool
40+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
42+
w.Header().Set("Content-Type", "application/json")
43+
_ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}})
44+
return
45+
}
46+
body, _ := io.ReadAll(r.Body)
47+
if strings.Contains(string(body), "favorite editor is Emacs") {
48+
sawAtom.Store(true)
49+
}
50+
w.Header().Set("Content-Type", "application/json")
51+
_ = json.NewEncoder(w).Encode(map[string]any{
52+
"choices": []map[string]any{{
53+
"message": map[string]any{"role": "assistant", "content": "ok"},
54+
"finish_reason": "stop",
55+
}},
56+
"usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5},
57+
})
58+
}))
59+
defer srv.Close()
60+
61+
resolved := config.LoadConfig(config.CLIFlags{})
62+
resolved.BaseURL = srv.URL + "/v1"
63+
resolved.APIKey = "test"
64+
resolved.Model = "mock-model"
65+
resolved.Memory.Extended = &extCfg
66+
67+
_, _, err := runTaskHeadless(context.Background(), resolved, "you are a test agent", "what is my favorite editor?", nil)
68+
if err != nil {
69+
t.Fatalf("runTaskHeadless: %v", err)
70+
}
71+
if !sawAtom.Load() {
72+
t.Error("seeded memory atom did not reach the model context — memory is not wired into headless runs")
73+
}
74+
}

0 commit comments

Comments
 (0)