Skip to content

Commit d5c68c5

Browse files
authored
feat: agent memory system (#1659)
1 parent 228e4ae commit d5c68c5

File tree

29 files changed

+6746
-2337
lines changed

29 files changed

+6746
-2337
lines changed

api/v1/api.gen.go

Lines changed: 2954 additions & 2046 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1/api.yaml

Lines changed: 342 additions & 42 deletions
Large diffs are not rendered by default.

internal/agent/api.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type API struct {
7171
dagStore exec.DAGStore // For resolving DAG file paths
7272
environment EnvironmentInfo
7373
hooks *Hooks
74+
memoryStore MemoryStore
7475
}
7576

7677
// APIConfig contains configuration for the API.
@@ -83,6 +84,7 @@ type APIConfig struct {
8384
DAGStore exec.DAGStore // For resolving DAG file paths
8485
Environment EnvironmentInfo
8586
Hooks *Hooks
87+
MemoryStore MemoryStore
8688
}
8789

8890
// SessionWithState is a session with its current state.
@@ -110,6 +112,7 @@ func NewAPI(cfg APIConfig) *API {
110112
dagStore: cfg.DAGStore,
111113
environment: cfg.Environment,
112114
hooks: cfg.Hooks,
115+
memoryStore: cfg.MemoryStore,
113116
}
114117
}
115118

@@ -277,13 +280,14 @@ func (a *API) createMessageCallback(id string) func(ctx context.Context, msg Mes
277280
}
278281

