From 9a259c64aee276905a473730965384f8b4cc9631 Mon Sep 17 00:00:00 2001 From: Omer Aplak Date: Mon, 9 Feb 2026 19:34:52 -0800 Subject: [PATCH] fix: prevent delegated sub-agent messages from polluting supervisor memory context --- .changeset/cruel-trains-cut.md | 20 +++ packages/core/src/agent/subagent/index.ts | 5 + .../src/memory/manager/memory-manager.spec.ts | 63 ++++++++++ .../core/src/memory/manager/memory-manager.ts | 119 +++++++++++++++--- website/docs/agents/memory/overview.md | 2 + website/docs/agents/subagents.md | 1 + 6 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 .changeset/cruel-trains-cut.md diff --git a/.changeset/cruel-trains-cut.md b/.changeset/cruel-trains-cut.md new file mode 100644 index 000000000..2fc000750 --- /dev/null +++ b/.changeset/cruel-trains-cut.md @@ -0,0 +1,20 @@ +--- +"@voltagent/core": patch +--- + +fix: prevent delegated sub-agent messages from polluting supervisor memory context + +When a supervisor delegated work via `delegate_task`, the sub-agent used the same `conversationId` and persisted its own delegated input/output into that shared thread. On later turns, supervisor memory reads could include those delegated sub-agent messages, which could lead to duplicate/phantom prompts in the parent conversation context. + +### Previous behavior + +- Sub-agent delegated messages were persisted into the same conversation thread as the supervisor. +- Supervisor memory reads could load those delegated messages back into the parent prompt context. + +### New behavior + +- Delegated sub-agent messages are tagged with sub-agent metadata (`subAgentId`, `subAgentName`, `parentAgentId`). +- Parent memory reads now filter delegated sub-agent records from supervisor conversation context. +- `conversationId` behavior remains shared (no child/derived conversation IDs introduced). + +This keeps supervisor context clean in multi-turn handoff flows while preserving delegated records with metadata for observability/debugging. diff --git a/packages/core/src/agent/subagent/index.ts b/packages/core/src/agent/subagent/index.ts index 2ed79d573..86aa932ae 100644 --- a/packages/core/src/agent/subagent/index.ts +++ b/packages/core/src/agent/subagent/index.ts @@ -377,6 +377,11 @@ ${task}\n\nContext: ${safeStringify(contextObj, { indentation: 2 })}`; id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: taskContent }], + metadata: { + subAgentId: targetAgent.id, + subAgentName: targetAgent.name, + parentAgentId: sourceAgent?.id || parentAgentId, + }, }; // Combine shared context with the new task message diff --git a/packages/core/src/memory/manager/memory-manager.spec.ts b/packages/core/src/memory/manager/memory-manager.spec.ts index 3750e83d5..0dc8cf5d0 100644 --- a/packages/core/src/memory/manager/memory-manager.spec.ts +++ b/packages/core/src/memory/manager/memory-manager.spec.ts @@ -92,6 +92,32 @@ describe("MemoryManager", () => { expect(messages[0].id).toBe("msg-1"); }); + it("should attach delegation metadata for sub-agent writes", async () => { + const context = createMockOperationContext(); + context.parentAgentId = "supervisor-1"; + context.systemContext.set(Symbol("agent-metadata"), { + agentId: "sub-agent-1", + agentName: "Sub Agent", + }); + + const message = createTestUIMessage({ + id: "msg-sub-1", + role: "user", + parts: [{ type: "text", text: "delegated task" }], + }); + + await manager.saveMessage(context, message, "user-1", "conv-sub"); + + const messages = await memory.getMessages("user-1", "conv-sub"); + expect(messages).toHaveLength(1); + expect(messages[0].metadata).toMatchObject({ + operationId: "test-operation-id", + parentAgentId: "supervisor-1", + subAgentId: "sub-agent-1", + subAgentName: "Sub Agent", + }); + }); + it("should generate a title when creating a conversation", async () => { const context = createMockOperationContext(); context.input = "Plan a weekend trip to Rome."; @@ -161,6 +187,43 @@ describe("MemoryManager", () => { expect(messages[1].id).toBe("msg-2"); }); + it("should filter delegated sub-agent messages when reading parent conversation", async () => { + const parentContext = createMockOperationContext(); + + await manager.saveMessage( + parentContext, + createTestUIMessage({ + id: "msg-parent", + role: "user", + parts: [{ type: "text", text: "parent message" }], + }), + "user-1", + "conv-filter", + ); + + const delegatedContext = createMockOperationContext(); + delegatedContext.parentAgentId = "agent-1"; + delegatedContext.systemContext.set(Symbol("agent-metadata"), { + agentId: "sub-agent-1", + agentName: "Sub Agent", + }); + + await manager.saveMessage( + delegatedContext, + createTestUIMessage({ + id: "msg-sub", + role: "assistant", + parts: [{ type: "text", text: "sub-agent result" }], + }), + "user-1", + "conv-filter", + ); + + const visibleMessages = await manager.getMessages(parentContext, "user-1", "conv-filter"); + expect(visibleMessages).toHaveLength(1); + expect(visibleMessages[0].id).toBe("msg-parent"); + }); + it("should return empty array when memory is disabled", async () => { const disabledManager = new MemoryManager("agent-3", false); diff --git a/packages/core/src/memory/manager/memory-manager.ts b/packages/core/src/memory/manager/memory-manager.ts index 287fb0248..310b8e7a0 100644 --- a/packages/core/src/memory/manager/memory-manager.ts +++ b/packages/core/src/memory/manager/memory-manager.ts @@ -193,29 +193,109 @@ export class MemoryManager { } private applyOperationMetadata(message: UIMessage, context: OperationContext): UIMessage { - const operationId = context.operationId; - if (!operationId) { - return message; - } - const existingMetadata = typeof message.metadata === "object" && message.metadata !== null ? (message.metadata as Record) : undefined; - if (existingMetadata?.operationId === operationId) { + const nextMetadata: Record = { ...(existingMetadata ?? {}) }; + let changed = false; + + const operationId = context.operationId; + if (operationId && nextMetadata.operationId !== operationId) { + nextMetadata.operationId = operationId; + changed = true; + } + + const delegationMetadata = this.resolveDelegationMetadata(context); + if (delegationMetadata?.parentAgentId && nextMetadata.parentAgentId === undefined) { + nextMetadata.parentAgentId = delegationMetadata.parentAgentId; + changed = true; + } + if (delegationMetadata?.subAgentId && nextMetadata.subAgentId === undefined) { + nextMetadata.subAgentId = delegationMetadata.subAgentId; + changed = true; + } + if (delegationMetadata?.subAgentName && nextMetadata.subAgentName === undefined) { + nextMetadata.subAgentName = delegationMetadata.subAgentName; + changed = true; + } + + if (!changed) { return message; } return { ...message, - metadata: { - ...(existingMetadata ?? {}), - operationId, - }, + metadata: nextMetadata, }; } + private resolveDelegationMetadata( + context: OperationContext, + ): { parentAgentId: string; subAgentId?: string; subAgentName?: string } | undefined { + if (!context.parentAgentId) { + return undefined; + } + + let subAgentId: string | undefined; + let subAgentName: string | undefined; + + for (const value of context.systemContext.values()) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + + const record = value as Record; + if (typeof record.agentId === "string" && record.agentId.trim().length > 0) { + subAgentId = record.agentId; + } + if (typeof record.agentName === "string" && record.agentName.trim().length > 0) { + subAgentName = record.agentName; + } + + if (subAgentId || subAgentName) { + break; + } + } + + return { + parentAgentId: context.parentAgentId, + ...(subAgentId ? { subAgentId } : {}), + ...(subAgentName ? { subAgentName } : {}), + }; + } + + private filterDelegatedSubAgentMessages( + messages: UIMessage<{ createdAt: Date }>[], + ): UIMessage<{ createdAt: Date }>[] { + return messages.filter((message) => { + const metadata = + typeof message.metadata === "object" && message.metadata !== null + ? (message.metadata as Record) + : undefined; + if (!metadata) { + return true; + } + + const subAgentId = + typeof metadata.subAgentId === "string" && metadata.subAgentId.trim().length > 0 + ? metadata.subAgentId + : undefined; + const parentAgentId = + typeof metadata.parentAgentId === "string" && metadata.parentAgentId.trim().length > 0 + ? metadata.parentAgentId + : undefined; + + // Keep non-delegated records and delegated records from the current agent itself. + if (!subAgentId || !parentAgentId) { + return true; + } + + return subAgentId === this.resourceId; + }); + } + async saveConversationSteps( context: OperationContext, steps: ConversationStepRecord[], @@ -325,6 +405,8 @@ export class MemoryManager { } } + messages = this.filterDelegatedSubAgentMessages(messages); + // Log successful memory operation - PRESERVED memoryLogger.debug(`[Memory] Read successful (${messages.length} records)`, { event: LogEvents.MEMORY_OPERATION_COMPLETED, @@ -377,13 +459,17 @@ export class MemoryManager { context, // Pass OperationContext to Memory ); - memoryLogger.debug(`[Memory] Search successful (${messages.length} records)`, { + const filteredMessages = this.filterDelegatedSubAgentMessages( + messages as UIMessage<{ createdAt: Date }>[], + ); + + memoryLogger.debug(`[Memory] Search successful (${filteredMessages.length} records)`, { event: LogEvents.MEMORY_OPERATION_COMPLETED, operation: "search", - messages: messages.length, + messages: filteredMessages.length, }); - return messages; + return filteredMessages; } catch (error) { memoryLogger.error( `Memory search failed: ${error instanceof Error ? error.message : "Unknown error"}`, @@ -485,6 +571,8 @@ export class MemoryManager { context, // Pass OperationContext to Memory )) as UIMessage<{ createdAt: Date }>[]; + messages = this.filterDelegatedSubAgentMessages(messages); + context.logger.debug( `[Memory] Fetched messages from memory. Message Count: ${messages.length}`, { @@ -769,10 +857,7 @@ export class MemoryManager { /** * Clear working memory */ - async clearWorkingMemory(params: { - conversationId?: string; - userId?: string; - }): Promise { + async clearWorkingMemory(params: { conversationId?: string; userId?: string }): Promise { if (!this.conversationMemory) { return; } diff --git a/website/docs/agents/memory/overview.md b/website/docs/agents/memory/overview.md index 1660a364b..88890ce39 100644 --- a/website/docs/agents/memory/overview.md +++ b/website/docs/agents/memory/overview.md @@ -131,6 +131,8 @@ const agent3 = new Agent({ For stateless sub-agents, set `memory: false` on each sub-agent explicitly. +When a supervisor delegates to sub-agents, delegated messages are tagged with sub-agent metadata so supervisor memory reads can filter sub-agent records from parent conversation context. + ### Global Defaults (VoltAgent) Set default memory instances once at the VoltAgent entrypoint. Defaults apply only when an agent or workflow does not specify `memory`. If nothing is configured, VoltAgent still falls back to built-in in-memory storage. An explicit `memory: false` on an agent disables memory and bypasses defaults. diff --git a/website/docs/agents/subagents.md b/website/docs/agents/subagents.md index 783d4b2c0..9742e9e86 100644 --- a/website/docs/agents/subagents.md +++ b/website/docs/agents/subagents.md @@ -484,6 +484,7 @@ This tool is automatically added to supervisor agents and handles delegation. - **Execution**: - Finds the sub-agent instances based on the provided names - Calls the `handoffTask` (or `handoffToMultiple`) method internally + - Tags delegated sub-agent messages with metadata so supervisor memory reads can exclude sub-agent records - Passes the supervisor's agent ID (`parentAgentId`) and history entry ID (`parentHistoryEntryId`) for observability - **Returns**: - **Always returns an array** of result objects (even for single agent):