diff --git a/.changeset/fn-8208-send-message-recipient-validation.md b/.changeset/fn-8208-send-message-recipient-validation.md new file mode 100644 index 0000000000..3a9b5e1bfc --- /dev/null +++ b/.changeset/fn-8208-send-message-recipient-validation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reject messages addressed to nonexistent agent recipients. +category: fix +dev: fn_send_message now validates agent recipients through the async AgentStore lookup before delivery. diff --git a/packages/dashboard/src/__tests__/chat-send-message-agentstore-wiring.test.ts b/packages/dashboard/src/__tests__/chat-send-message-agentstore-wiring.test.ts new file mode 100644 index 0000000000..d21d24e870 --- /dev/null +++ b/packages/dashboard/src/__tests__/chat-send-message-agentstore-wiring.test.ts @@ -0,0 +1,11 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +describe("dashboard chat send-message registration", () => { + it("forwards ChatManager's AgentStore to the messaging tool factory", async () => { + const source = await readFile(fileURLToPath(new URL("../chat.ts", import.meta.url)), "utf8"); + + expect(source).toContain("createSendMessageTool(this.messageStore, agent.id, { agentStore: this.agentStore })"); + }); +}); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 62fc794fa3..8f5f865ed4 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -2375,7 +2375,7 @@ export class ChatManager { const messagingTools = agent?.id && this.messageStore ? [ - createSendMessageTool(this.messageStore, agent.id), + createSendMessageTool(this.messageStore, agent.id, { agentStore: this.agentStore }), createReadMessagesTool(this.messageStore, agent.id), ] : []; diff --git a/packages/engine/src/__tests__/agent-tools-send-message-recipient-validation.test.ts b/packages/engine/src/__tests__/agent-tools-send-message-recipient-validation.test.ts new file mode 100644 index 0000000000..fad1d8ed5a --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-send-message-recipient-validation.test.ts @@ -0,0 +1,116 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import { createSendMessageTool } from "../agent-tools.js"; + +function firstText(result: { content: Array<{ type: string; text?: string }> }): string { + const first = result.content[0]; + return first?.type === "text" ? (first.text ?? "") : ""; +} + +function createMessageStoreHarness() { + const wakeSpy = vi.fn(); + const sendMessage = vi.fn(async (input: Record) => { + if (input.toType === "agent") { + await wakeSpy(input); + } + return { id: "msg-1" }; + }); + return { messageStore: { sendMessage }, sendMessage, wakeSpy }; +} + +async function executeSend( + tool: ReturnType, + params: Record, +) { + return tool.execute("1", params as never, undefined, undefined, {}); +} + +describe("createSendMessageTool recipient validation", () => { + it("rejects a missing agent before persistence or wake", async () => { + const { messageStore, sendMessage, wakeSpy } = createMessageStoreHarness(); + const agentStore = { getAgent: vi.fn().mockResolvedValue(null) }; + const tool = createSendMessageTool(messageStore as never, "agent-a", { agentStore: agentStore as never }); + + const result = await executeSend(tool, { to_id: "agent-does-not-exist", content: "hello", type: "agent-to-agent" }); + + expect(firstText(result as never)).toMatch(/^ERROR: Recipient agent 'agent-does-not-exist' does not exist/); + expect(agentStore.getAgent).toHaveBeenCalledWith("agent-does-not-exist"); + expect(sendMessage).not.toHaveBeenCalled(); + expect(wakeSpy).not.toHaveBeenCalled(); + }); + + it("uses async getAgent and still sends and wakes an existing recipient", async () => { + const { messageStore, sendMessage, wakeSpy } = createMessageStoreHarness(); + // Mimics PostgreSQL mode: only async getAgent is available; there is no sync cache. + const agentStore = { getAgent: vi.fn().mockResolvedValue({ id: "agent-b" }) }; + const tool = createSendMessageTool(messageStore as never, "agent-a", { agentStore: agentStore as never }); + + const result = await executeSend(tool, { to_id: "agent-b", content: "hello" }); + + expect(firstText(result as never)).toContain("Message sent to agent-b"); + expect(agentStore.getAgent).toHaveBeenCalledWith("agent-b"); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(wakeSpy).toHaveBeenCalledTimes(1); + }); + + it("does not resolve user recipients and preserves no-resolver behavior", async () => { + const userHarness = createMessageStoreHarness(); + const agentStore = { getAgent: vi.fn() }; + const userTool = createSendMessageTool(userHarness.messageStore as never, "agent-a", { agentStore: agentStore as never }); + + const userResult = await executeSend(userTool, { to_id: "dashboard-user", content: "hello", type: "agent-to-user" }); + + expect(firstText(userResult as never)).toContain("Message sent to dashboard-user"); + expect(agentStore.getAgent).not.toHaveBeenCalled(); + expect(userHarness.sendMessage).toHaveBeenCalledTimes(1); + + const legacyHarness = createMessageStoreHarness(); + const legacyTool = createSendMessageTool(legacyHarness.messageStore as never, "agent-a"); + const legacyResult = await executeSend(legacyTool, { to_id: "unknown-agent", content: "hello" }); + expect(firstText(legacyResult as never)).toContain("Message sent to unknown-agent"); + expect(legacyHarness.sendMessage).toHaveBeenCalledTimes(1); + + expect(firstText(await executeSend(legacyTool, { to_id: "agent-b", content: " " }) as never)).toBe("ERROR: Message content cannot be empty"); + expect(firstText(await executeSend(legacyTool, { to_id: "agent-b", content: "hello", reply_to_message_id: " " }) as never)).toBe("ERROR: reply_to_message_id must be a non-empty string"); + }); + + it("does not report delivery when recipient validation is unavailable", async () => { + const failedLookupHarness = createMessageStoreHarness(); + const failedLookupTool = createSendMessageTool(failedLookupHarness.messageStore as never, "agent-a", { + agentStore: { getAgent: vi.fn().mockRejectedValue(new Error("database unavailable")) } as never, + }); + + const result = await executeSend(failedLookupTool, { to_id: "agent-b", content: "hello" }); + expect(firstText(result as never)).toMatch(/^ERROR: Recipient agent 'agent-b' could not be validated/); + expect(failedLookupHarness.sendMessage).not.toHaveBeenCalled(); + expect(failedLookupHarness.wakeSpy).not.toHaveBeenCalled(); + }); + + it("rejects an undefined recipient lookup result", async () => { + const { messageStore, sendMessage, wakeSpy } = createMessageStoreHarness(); + const tool = createSendMessageTool(messageStore as never, "agent-a", { + agentStore: { getAgent: vi.fn().mockResolvedValue(undefined) } as never, + }); + + const result = await executeSend(tool, { to_id: "agent-does-not-exist", content: "hello" }); + expect(firstText(result as never)).toMatch(/^ERROR: Recipient agent 'agent-does-not-exist' does not exist/); + expect(sendMessage).not.toHaveBeenCalled(); + expect(wakeSpy).not.toHaveBeenCalled(); + }); +}); + +describe("send-message registration wiring", () => { + it("forwards the in-scope AgentStore at every engine registration", async () => { + const [executor, stepSessionExecutor, heartbeat] = await Promise.all([ + readFile(fileURLToPath(new URL("../executor.ts", import.meta.url)), "utf8"), + readFile(fileURLToPath(new URL("../step-session-executor.ts", import.meta.url)), "utf8"), + readFile(fileURLToPath(new URL("../agent-heartbeat.ts", import.meta.url)), "utf8"), + ]); + + expect(executor).toContain("createSendMessageTool(this.options.messageStore, assignedAgentId, { autoRecovery: settings.autoRecovery, runAudit: audit, taskStore: this.store, settings, agentStore: this.options.agentStore })"); + expect(stepSessionExecutor).toContain("createSendMessageTool(this.options.messageStore, taskDetail.assignedAgentId, { autoRecovery: settings.autoRecovery, taskStore: this.options.store!, settings, agentStore: this.options.agentStore })"); + expect(heartbeat.match(/createSendMessageTool\(this\.messageStore, agentId, \{ agentStore: this\.store \}\)/g)).toHaveLength(1); + expect(heartbeat.match(/createSendMessageTool\(messageStore, agentId, \{ agentStore: this\.store \}\)/g)).toHaveLength(1); + }); +}); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index af9647f96a..fa434c38d5 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -2503,7 +2503,7 @@ export class HeartbeatMonitor { // Messaging tools — when MessageStore is available if (this.messageStore) { - heartbeatTools.push(createSendMessageTool(this.messageStore, agentId)); + heartbeatTools.push(createSendMessageTool(this.messageStore, agentId, { agentStore: this.store })); heartbeatTools.push(createReadMessagesTool(this.messageStore, agentId)); } if (this.chatStore) { @@ -3764,7 +3764,7 @@ export class HeartbeatMonitor { // Messaging tools — when MessageStore is available, agents can send and receive messages if (messageStore) { - tools.push(createSendMessageTool(messageStore, agentId)); + tools.push(createSendMessageTool(messageStore, agentId, { agentStore: this.store })); tools.push(createReadMessagesTool(messageStore, agentId)); } if (this.chatStore) { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 9ffbc682e5..b5924d9687 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -4214,7 +4214,7 @@ export function createAskQuestionTool(): ToolDefinition { export function createSendMessageTool( messageStore: MessageStore, fromAgentId: string, - options?: { autoRecovery?: ProjectSettings["autoRecovery"]; runAudit?: RunAuditor; taskStore?: TaskStore; settings?: Settings }, + options?: { autoRecovery?: ProjectSettings["autoRecovery"]; runAudit?: RunAuditor; taskStore?: TaskStore; settings?: Settings; agentStore?: AgentStore }, ): ToolDefinition { const deliveryHandler = new MessageDeliveryAutoRecoveryHandler({ runAudit: options?.runAudit ?? { database: async () => {}, git: async () => {}, filesystem: async () => {}, sandbox: async () => {} }, @@ -4261,6 +4261,28 @@ export function createSendMessageTool( }; } + /* + FNXC:AgentMessaging 2026-07-28-12:10: + Agent-to-agent sends must reject missing recipients rather than store an unread, undeliverable message and report false delivery success. Use async getAgent instead of getCachedAgent because the synchronous cache always returns null in PostgreSQL mode. A lookup failure is validation-unavailable and must block the send; only a successful lookup may establish delivery confidence. + */ + if (recipient.type === "agent" && options?.agentStore) { + let resolvedRecipient: Awaited> | undefined; + try { + resolvedRecipient = await options.agentStore.getAgent(recipient.id); + } catch { + return { + content: [{ type: "text" as const, text: `ERROR: Recipient agent '${params.to_id}' could not be validated — message not sent` }], + details: {}, + }; + } + if (resolvedRecipient == null) { + return { + content: [{ type: "text" as const, text: `ERROR: Recipient agent '${params.to_id}' does not exist — message not sent` }], + details: {}, + }; + } + } + const result = await deliveryHandler.runWithBoundedRetry({ run: async () => messageStore.sendMessage({ fromId: fromAgentId, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index c847f1b1b0..9d024b6de3 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -11674,7 +11674,7 @@ export class TaskExecutor { ] : []), // Messaging tools — allows executor agents to send and receive messages. ...(this.options.messageStore && assignedAgentId ? [ - createSendMessageTool(this.options.messageStore, assignedAgentId, { autoRecovery: settings.autoRecovery, runAudit: audit, taskStore: this.store, settings }), + createSendMessageTool(this.options.messageStore, assignedAgentId, { autoRecovery: settings.autoRecovery, runAudit: audit, taskStore: this.store, settings, agentStore: this.options.agentStore }), createReadMessagesTool(this.options.messageStore, assignedAgentId), ] : []), // Add plugin tools from PluginRunner diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 58bde02618..c14cea0b30 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -1321,7 +1321,7 @@ export class StepSessionExecutor { const messagingTools = this.options.messageStore && taskDetail.assignedAgentId ? [ - createSendMessageTool(this.options.messageStore, taskDetail.assignedAgentId, { autoRecovery: settings.autoRecovery, taskStore: this.options.store!, settings }), + createSendMessageTool(this.options.messageStore, taskDetail.assignedAgentId, { autoRecovery: settings.autoRecovery, taskStore: this.options.store!, settings, agentStore: this.options.agentStore }), createReadMessagesTool(this.options.messageStore, taskDetail.assignedAgentId), ] : [];