279282
// persistNewSession saves a new session to the store if configured.
280-
func (a *API) persistNewSession(ctx context.Context, id, userID string, now time.Time) {
283+
func (a *API) persistNewSession(ctx context.Context, id, userID, dagName string, now time.Time) {
281284
if a.store == nil {
282285
return
283286
}
284287
sess := &Session{
285288
ID: id,
286289
UserID: userID,
290+
DAGName: dagName,
287291
CreatedAt: now,
288292
UpdatedAt: now,
289293
}
@@ -340,6 +344,13 @@ func (a *API) handleNewSession(w http.ResponseWriter, r *http.Request) {
340344
id := uuid.New().String()
341345
now := time.Now()
342346

347+
// Extract primary DAG name from resolved contexts for per-DAG memory
348+
resolved := a.resolveContexts(r.Context(), req.DAGContexts)
349+
var dagName string
350+
if len(resolved) > 0 {
351+
dagName = resolved[0].DAGName
352+
}
353+
343354
mgr := NewSessionManager(SessionManagerConfig{
344355
ID: id,
345356
UserID: userID,
@@ -353,12 +364,14 @@ func (a *API) handleNewSession(w http.ResponseWriter, r *http.Request) {
353364
IPAddress: ipAddress,
354365
InputCostPer1M: modelCfg.InputCostPer1M,
355366
OutputCostPer1M: modelCfg.OutputCostPer1M,
367+
MemoryStore: a.memoryStore,
368+
DAGName: dagName,
356369
})
357370

358-
a.persistNewSession(r.Context(), id, userID, now)
371+
a.persistNewSession(r.Context(), id, userID, dagName, now)
359372
a.sessions.Store(id, mgr)
360373

361-
messageWithContext := a.formatMessage(r.Context(), req.Message, req.DAGContexts)
374+
messageWithContext := formatMessageWithContexts(req.Message, resolved)
362375
if err := mgr.AcceptUserMessage(r.Context(), provider, model, modelCfg.Model, messageWithContext); err != nil {
363376
a.logger.Error("Failed to accept user message", "error", err)
364377
a.respondError(w, http.StatusInternalServerError, api.ErrorCodeInternalError, "Failed to process message")
@@ -609,6 +622,8 @@ func (a *API) reactivateSession(ctx context.Context, id, userID, username, ipAdd
609622
Hooks: a.hooks,
610623
Username: username,
611624
IPAddress: ipAddress,
625+
MemoryStore: a.memoryStore,
626+
DAGName: sess.DAGName,
612627
})
613628
a.sessions.Store(id, mgr)
614629

internal/agent/memory.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package agent
2+
3+
import "context"
4+
5+
// MemoryStore provides access to agent memory files.
6+
// All implementations must be safe for concurrent use.
7+
type MemoryStore interface {
8+
// LoadGlobalMemory reads the global MEMORY.md, truncated to maxLines.
9+
LoadGlobalMemory(ctx context.Context) (string, error)
10+
11+
// LoadDAGMemory reads the MEMORY.md for a specific DAG, truncated to maxLines.
12+
LoadDAGMemory(ctx context.Context, dagName string) (string, error)
13+
14+
// SaveGlobalMemory writes content to the global MEMORY.md.
15+
SaveGlobalMemory(ctx context.Context, content string) error
16+
17+
// SaveDAGMemory writes content to a DAG-specific MEMORY.md.
18+
SaveDAGMemory(ctx context.Context, dagName string, content string) error
19+
20+
// MemoryDir returns the root memory directory path.
21+
MemoryDir() string
22+
23+
// ListDAGMemories returns the names of all DAGs that have memory files.
24+
ListDAGMemories(ctx context.Context) ([]string, error)
25+
26+
// DeleteGlobalMemory removes the global MEMORY.md file.
27+
DeleteGlobalMemory(ctx context.Context) error
28+
29+
// DeleteDAGMemory removes a DAG-specific MEMORY.md file.
30+
DeleteDAGMemory(ctx context.Context, dagName string) error
31+
}
32+
33+
// MemoryContent holds loaded memory for system prompt injection.
34+
type MemoryContent struct {
35+
GlobalMemory string // Contents of global MEMORY.md (truncated)
36+
DAGMemory string // Contents of per-DAG MEMORY.md (truncated)
37+
DAGName string // Name of the DAG (empty if no DAG context)
38+
MemoryDir string // Root memory directory path
39+
}

internal/agent/session.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type SessionManager struct {
4242
inputCostPer1M float64
4343
outputCostPer1M float64
4444
totalCost float64
45+
memoryStore MemoryStore
46+
dagName string
4547
}
4648

4749
// SessionManagerConfig contains configuration for creating a SessionManager.
@@ -61,6 +63,8 @@ type SessionManagerConfig struct {
6163
IPAddress string
6264
InputCostPer1M float64
6365
OutputCostPer1M float64
66+
MemoryStore MemoryStore
67+
DAGName string
6468
}
6569

6670
// NewSessionManager creates a new SessionManager.
@@ -97,6 +101,8 @@ func NewSessionManager(cfg SessionManagerConfig) *SessionManager {
97101
ipAddress: cfg.IPAddress,
98102
inputCostPer1M: cfg.InputCostPer1M,
99103
outputCostPer1M: cfg.OutputCostPer1M,
104+
memoryStore: cfg.MemoryStore,
105+
dagName: cfg.DAGName,
100106
}
101107
}
102108

@@ -200,6 +206,7 @@ func (sm *SessionManager) GetSession() Session {
200206
return Session{
201207
ID: sm.id,
202208
UserID: sm.userID,
209+
DAGName: sm.dagName,
203210
CreatedAt: sm.createdAt,
204211
UpdatedAt: sm.lastActivity,
205212
}
@@ -353,14 +360,15 @@ func (sm *SessionManager) ensureLoop(provider llm.Provider, modelID string, reso
353360

354361
// createLoop creates a new Loop instance with the current configuration.
355362
func (sm *SessionManager) createLoop(provider llm.Provider, model string, history []llm.Message, safeMode bool) *Loop {
363+
memory := sm.loadMemory()
356364
return NewLoop(LoopConfig{
357365
Provider: provider,
358366
Model: model,
359367
History: history,
360368
Tools: CreateTools(sm.environment.DAGsDir),
361369
RecordMessage: sm.createRecordMessageFunc(),
362370
Logger: sm.logger,
363-
SystemPrompt: GenerateSystemPrompt(sm.environment, nil),
371+
SystemPrompt: GenerateSystemPrompt(sm.environment, nil, memory),
364372
WorkingDir: sm.workingDir,
365373
SessionID: sm.id,
366374
OnWorking: sm.SetWorking,
@@ -375,6 +383,31 @@ func (sm *SessionManager) createLoop(provider llm.Provider, model string, histor
375383
})
376384
}
377385

386+
// loadMemory loads memory content from the memory store.
387+
func (sm *SessionManager) loadMemory() MemoryContent {
388+
if sm.memoryStore == nil {
389+
return MemoryContent{}
390+
}
391+
ctx := context.Background()
392+
global, err := sm.memoryStore.LoadGlobalMemory(ctx)
393+
if err != nil {
394+
sm.logger.Debug("failed to load global memory", "error", err)
395+
}
396+
var dagMem string
397+
if sm.dagName != "" {
398+
dagMem, err = sm.memoryStore.LoadDAGMemory(ctx, sm.dagName)
399+
if err != nil {
400+
sm.logger.Debug("failed to load DAG memory", "error", err, "dag_name", sm.dagName)
401+
}
402+
}
403+
return MemoryContent{
404+
GlobalMemory: global,
405+
DAGMemory: dagMem,
406+
DAGName: sm.dagName,
407+
MemoryDir: sm.memoryStore.MemoryDir(),
408+
}
409+
}
410+
378411
// runLoop executes the session loop and handles cleanup.
379412
func (sm *SessionManager) runLoop(ctx context.Context, loop *Loop) {
380413
defer func() {

internal/agent/system_prompt.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@ type CurrentDAG struct {
3434
type systemPromptData struct {
3535
EnvironmentInfo
3636
CurrentDAG *CurrentDAG
37+
Memory MemoryContent
3738
}
3839

39-
// GenerateSystemPrompt renders the system prompt template with the given environment
40-
// and optional DAG context.
41-
func GenerateSystemPrompt(env EnvironmentInfo, currentDAG *CurrentDAG) string {
40+
// GenerateSystemPrompt renders the system prompt template with the given environment,
41+
// optional DAG context, and memory content.
42+
func GenerateSystemPrompt(env EnvironmentInfo, currentDAG *CurrentDAG, memory MemoryContent) string {
4243
var buf bytes.Buffer
4344
data := systemPromptData{
4445
EnvironmentInfo: env,
4546
CurrentDAG: currentDAG,
47+
Memory: memory,
4648
}
4749
if err := systemPromptTemplate.Execute(&buf, data); err != nil {
4850
return fallbackPrompt(env)
@@ -52,5 +54,5 @@ func GenerateSystemPrompt(env EnvironmentInfo, currentDAG *CurrentDAG) string {
5254

5355
// fallbackPrompt returns a basic prompt when template execution fails.
5456
func fallbackPrompt(env EnvironmentInfo) string {
55-
return "You are Hermio, an AI assistant for DAG workflows. DAGs Directory: " + env.DAGsDir
57+
return "You are Tsumugi, an AI assistant for DAG workflows. DAGs Directory: " + env.DAGsDir
5658
}

internal/agent/system_prompt.txt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ Status: {{.CurrentDAG.Status}}{{end}}
3131
{{end}}
3232
</environment>
3333

34+
{{if or .Memory.GlobalMemory .Memory.DAGMemory}}
35+
<memory>
36+
{{if .Memory.GlobalMemory}}
37+
<global_memory>
38+
{{.Memory.GlobalMemory}}
39+
</global_memory>
40+
{{end}}
41+
{{if .Memory.DAGMemory}}
42+
<dag_memory dag="{{.Memory.DAGName}}">
43+
{{.Memory.DAGMemory}}
44+
</dag_memory>
45+
{{end}}
46+
</memory>
47+
{{end}}
48+
49+
{{if .Memory.MemoryDir}}
50+
<memory_paths>
51+
- Memory directory: {{.Memory.MemoryDir}}
52+
- Global memory: {{.Memory.MemoryDir}}/MEMORY.md
53+
{{if .Memory.DAGName}}- DAG memory: {{.Memory.MemoryDir}}/dags/{{.Memory.DAGName}}/MEMORY.md
54+
{{end}}</memory_paths>
55+
{{end}}
56+
3457
<rules>
3558
<safety>
3659
Do not start DAGs unless the user explicitly requests execution—starting a DAG triggers real processes that may have unintended side effects.
@@ -131,6 +154,32 @@ Do not use placeholders unless user explicitly requests dummy data for testing.
131154
3. Proceed only after user confirms configuration is complete.
132155
</configuration>
133156

157+
{{if .Memory.MemoryDir}}
158+
<memory_management>
159+
Consult your memory files to build on previous experience.
160+
When you learn something reusable, record it with DAG-first routing.
161+
162+
Paths:
163+
- Global: {{.Memory.MemoryDir}}/MEMORY.md
164+
- Per-DAG: {{.Memory.MemoryDir}}/dags/<dag-name>/MEMORY.md
165+
166+
Rules:
167+
- MEMORY.md is loaded into context automatically; keep under 200 lines
168+
- Use `read` and `patch` to manage memory files
169+
- Organize by topic, not chronologically
170+
- If DAG context is available, save memory to Per-DAG by default (not Global)
171+
- After creating or updating a DAG, if anything should be remembered, create/update that DAG's memory file
172+
- DAG memory can include DAG-specific config assumptions, pitfalls, fixes, and debugging playbooks
173+
- Global memory is only for cross-DAG or user-wide stable preferences/policies
174+
- If unsure whether knowledge is global or DAG-specific, store it in Per-DAG memory
175+
- If no DAG context is available, ask the user before writing to Global memory
176+
- Save stable patterns, environment details, user preferences, and debugging insights
177+
- Do not save session-specific state, secrets, or unverified info
178+
- When the user says "remember this", save it immediately
179+
- When the user says "forget this", remove it
180+
</memory_management>
181+
{{end}}
182+
134183
<schema_reference>
135184
Use `read_schema` to look up DAG YAML structure:
136185
- Root fields: `path: ""`

0 commit comments

Comments
 (0)