feat(team): share leader memory with team members (read-only)

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.
This commit is contained in:
viettranx
2026-03-27 22:45:40 +07:00
parent e9342533e0
commit ee9f5b8aaf
10 changed files with 523 additions and 7 deletions
+1
View File
@@ -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,
+4
View File
@@ -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)
+1
View File
@@ -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
+1
View File
@@ -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
}
+21
View File
@@ -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 (
+12
View File
@@ -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")
+23 -4
View File
@@ -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))
+37 -3
View File
@@ -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
}
+421
View File
@@ -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)
}
}
+2
View File
@@ -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