Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/cruel-trains-cut.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/core/src/agent/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/memory/manager/memory-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -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);

Expand Down
119 changes: 102 additions & 17 deletions packages/core/src/memory/manager/memory-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
: undefined;

if (existingMetadata?.operationId === operationId) {
const nextMetadata: Record<string, unknown> = { ...(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<string, unknown>;
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<string, unknown>)
: 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[],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"}`,
Expand Down Expand Up @@ -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}`,
{
Expand Down Expand Up @@ -769,10 +857,7 @@ export class MemoryManager {
/**
* Clear working memory
*/
async clearWorkingMemory(params: {
conversationId?: string;
userId?: string;
}): Promise<void> {
async clearWorkingMemory(params: { conversationId?: string; userId?: string }): Promise<void> {
if (!this.conversationMemory) {
return;
}
Expand Down
2 changes: 2 additions & 0 deletions website/docs/agents/memory/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions website/docs/agents/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down