Skip to content

Commit a67548b

Browse files
committed
feat(loop): prompt tiering — separate memory message for cache efficiency
Splits memory injection into a separate system message rather than concatenating into messages[0] (baseSystem). This keeps the stable base prompt identical across turns, enabling DeepSeek/Anthropic prompt caching to reuse cached tokens. Changes: - Engine.memMsgIdx tracks volatile memory message position - memoryPromptFunc output goes to messages[1] per-iteration - messages[0] (baseSystem) never modified after init - memMsgIdx resets between Run/RunWithMessages calls - odek.go: removed init-time memory append (per-turn callback handles it) Estimated token savings: ~60% of system prompt tokens cached (baseSystem ~5K tokens cached, only memory block ~1K recalculated)
1 parent 0ecf289 commit a67548b

3 files changed

Lines changed: 195 additions & 8 deletions

File tree

internal/loop/loop.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ type Engine struct {
7272
// are visible to the agent on the next turn.
7373
memoryPromptFunc func() string
7474

75+
// memMsgIdx tracks the position of the volatile memory system message
76+
// in the messages array. -1 means not yet inserted. Using a separate
77+
// message for memory (rather than concatenating into messages[0]) lets
78+
// DeepSeek/Anthropic prompt caching keep the stable baseSystem cached
79+
// across turns — only the memory message changes each iteration.
80+
memMsgIdx int
81+
7582
// PromptCaching enables Anthropic/OpenAI/DeepSeek prompt caching markers.
7683
// When enabled, the system prompt and first user message are annotated
7784
// with cache_control markers, and the system prompt is moved to the
@@ -288,6 +295,7 @@ func (e *Engine) trimContext(messages []llm.Message, toolDefs []llm.ToolDef) []l
288295

289296
// Run executes the loop for a given task and returns the final response.
290297
func (e *Engine) Run(ctx context.Context, task string) (string, error) {
298+
e.memMsgIdx = -1
291299
messages := []llm.Message{
292300
{Role: "user", Content: task},
293301
}
@@ -308,6 +316,7 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
308316
// new user message, call RunWithMessages, then save the returned messages.
309317
func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (string, []llm.Message, error) {
310318
// Reset token accounting for this run
319+
e.memMsgIdx = -1
311320
e.TotalInputTokens = 0
312321
e.TotalOutputTokens = 0
313322
e.TotalCacheCreationTokens = 0
@@ -401,13 +410,30 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
401410

402411
// Refresh memory content before each LLM call so the agent sees
403412
// the latest facts even if it mutated memory during this session.
413+
// Memory is injected as a separate system message (messages[1] or
414+
// later) so that messages[0] (baseSystem) remains stable across
415+
// turns — letting DeepSeek/Anthropic prompt caching keep it cached.
404416
if e.memoryPromptFunc != nil {
405417
if memBlock := e.memoryPromptFunc(); memBlock != "" {
406-
e.system = e.baseSystem + memBlock
407-
// Update messages[0] if it's the system message
418+
// Keep messages[0] as the stable baseSystem (never modified).
408419
if len(messages) > 0 && messages[0].Role == "system" {
409-
messages[0].Content = e.system
420+
messages[0].Content = e.baseSystem
421+
}
422+
memMsg := llm.Message{Role: "system", Content: memBlock}
423+
if e.memMsgIdx >= 0 && e.memMsgIdx < len(messages) {
424+
// Update existing memory slot — keeps position stable.
425+
messages[e.memMsgIdx].Content = memBlock
426+
} else {
427+
// First time: insert memory message after base system.
428+
insertAt := 1
429+
messages = append(messages[:insertAt],
430+
append([]llm.Message{memMsg}, messages[insertAt:]...)...)
431+
e.memMsgIdx = insertAt
410432
}
433+
} else if e.memMsgIdx >= 0 && e.memMsgIdx < len(messages) {
434+
// No memory block — remove the memory message if present.
435+
messages = append(messages[:e.memMsgIdx], messages[e.memMsgIdx+1:]...)
436+
e.memMsgIdx = -1
411437
}
412438
}
413439

internal/loop/loop_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,3 +941,168 @@ func TestEngine_Run_CacheAccumulation_NoCache(t *testing.T) {
941941
t.Errorf("TotalCachedTokens = %d, want 0", engine.TotalCachedTokens)
942942
}
943943
}
944+
945+
// ── Prompt Tiering Tests ───────────────────────────────────────────
946+
947+
// TestPromptTiering_SeparateMemoryMessage verifies that memory is injected
948+
// as a separate system message rather than concatenated into messages[0].
949+
// This ensures messages[0] (baseSystem) remains stable across turns for
950+
// DeepSeek/Anthropic prompt caching.
951+
func TestPromptTiering_SeparateMemoryMessage(t *testing.T) {
952+
callCount := 0
953+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
954+
callCount++
955+
var body struct {
956+
Messages []struct {
957+
Role string `json:"role"`
958+
Content string `json:"content"`
959+
} `json:"messages"`
960+
}
961+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
962+
return
963+
}
964+
965+
if callCount == 1 {
966+
// Verify: messages[0] = baseSystem (stable), messages[1] = memory (volatile)
967+
if len(body.Messages) < 2 {
968+
t.Errorf("expected at least 2 messages (system + memory + user), got %d", len(body.Messages))
969+
} else {
970+
if body.Messages[0].Role != "system" {
971+
t.Errorf("messages[0].Role = %q, want system", body.Messages[0].Role)
972+
}
973+
if body.Messages[0].Content != "You are a stable base." {
974+
t.Errorf("messages[0].Content = %q, want %q", body.Messages[0].Content, "You are a stable base.")
975+
}
976+
if body.Messages[1].Role != "system" {
977+
t.Errorf("messages[1].Role = %q, want system (memory)", body.Messages[1].Role)
978+
}
979+
if body.Messages[1].Content != "memory-block-v1" {
980+
t.Errorf("messages[1].Content = %q, want memory-block-v1", body.Messages[1].Content)
981+
}
982+
}
983+
// Return a tool call to force another iteration.
984+
fmt.Fprint(w, `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}]}`)
985+
} else {
986+
// Second call: memory should be updated.
987+
if len(body.Messages) >= 2 && body.Messages[1].Role == "system" {
988+
if body.Messages[1].Content != "memory-block-v2" {
989+
t.Errorf("messages[1].Content = %q, want memory-block-v2", body.Messages[1].Content)
990+
}
991+
// messages[0] must still be the stable base.
992+
if body.Messages[0].Content != "You are a stable base." {
993+
t.Errorf("messages[0].Content changed: %q, want %q", body.Messages[0].Content, "You are a stable base.")
994+
}
995+
}
996+
fmt.Fprint(w, `{"choices":[{"message":{"content":"done"}}]}`)
997+
}
998+
}))
999+
defer server.Close()
1000+
1001+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1002+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
1003+
registry := tool.NewRegistry([]tool.Tool{echoTool})
1004+
engine := New(client, registry, 10, "You are a stable base.", nil, 0)
1005+
1006+
// Set up memory callback that returns different values per call.
1007+
memVersion := 0
1008+
engine.SetMemoryPromptFunc(func() string {
1009+
memVersion++
1010+
return fmt.Sprintf("memory-block-v%d", memVersion)
1011+
})
1012+
1013+
_, err := engine.Run(context.Background(), "test")
1014+
if err != nil {
1015+
t.Fatalf("Run() error: %v", err)
1016+
}
1017+
}
1018+
1019+
// TestPromptTiering_NoMemoryDropsMessage verifies that when the memory
1020+
// callback returns empty, the memory system message is removed.
1021+
func TestPromptTiering_NoMemoryDropsMessage(t *testing.T) {
1022+
callCount := 0
1023+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1024+
callCount++
1025+
var body struct {
1026+
Messages []struct {
1027+
Role string `json:"role"`
1028+
Content string `json:"content"`
1029+
} `json:"messages"`
1030+
}
1031+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
1032+
return
1033+
}
1034+
1035+
if callCount == 1 {
1036+
// First call: memory is non-empty, should be at index 1.
1037+
if len(body.Messages) >= 2 && body.Messages[1].Role == "system" {
1038+
if body.Messages[1].Content != "initial-memory" {
1039+
t.Errorf("unexpected memory: %q", body.Messages[1].Content)
1040+
}
1041+
}
1042+
fmt.Fprint(w, `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}]}`)
1043+
} else {
1044+
// Second call: memory is empty, should NOT have a second system message.
1045+
systemCount := 0
1046+
for _, m := range body.Messages {
1047+
if m.Role == "system" {
1048+
systemCount++
1049+
}
1050+
}
1051+
if systemCount != 1 {
1052+
t.Errorf("expected 1 system message (base only), got %d", systemCount)
1053+
}
1054+
fmt.Fprint(w, `{"choices":[{"message":{"content":"done"}}]}`)
1055+
}
1056+
}))
1057+
defer server.Close()
1058+
1059+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1060+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
1061+
registry := tool.NewRegistry([]tool.Tool{echoTool})
1062+
engine := New(client, registry, 10, "You are a stable base.", nil, 0)
1063+
1064+
memVersion := 0
1065+
engine.SetMemoryPromptFunc(func() string {
1066+
memVersion++
1067+
if memVersion == 1 {
1068+
return "initial-memory"
1069+
}
1070+
return "" // Empty after first call
1071+
})
1072+
1073+
_, err := engine.Run(context.Background(), "test")
1074+
if err != nil {
1075+
t.Fatalf("Run() error: %v", err)
1076+
}
1077+
}
1078+
1079+
// TestPromptTiering_MemMsgIdxResets verifies that memMsgIdx resets
1080+
// between Run calls, preventing stale index carry-over.
1081+
func TestPromptTiering_MemMsgIdxResets(t *testing.T) {
1082+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1083+
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
1084+
}))
1085+
defer server.Close()
1086+
1087+
client := llm.New(server.URL, "sk-test", "test-model", "", 0)
1088+
engine := New(client, registryOrNil(), 10, "base system", nil, 0)
1089+
1090+
// Run 1 with memory
1091+
engine.SetMemoryPromptFunc(func() string { return "mem-run1" })
1092+
engine.Run(context.Background(), "run1")
1093+
1094+
// memMsgIdx should be set
1095+
if engine.memMsgIdx != 1 {
1096+
t.Errorf("after run1: memMsgIdx = %d, want 1", engine.memMsgIdx)
1097+
}
1098+
1099+
// Run 2 without memory callback — should reset
1100+
engine.memoryPromptFunc = nil
1101+
engine.Run(context.Background(), "run2")
1102+
1103+
if engine.memMsgIdx != -1 {
1104+
t.Errorf("after run2 (no callback): memMsgIdx = %d, want -1", engine.memMsgIdx)
1105+
}
1106+
}
1107+
1108+
func registryOrNil() *tool.Registry { return tool.NewRegistry(nil) }

odek.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,12 +435,8 @@ func New(cfg Config) (*Agent, error) {
435435
}
436436

437437
// Wire per-turn memory injection so the agent sees the latest facts
438-
// even after mutating memory during a session.
439-
// The initial system message gets the current memory snapshot,
440438
// and the loop engine refreshes it before each LLM call.
441-
if memoryBlock := memoryManager.BuildSystemPrompt(); memoryBlock != "" {
442-
cfg.SystemMessage += memoryBlock
443-
}
439+
// (Memory is injected per-turn via SetMemoryPromptFunc below.)
444440

445441
// Auto-search relevant past episodes and inject them into the system
446442
// prompt so the agent has context from previous sessions without

0 commit comments

Comments
 (0)