mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-06 22:17:41 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user