From ee9f5b8aaf7b626ca7961eafe80b4fd4e6f8c8d9 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Mar 2026 22:45:40 +0700 Subject: [PATCH] feat(team): share leader memory with team members (read-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team members dispatched via team tasks can now read the leader's memory files (MEMORY.md, memory/*.md) as a fallback when they have no own memory. Memory writes are blocked for members — only the leader can save memory files. This follows the existing team_workspace context propagation pattern: dispatch metadata → RunRequest → context → tools. Affected tools: read_file (interceptor), memory_get, memory_search, list_files (interceptor). All include leader fallback with per-user → global scope cascade. --- cmd/gateway_consumer_handlers.go | 1 + internal/agent/loop_context.go | 4 + internal/agent/loop_types.go | 1 + internal/store/run_context.go | 1 + internal/tools/context_keys.go | 21 ++ internal/tools/context_keys_test.go | 12 + internal/tools/memory.go | 27 +- internal/tools/memory_interceptor.go | 40 +- internal/tools/memory_interceptor_test.go | 421 ++++++++++++++++++++++ internal/tools/team_tool_dispatch.go | 2 + 10 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 internal/tools/memory_interceptor_test.go diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 5da81fe7..09be273f 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -287,6 +287,7 @@ func handleTeammateMessage( Stream: false, TeamTaskID: msg.Metadata["team_task_id"], TeamWorkspace: msg.Metadata["team_workspace"], + LeaderAgentID: msg.Metadata["leader_agent_id"], WorkspaceChatID: origChatID, TeamID: msg.Metadata["team_id"], LinkedTraceID: linkedTraceID, diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 1828c3ad..db051a87 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -142,6 +142,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if req.TeamID != "" { ctx = tools.WithToolTeamID(ctx, req.TeamID) } + if req.LeaderAgentID != "" { + ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID) + } // Team workspace: auto-resolve for agents with team membership (not dispatched). // Lead agents default to team workspace; non-lead members keep own workspace. @@ -256,6 +259,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup WorkspaceChannel: req.WorkspaceChannel, WorkspaceChatID: req.WorkspaceChatID, TeamTaskID: req.TeamTaskID, + LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx), AgentToolKey: l.id, } ctx = store.WithRunContext(ctx, rc) diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index faabe556..b3e0a6fa 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -436,6 +436,7 @@ type RunRequest struct { TeamID string // team ID (if delegation is team-scoped) TeamTaskID string // team task ID (if delegation has an associated task) ParentAgentID string // parent agent key that initiated the delegation + LeaderAgentID string // leader agent UUID for member memory read fallback // Workspace scope propagation (set by delegation, read by workspace tools) WorkspaceChannel string diff --git a/internal/store/run_context.go b/internal/store/run_context.go index bc6212d8..46c79216 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -51,6 +51,7 @@ type RunContext struct { WorkspaceChannel string WorkspaceChatID string TeamTaskID string + LeaderAgentID string // leader's agent UUID for member memory read fallback AgentToolKey string // tool-level agent key for registry routing } diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 37818850..394a97c7 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -356,6 +356,27 @@ func TeamTaskIDFromCtx(ctx context.Context) string { return "" } +// --- Leader agent ID propagation (team task dispatch → memory interceptor) --- + +const ctxLeaderAgentID toolContextKey = "tool_leader_agent_id" + +// WithLeaderAgentID injects the team leader's agent UUID string into context +// so the memory interceptor can fallback-read leader's memory for team members. +func WithLeaderAgentID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, ctxLeaderAgentID, id) +} + +// LeaderAgentIDFromCtx returns the leader's agent UUID string from context. +func LeaderAgentIDFromCtx(ctx context.Context) string { + if v, _ := ctx.Value(ctxLeaderAgentID).(string); v != "" { + return v + } + if rc := store.RunContextFromCtx(ctx); rc != nil { + return rc.LeaderAgentID + } + return "" +} + // --- Workspace scope propagation (delegation origin) --- const ( diff --git a/internal/tools/context_keys_test.go b/internal/tools/context_keys_test.go index c1fdd6a9..fd980751 100644 --- a/internal/tools/context_keys_test.go +++ b/internal/tools/context_keys_test.go @@ -67,6 +67,18 @@ func TestToolContextKeys_AsyncCB(t *testing.T) { } } +func TestToolContextKeys_LeaderAgentID(t *testing.T) { + ctx := context.Background() + if v := LeaderAgentIDFromCtx(ctx); v != "" { + t.Errorf("expected empty, got %q", v) + } + + ctx = WithLeaderAgentID(ctx, "leader-uuid-123") + if v := LeaderAgentIDFromCtx(ctx); v != "leader-uuid-123" { + t.Errorf("expected leader-uuid-123, got %q", v) + } +} + func TestToolContextKeys_MultipleValues(t *testing.T) { ctx := context.Background() ctx = WithToolChannel(ctx, "slack") diff --git a/internal/tools/memory.go b/internal/tools/memory.go index bd97708c..7cd8ded0 100644 --- a/internal/tools/memory.go +++ b/internal/tools/memory.go @@ -98,10 +98,19 @@ func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]any) *Re searchOpts.MinScore = mc.MinScore } } - results, err := t.memStore.Search(ctx, query, agentID.String(), userID, searchOpts) + agentStr := agentID.String() + results, err := t.memStore.Search(ctx, query, agentStr, userID, searchOpts) if err != nil { return ErrorResult(fmt.Sprintf("memory search failed: %v", err)) } + // Fallback: also search leader's memory for team members and merge results. + if leaderID := LeaderAgentIDFromCtx(ctx); leaderID != "" && leaderID != agentStr { + leaderResults, lerr := t.memStore.Search(ctx, query, leaderID, userID, searchOpts) + if lerr != nil && userID != "" { + leaderResults, _ = t.memStore.Search(ctx, query, leaderID, "", searchOpts) + } + results = append(results, leaderResults...) + } if len(results) == 0 { return NewResult("No memory results found for query: " + query) } @@ -179,11 +188,21 @@ func (t *MemoryGetTool) Execute(ctx context.Context, args map[string]any) *Resul userID := store.MemoryUserID(ctx) + agentStr := agentID.String() + // Try per-user first, then global - content, err := t.memStore.GetDocument(ctx, agentID.String(), userID, path) + content, err := t.memStore.GetDocument(ctx, agentStr, userID, path) if err != nil && userID != "" { - // Fallback to global - content, err = t.memStore.GetDocument(ctx, agentID.String(), "", path) + content, err = t.memStore.GetDocument(ctx, agentStr, "", path) + } + // Fallback: try leader's memory for team members. + if err != nil { + if leaderID := LeaderAgentIDFromCtx(ctx); leaderID != "" && leaderID != agentStr { + content, err = t.memStore.GetDocument(ctx, leaderID, userID, path) + if err != nil && userID != "" { + content, err = t.memStore.GetDocument(ctx, leaderID, "", path) + } + } } if err != nil { return ErrorResult(fmt.Sprintf("failed to read %s: %v", path, err)) diff --git a/internal/tools/memory_interceptor.go b/internal/tools/memory_interceptor.go index f28b9977..99fde7c4 100644 --- a/internal/tools/memory_interceptor.go +++ b/internal/tools/memory_interceptor.go @@ -117,7 +117,16 @@ func (m *MemoryInterceptor) ReadFile(ctx context.Context, path string) (string, content, err = m.memStore.GetDocument(ctx, agentStr, "", relPath) } if err != nil { - // Not found is OK — return empty + // Not found in own memory — try leader's memory as fallback (team members only). + if leaderID := LeaderAgentIDFromCtx(ctx); leaderID != "" && leaderID != agentStr { + leaderContent, lerr := m.memStore.GetDocument(ctx, leaderID, userID, relPath) + if lerr != nil && userID != "" { + leaderContent, lerr = m.memStore.GetDocument(ctx, leaderID, "", relPath) + } + if lerr == nil && leaderContent != "" { + return leaderContent, true, nil + } + } slog.Debug("memory interceptor: document not found", "path", path, "agent", agentStr) return "", true, nil } @@ -147,11 +156,17 @@ func (m *MemoryInterceptor) WriteFile(ctx context.Context, path, content string, return MemoryWriteResult{}, nil // no agent context } + // Block memory writes for team members — memory is leader-only. + agentStr := agentID.String() + if leaderID := LeaderAgentIDFromCtx(ctx); leaderID != "" && leaderID != agentStr { + return MemoryWriteResult{Handled: true}, fmt.Errorf( + "memory is read-only for team members — only the team leader can save memory files") + } + // Normalize absolute path to workspace-relative for DB storage relPath := normalizeToRelative(path, ws) userID := store.MemoryUserID(ctx) - agentStr := agentID.String() var previousContent string @@ -208,11 +223,30 @@ func (m *MemoryInterceptor) ListFiles(ctx context.Context, path string) (string, } userID := store.MemoryUserID(ctx) - docs, err := m.memStore.ListDocuments(ctx, agentID.String(), userID) + agentStr := agentID.String() + docs, err := m.memStore.ListDocuments(ctx, agentStr, userID) if err != nil { return "", true, err } + // Merge leader's documents for team members (fallback, deduplicate by path). + if leaderID := LeaderAgentIDFromCtx(ctx); leaderID != "" && leaderID != agentStr { + seen := make(map[string]bool, len(docs)) + for _, d := range docs { + seen[d.Path] = true + } + leaderDocs, _ := m.memStore.ListDocuments(ctx, leaderID, userID) + // Also try global scope if per-user returned nothing. + if len(leaderDocs) == 0 && userID != "" { + leaderDocs, _ = m.memStore.ListDocuments(ctx, leaderID, "") + } + for _, d := range leaderDocs { + if !seen[d.Path] { + docs = append(docs, d) + } + } + } + if len(docs) == 0 { return "", true, nil } diff --git a/internal/tools/memory_interceptor_test.go b/internal/tools/memory_interceptor_test.go new file mode 100644 index 00000000..e39de310 --- /dev/null +++ b/internal/tools/memory_interceptor_test.go @@ -0,0 +1,421 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// mockMemoryStore is a minimal in-memory implementation of store.MemoryStore +// for unit testing the MemoryInterceptor. +type mockMemoryStore struct { + docs map[string]string // key: "agentID|userID|path" +} + +func newMockMemoryStore() *mockMemoryStore { + return &mockMemoryStore{docs: make(map[string]string)} +} + +func docKey(agentID, userID, path string) string { + return agentID + "|" + userID + "|" + path +} + +func (m *mockMemoryStore) GetDocument(_ context.Context, agentID, userID, path string) (string, error) { + if v, ok := m.docs[docKey(agentID, userID, path)]; ok { + return v, nil + } + return "", fmt.Errorf("not found") +} + +func (m *mockMemoryStore) PutDocument(_ context.Context, agentID, userID, path, content string) error { + m.docs[docKey(agentID, userID, path)] = content + return nil +} + +func (m *mockMemoryStore) DeleteDocument(_ context.Context, agentID, userID, path string) error { + delete(m.docs, docKey(agentID, userID, path)) + return nil +} + +func (m *mockMemoryStore) ListDocuments(_ context.Context, agentID, userID string) ([]store.DocumentInfo, error) { + var out []store.DocumentInfo + prefix := agentID + "|" + userID + "|" + for k := range m.docs { + if strings.HasPrefix(k, prefix) { + path := strings.TrimPrefix(k, prefix) + out = append(out, store.DocumentInfo{Path: path}) + } + } + return out, nil +} + +// Unused interface methods — satisfy store.MemoryStore. +func (m *mockMemoryStore) ListAllDocumentsGlobal(_ context.Context) ([]store.DocumentInfo, error) { + return nil, nil +} +func (m *mockMemoryStore) ListAllDocuments(_ context.Context, _ string) ([]store.DocumentInfo, error) { + return nil, nil +} +func (m *mockMemoryStore) GetDocumentDetail(_ context.Context, _, _, _ string) (*store.DocumentDetail, error) { + return nil, nil +} +func (m *mockMemoryStore) ListChunks(_ context.Context, _, _, _ string) ([]store.ChunkInfo, error) { + return nil, nil +} +func (m *mockMemoryStore) Search(_ context.Context, _ string, _, _ string, _ store.MemorySearchOptions) ([]store.MemorySearchResult, error) { + return nil, nil +} +func (m *mockMemoryStore) IndexDocument(_ context.Context, _, _, _ string) error { return nil } +func (m *mockMemoryStore) IndexAll(_ context.Context, _, _ string) error { return nil } +func (m *mockMemoryStore) SetEmbeddingProvider(_ store.EmbeddingProvider) {} +func (m *mockMemoryStore) Close() error { return nil } + +// --- Test helpers --- + +func memCtx(agentID uuid.UUID, userID, leaderID string) context.Context { + ctx := context.Background() + ctx = store.WithAgentID(ctx, agentID) + ctx = store.WithUserID(ctx, userID) + if leaderID != "" { + ctx = WithLeaderAgentID(ctx, leaderID) + } + return ctx +} + +// --- ReadFile tests --- + +func TestReadFile_NoLeader_OwnMemory(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + agentID := uuid.New() + + ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "my notes" + + ctx := memCtx(agentID, "user1", "") + content, handled, err := mi.ReadFile(ctx, "MEMORY.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if content != "my notes" { + t.Errorf("expected 'my notes', got %q", content) + } +} + +func TestReadFile_LeaderFallback(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + // Leader has memory, member does not. + ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader notes" + + ctx := memCtx(memberID, "user1", leaderID.String()) + content, handled, err := mi.ReadFile(ctx, "MEMORY.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if content != "leader notes" { + t.Errorf("expected 'leader notes', got %q", content) + } +} + +func TestReadFile_LeaderFallback_SharedScope(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + // Leader has shared (global) memory only. + ms.docs[docKey(leaderID.String(), "", "MEMORY.md")] = "leader shared" + + ctx := memCtx(memberID, "user1", leaderID.String()) + content, handled, err := mi.ReadFile(ctx, "MEMORY.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if content != "leader shared" { + t.Errorf("expected 'leader shared', got %q", content) + } +} + +func TestReadFile_LeaderIsSelf(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + agentID := uuid.New() + + ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "own notes" + + // Leader is the same agent — should read own memory, no fallback. + ctx := memCtx(agentID, "user1", agentID.String()) + content, handled, err := mi.ReadFile(ctx, "MEMORY.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if content != "own notes" { + t.Errorf("expected 'own notes', got %q", content) + } +} + +func TestReadFile_NonMemoryPath(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + + ctx := memCtx(uuid.New(), "user1", "") + _, handled, err := mi.ReadFile(ctx, "README.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if handled { + t.Error("expected handled=false for non-memory path") + } +} + +func TestReadFile_MemberNoMemory_NoLeader_Empty(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + + ctx := memCtx(uuid.New(), "user1", "") + content, handled, err := mi.ReadFile(ctx, "MEMORY.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true for memory path") + } + if content != "" { + t.Errorf("expected empty content, got %q", content) + } +} + +// --- WriteFile tests --- + +func TestWriteFile_NoLeader_AllowWrite(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + agentID := uuid.New() + + ctx := memCtx(agentID, "user1", "") + result, err := mi.WriteFile(ctx, "MEMORY.md", "new content", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Handled { + t.Fatal("expected handled=true") + } + + // Verify content was written. + got, _ := ms.GetDocument(ctx, agentID.String(), "user1", "MEMORY.md") + if got != "new content" { + t.Errorf("expected 'new content', got %q", got) + } +} + +func TestWriteFile_LeaderPresent_BlockWrite(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + ctx := memCtx(memberID, "user1", leaderID.String()) + result, err := mi.WriteFile(ctx, "MEMORY.md", "attempt", false) + if err == nil { + t.Fatal("expected error for blocked write") + } + if !result.Handled { + t.Fatal("expected handled=true") + } + if !strings.Contains(err.Error(), "read-only") { + t.Errorf("expected read-only error, got: %v", err) + } +} + +func TestWriteFile_LeaderIsSelf_AllowWrite(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + agentID := uuid.New() + + // Leader is the same agent — should allow write. + ctx := memCtx(agentID, "user1", agentID.String()) + result, err := mi.WriteFile(ctx, "MEMORY.md", "leader writes", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Handled { + t.Fatal("expected handled=true") + } + + got, _ := ms.GetDocument(ctx, agentID.String(), "user1", "MEMORY.md") + if got != "leader writes" { + t.Errorf("expected 'leader writes', got %q", got) + } +} + +// --- MemoryGetTool leader fallback tests --- + +func TestMemoryGet_LeaderFallback(t *testing.T) { + ms := newMockMemoryStore() + tool := NewMemoryGetTool() + tool.SetMemoryStore(ms) + + memberID := uuid.New() + leaderID := uuid.New() + + ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader get content" + + ctx := memCtx(memberID, "user1", leaderID.String()) + result := tool.Execute(ctx, map[string]any{"path": "MEMORY.md"}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "leader get content") { + t.Errorf("expected leader content in result, got: %s", result.ForLLM) + } +} + +func TestMemoryGet_BlockedByNoLeader(t *testing.T) { + ms := newMockMemoryStore() + tool := NewMemoryGetTool() + tool.SetMemoryStore(ms) + + memberID := uuid.New() + // No leader, no own memory → error. + ctx := memCtx(memberID, "user1", "") + result := tool.Execute(ctx, map[string]any{"path": "MEMORY.md"}) + if !result.IsError { + t.Fatal("expected error for missing memory") + } +} + +// --- MemorySearchTool leader fallback tests --- + +func TestMemorySearch_LeaderFallback(t *testing.T) { + ms := newMockMemoryStore() + tool := NewMemorySearchTool() + tool.SetMemoryStore(ms) + + memberID := uuid.New() + leaderID := uuid.New() + + // mockMemoryStore.Search returns nil — just verify no crash and correct agent IDs used. + ctx := memCtx(memberID, "user1", leaderID.String()) + result := tool.Execute(ctx, map[string]any{"query": "test"}) + // With mock returning nil results for both, should get "No memory results found". + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No memory results found") { + t.Errorf("expected no results message, got: %s", result.ForLLM) + } +} + +// --- ListFiles tests --- + +func TestListFiles_MergeLeaderDocs(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + // Leader has docs, member has none. + ms.docs[docKey(leaderID.String(), "user1", "MEMORY.md")] = "leader mem" + ms.docs[docKey(leaderID.String(), "user1", "memory/notes.md")] = "leader notes" + + ctx := memCtx(memberID, "user1", leaderID.String()) + listing, handled, err := mi.ListFiles(ctx, "memory") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if !strings.Contains(listing, "MEMORY.md") { + t.Errorf("expected MEMORY.md in listing, got: %s", listing) + } + if !strings.Contains(listing, "memory/notes.md") { + t.Errorf("expected memory/notes.md in listing, got: %s", listing) + } +} + +func TestListFiles_LeaderGlobalScopeFallback(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + // Leader has only global-scope docs (userID=""). + ms.docs[docKey(leaderID.String(), "", "MEMORY.md")] = "leader global" + + ctx := memCtx(memberID, "user1", leaderID.String()) + listing, handled, err := mi.ListFiles(ctx, "memory") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if !strings.Contains(listing, "MEMORY.md") { + t.Errorf("expected MEMORY.md from leader's global scope, got: %s", listing) + } +} + +func TestReadFile_LeaderFallback_MemorySubpath(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + memberID := uuid.New() + leaderID := uuid.New() + + // Leader has a memory subpath file. + ms.docs[docKey(leaderID.String(), "user1", "memory/notes.md")] = "leader subpath" + + ctx := memCtx(memberID, "user1", leaderID.String()) + content, handled, err := mi.ReadFile(ctx, "memory/notes.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + if content != "leader subpath" { + t.Errorf("expected 'leader subpath', got %q", content) + } +} + +func TestListFiles_LeaderIsSelf_NoDuplication(t *testing.T) { + ms := newMockMemoryStore() + mi := NewMemoryInterceptor(ms, "/workspace") + agentID := uuid.New() + + ms.docs[docKey(agentID.String(), "user1", "MEMORY.md")] = "own mem" + + ctx := memCtx(agentID, "user1", agentID.String()) + listing, handled, err := mi.ListFiles(ctx, "memory") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !handled { + t.Fatal("expected handled=true") + } + // Should appear exactly once. + count := strings.Count(listing, "MEMORY.md") + if count != 1 { + t.Errorf("expected MEMORY.md once, got %d times in: %s", count, listing) + } +} diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index d604eb14..7f12d3fc 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -185,6 +185,8 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T if originSessionKey != "" { meta["origin_session_key"] = originSessionKey } + // Pass leader agent ID so member agents can fallback-read leader's memory. + meta["leader_agent_id"] = team.LeadAgentID.String() // Pass the team workspace dir so member agents write files to the shared folder. if ws := taskTeamWorkspace(task); ws != "" { meta["team_workspace"] = ws