feat(FN-5425): add coordination notice system for multi-agent room task fil
Adds a `room-coordination.ts` helper module for multi-agent coordination messaging, wires coordination notices into the agent heartbeat path, and ships tests plus documentation for the feature. A changeset prepares the `@runfusion/fusion` package for release. Fusion-Task-Id: FN-5425
This commit is contained in:
committed by
gsxdsm
parent
5c15031416
commit
6fdfb1ad5c
@@ -109,7 +109,11 @@ exports[`agent-heartbeat procedure templates > keeps strict no-task procedure st
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -147,7 +151,11 @@ exports[`agent-heartbeat procedure templates > keeps strict task procedure stabl
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStore, ChatStore, TaskStore } from "@fusion/core";
|
||||
import { HeartbeatMonitor } from "../agent-heartbeat.js";
|
||||
import * as roomCoordination from "../room-coordination.js";
|
||||
|
||||
const sessionCapture = vi.hoisted(() => ({
|
||||
prompt: "",
|
||||
@@ -283,6 +284,176 @@ describe("heartbeat room messages", () => {
|
||||
expect(sessionCapture.prompt).not.toContain("Resolved Referent:");
|
||||
});
|
||||
|
||||
describe("multi-agent room coordination (FN-5425)", () => {
|
||||
async function seedMultiAgentRoom(
|
||||
localHarness: Harness,
|
||||
{ peerAgentId = "agent-peer", roomName }: { peerAgentId?: string; roomName: string },
|
||||
): Promise<{ room: ReturnType<ChatStore["createRoom"]>; peerAgentId: string }> {
|
||||
const peerAgent = await localHarness.agentStore.createAgent({
|
||||
name: peerAgentId,
|
||||
role: "executor",
|
||||
soul: "Peer room member",
|
||||
runtimeConfig: { enabled: true },
|
||||
});
|
||||
const room = localHarness.chatStore.createRoom({ name: roomName, memberAgentIds: [localHarness.agentId] });
|
||||
localHarness.chatStore.addRoomMember(room.id, peerAgent.id);
|
||||
return { room, peerAgentId: peerAgent.id };
|
||||
}
|
||||
|
||||
it("renders claim branch and emits coordination audit in multi-agent room", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-claim" });
|
||||
const userMessage = harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "user",
|
||||
content: "please file a task for the secrets-sync regression",
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Coordination Notices:");
|
||||
expect(sessionCapture.prompt).toContain("branch: claim");
|
||||
expect(sessionCapture.prompt).toContain("Claiming:");
|
||||
expect(sessionCapture.prompt).toContain("fn_task_create");
|
||||
expect(sessionCapture.prompt).toContain("fn_post_room_message");
|
||||
expect(sessionCapture.prompt).toContain("FN-4918");
|
||||
|
||||
const event = harness.taskStore
|
||||
.getRunAuditEvents({ runId: run!.id })
|
||||
.find((auditEvent) => auditEvent.mutationType === "room:coordination:branch" && auditEvent.target === userMessage.id);
|
||||
expect(event?.metadata).toMatchObject({ branch: "claim" });
|
||||
});
|
||||
|
||||
it("renders defer branch when peer already claimed", async () => {
|
||||
harness = await createHarness();
|
||||
const { room, peerAgentId } = await seedMultiAgentRoom(harness, { roomName: "coord-defer-claim" });
|
||||
const priorClaim = harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: peerAgentId,
|
||||
content: "Claiming: filing task for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "user",
|
||||
content: "please file a task for the secrets-sync regression",
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
expect(sessionCapture.prompt).toContain("branch: defer-suggested");
|
||||
expect(sessionCapture.prompt).toContain(peerAgentId);
|
||||
expect(sessionCapture.prompt).toContain("Do NOT call fn_task_create");
|
||||
|
||||
const event = harness.taskStore
|
||||
.getRunAuditEvents({ runId: run!.id })
|
||||
.find((auditEvent) => auditEvent.mutationType === "room:coordination:branch");
|
||||
expect(event?.metadata).toMatchObject({ branch: "defer-suggested", priorClaimMessageId: priorClaim.id });
|
||||
});
|
||||
|
||||
it("captures prior task id from peer announcement", async () => {
|
||||
harness = await createHarness();
|
||||
const { room, peerAgentId } = await seedMultiAgentRoom(harness, { roomName: "coord-defer-task" });
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: peerAgentId,
|
||||
content: "Filed FN-9042 for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
expect(sessionCapture.prompt).toContain("FN-9042");
|
||||
const event = harness.taskStore.getRunAuditEvents({ runId: run!.id }).find((auditEvent) => auditEvent.mutationType === "room:coordination:branch");
|
||||
expect(event?.metadata).toMatchObject({ priorTaskId: "FN-9042" });
|
||||
});
|
||||
|
||||
it("does not render coordination notices for single-agent room", async () => {
|
||||
harness = await createHarness();
|
||||
const room = harness.chatStore.createRoom({ name: "single-agent", memberAgentIds: [harness.agentId] });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "file a task for X" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
expect(harness.taskStore.getRunAuditEvents({ runId: run!.id }).some((event) => event.mutationType === "room:coordination:branch")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not render coordination notices for non-task-filing content", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-non-intent" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "what do you think about the secrets-sync regression?" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
expect(harness.taskStore.getRunAuditEvents({ runId: run!.id }).some((event) => event.mutationType === "room:coordination:branch")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps deictic-only messages in ambiguity layer only", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-deictic-only" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "we should investigate the secrets-sync regression" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "yeah, create it" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Ambiguity Notices:");
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
});
|
||||
|
||||
it("does not defer to a self-authored prior claim", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-self-claim" });
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "assistant",
|
||||
senderAgentId: harness.agentId,
|
||||
content: "Claiming: filing task for the secrets-sync regression",
|
||||
});
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("branch: claim");
|
||||
expect(sessionCapture.prompt).not.toContain("branch: defer-suggested");
|
||||
});
|
||||
|
||||
it("fails open when coordination helper throws", async () => {
|
||||
harness = await createHarness();
|
||||
const { room } = await seedMultiAgentRoom(harness, { roomName: "coord-throw" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "please file a task for the secrets-sync regression" });
|
||||
const spy = vi.spyOn(roomCoordination, "decideRoomCoordination").mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store: harness.agentStore, taskStore: harness.taskStore, rootDir: harness.rootDir, chatStore: harness.chatStore });
|
||||
await expect(monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any })).resolves.toBeTruthy();
|
||||
expect(sessionCapture.prompt).not.toContain("Room Coordination Notices:");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it("registers fn_post_room_message and posts through the real ChatStore under restrictive policy", async () => {
|
||||
harness = await createHarness({
|
||||
presetId: "approval-required",
|
||||
|
||||
217
packages/engine/src/__tests__/room-coordination.test.ts
Normal file
217
packages/engine/src/__tests__/room-coordination.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { ChatRoomMember, ChatRoomMessage } from "@fusion/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
countActiveAgentMembers,
|
||||
decideRoomCoordination,
|
||||
detectTaskFilingIntent,
|
||||
renderRoomCoordinationPromptBlock,
|
||||
} from "../room-coordination.js";
|
||||
|
||||
function roomMember(agentId: string | null): ChatRoomMember {
|
||||
return {
|
||||
roomId: "room-1",
|
||||
// cast for test-only simulation of nullable rows from external callers
|
||||
agentId: agentId as unknown as string,
|
||||
role: "member",
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function roomMessage(id: string, content: string, senderAgentId: string | null = "agent-peer"): ChatRoomMessage {
|
||||
return {
|
||||
id,
|
||||
roomId: "room-1",
|
||||
role: senderAgentId ? "assistant" : "user",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
senderAgentId,
|
||||
mentions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("room-coordination", () => {
|
||||
describe("detectTaskFilingIntent", () => {
|
||||
it("detects explicit task-filing intent with extracted subject", () => {
|
||||
const result = detectTaskFilingIntent("please file a task for the secrets-sync regression");
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
expect(result.subject).toBe("the secrets-sync regression");
|
||||
expect(result.cues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Can you create a task to fix the broken merge?", "fix the broken merge"],
|
||||
["open a task about the new typecheck failure", "the new typecheck failure"],
|
||||
["track this as a task", null],
|
||||
["FILE A TASK: dashboard FAB regression.", "dashboard FAB regression"],
|
||||
["create a task for it", "it"],
|
||||
["create a task for FN-1234", "FN-1234"],
|
||||
])("handles positive variants: %s", (content, expectedSubject) => {
|
||||
const result = detectTaskFilingIntent(content);
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
if (expectedSubject === null) {
|
||||
expect([null, "this"]).toContain(result.subject);
|
||||
} else {
|
||||
expect(result.subject).toContain(expectedSubject);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file it",
|
||||
"create it now",
|
||||
"do that as a task",
|
||||
"this is a task list",
|
||||
"I filed the task report yesterday",
|
||||
"",
|
||||
" ",
|
||||
])("rejects non-intent content: %s", (content) => {
|
||||
const result = detectTaskFilingIntent(content);
|
||||
expect(result).toEqual({ isTaskFilingIntent: false, cues: [], subject: null });
|
||||
});
|
||||
|
||||
it("returns true for past-tense phrasing trade-off", () => {
|
||||
const result = detectTaskFilingIntent("I filed a task earlier");
|
||||
expect(result.isTaskFilingIntent).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects oversized content", () => {
|
||||
const result = detectTaskFilingIntent(`create a task for ${"x".repeat(1000)}`);
|
||||
expect(result).toEqual({ isTaskFilingIntent: false, cues: [], subject: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("countActiveAgentMembers", () => {
|
||||
it("counts unique active agent members", () => {
|
||||
expect(countActiveAgentMembers([roomMember("a1"), roomMember("a2"), roomMember("a3")])).toBe(3);
|
||||
});
|
||||
|
||||
it("deduplicates duplicate agent ids", () => {
|
||||
expect(countActiveAgentMembers([roomMember("a1"), roomMember("a1"), roomMember("a2")])).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores non-agent members", () => {
|
||||
expect(countActiveAgentMembers([roomMember(null), roomMember("a1")])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideRoomCoordination", () => {
|
||||
const detection = detectTaskFilingIntent("please file a task for secrets sync");
|
||||
|
||||
it("returns null for non-intent detection", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection: { isTaskFilingIntent: false, cues: [], subject: null },
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for single-agent rooms", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1")],
|
||||
recentMessages: [],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns claim when no prior peer claim exists", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m1", "hello", null)],
|
||||
pendingSenderAgentId: null,
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
expect(result?.priorClaimMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns defer-suggested with prior peer claim", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m1", "Claiming: filing task for secrets sync", "agent-peer")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("defer-suggested");
|
||||
expect(result?.priorClaimMessageId).toBe("m1");
|
||||
expect(result?.priorClaimSenderId).toBe("agent-peer");
|
||||
});
|
||||
|
||||
it("returns defer-suggested with prior task announcement", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m2", "Filed FN-9001 for the secrets-sync regression", "agent-peer")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("defer-suggested");
|
||||
expect(result?.priorTaskId).toBe("FN-9001");
|
||||
});
|
||||
|
||||
it("does not defer to self-authored claim", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("m3", "Claiming: filing task for secrets sync", "agent-main")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
});
|
||||
|
||||
it("skips pending message itself from prior lookup", () => {
|
||||
const result = decideRoomCoordination({
|
||||
detection,
|
||||
members: [roomMember("a1"), roomMember("a2")],
|
||||
recentMessages: [roomMessage("pending", "Claiming: filing task for secrets sync", "agent-main")],
|
||||
pendingSenderAgentId: "agent-main",
|
||||
});
|
||||
expect(result?.branch).toBe("claim");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderRoomCoordinationPromptBlock", () => {
|
||||
it("renders claim branch instructions with explicit guard references", () => {
|
||||
const lines = renderRoomCoordinationPromptBlock(
|
||||
{
|
||||
branch: "claim",
|
||||
memberCount: 3,
|
||||
detection: { isTaskFilingIntent: true, cues: ["file a task"], subject: "secrets-sync regression" },
|
||||
},
|
||||
{ id: "m5" },
|
||||
);
|
||||
|
||||
const block = lines.join("\n");
|
||||
expect(block).toContain("fn_post_room_message");
|
||||
expect(block).toContain("fn_task_create");
|
||||
expect(block).toContain("FN-4918");
|
||||
expect(block).toContain("FN-4829");
|
||||
expect(block).toContain("FN-5152");
|
||||
expect(block).toContain("FN-5220");
|
||||
expect(block).toContain("reply_to_message_id = m5");
|
||||
});
|
||||
|
||||
it("renders defer branch with peer, message, and prior task id", () => {
|
||||
const lines = renderRoomCoordinationPromptBlock(
|
||||
{
|
||||
branch: "defer-suggested",
|
||||
memberCount: 2,
|
||||
detection: { isTaskFilingIntent: true, cues: ["create a task"], subject: "secrets-sync regression" },
|
||||
priorClaimMessageId: "m4",
|
||||
priorClaimSenderId: "agent-peer",
|
||||
priorTaskId: "FN-9042",
|
||||
},
|
||||
{ id: "m6" },
|
||||
);
|
||||
|
||||
const block = lines.join("\n");
|
||||
expect(block).toContain("agent-peer");
|
||||
expect(block).toContain("message m4");
|
||||
expect(block).toContain("FN-9042");
|
||||
expect(block).toContain("reply_to_message_id = m6");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { buildSessionSkillContextSync } from "./session-skill-context.js";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
import { trimPromptMd, trimTaskDescription, trimTriggeringComments } from "./heartbeat-prompt-trim.js";
|
||||
import { detectDeicticReference, extractAntecedentCandidates, renderAmbiguityPromptBlock, scoreReferentConfidence } from "./room-ambiguity.js";
|
||||
import { countActiveAgentMembers, decideRoomCoordination, detectTaskFilingIntent, renderRoomCoordinationPromptBlock } from "./room-coordination.js";
|
||||
|
||||
const promptSizeLog = createLogger("prompt-size");
|
||||
|
||||
@@ -400,6 +401,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
4. If a Pending Room Messages section is present, review it too:
|
||||
- Use fn_post_room_message only when the room content is relevant to your role, soul, or identity.
|
||||
- If a Room Ambiguity Notices section is present, follow it exactly: echo resolved referents before acting, and under clarification notices do not create tasks.
|
||||
- If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message.
|
||||
- Reference room message IDs when replying so humans can trace context.
|
||||
5. After processing messages, continue with your normal heartbeat duties.
|
||||
|
||||
@@ -486,7 +488,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
- If the message is informational, acknowledge it and respond via fn_send_message when appropriate.
|
||||
- If the message requests work, create a follow-up task with fn_task_create.
|
||||
- If the request has a clear owner and fn_delegate_task is available, delegate it directly.
|
||||
3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly.
|
||||
3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly. If a Room Coordination Notices section is present, follow its claim/defer branch exactly: under "claim" post a one-line claim before calling fn_task_create; under "defer-suggested" do NOT call fn_task_create and instead acknowledge the prior claim via fn_post_room_message.
|
||||
4. After processing messages, continue with your ambient work.
|
||||
|
||||
Example flow:
|
||||
@@ -518,7 +520,11 @@ export const HEARTBEAT_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tic
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -618,7 +624,11 @@ export const HEARTBEAT_NO_TASK_PROCEDURE_STRICT = `## Heartbeat Procedure (run e
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
not create tasks under clarification notices. If a Room Coordination Notices
|
||||
section is present, follow its claim/defer branch exactly: under "claim" post
|
||||
a one-line claim before calling fn_task_create; under "defer-suggested" do
|
||||
NOT call fn_task_create and instead acknowledge the prior claim via
|
||||
fn_post_room_message.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -954,6 +964,86 @@ export class HeartbeatMonitor {
|
||||
return lines;
|
||||
}
|
||||
|
||||
private async getRoomCoordinationNoticesSection(
|
||||
agent: Agent,
|
||||
runId: string,
|
||||
entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>,
|
||||
audit: ReturnType<typeof createRunAuditor>,
|
||||
): Promise<string[]> {
|
||||
if (!this.chatStore || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const message of entry.messages) {
|
||||
try {
|
||||
const detection = detectTaskFilingIntent(message.content);
|
||||
if (!detection.isTaskFilingIntent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const members = this.chatStore.listRoomMembers(entry.room.id);
|
||||
if (countActiveAgentMembers(members) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roomTimeline = this.chatStore.getRoomMessages(entry.room.id, { limit: 100 });
|
||||
const messageIndex = roomTimeline.findIndex((roomMessage) => roomMessage.id === message.id);
|
||||
if (messageIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
// messageIndex === 0 means no prior messages; coordination correctly defaults to claim.
|
||||
const recentMessages = messageIndex === 0
|
||||
? []
|
||||
: roomTimeline.slice(Math.max(0, messageIndex - 15), messageIndex);
|
||||
|
||||
const decision = decideRoomCoordination({
|
||||
detection,
|
||||
members,
|
||||
recentMessages,
|
||||
pendingSenderAgentId: message.senderAgentId ?? agent.id,
|
||||
});
|
||||
if (!decision) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("", "Room Coordination Notices:");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`- [room: ${entry.room.name} (${entry.room.id})] [message: ${message.id}] [branch: ${decision.branch}]`,
|
||||
...renderRoomCoordinationPromptBlock(decision, message).map((line) => ` - ${line}`),
|
||||
);
|
||||
|
||||
await audit.database({
|
||||
type: "room:coordination:branch",
|
||||
target: message.id,
|
||||
metadata: {
|
||||
roomId: entry.room.id,
|
||||
agentId: agent.id,
|
||||
branch: decision.branch,
|
||||
memberCount: decision.memberCount,
|
||||
intentCue: detection.cues[0] ?? null,
|
||||
priorClaimMessageId: decision.priorClaimMessageId ?? null,
|
||||
priorTaskId: decision.priorTaskId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
heartbeatLog.log(
|
||||
`[room-coordination] agent=${agent.id} run=${runId} room=${entry.room.id} messageId=${message.id} branch=${decision.branch} members=${decision.memberCount}`,
|
||||
);
|
||||
} catch (err) {
|
||||
heartbeatLog.warn(`Room coordination notice failed for ${message.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private async getPendingRoomMessages(agent: Agent, sinceIso: string): Promise<{
|
||||
entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>;
|
||||
total: number;
|
||||
@@ -2493,6 +2583,12 @@ export class HeartbeatMonitor {
|
||||
pendingRoomMessages.entries,
|
||||
audit,
|
||||
);
|
||||
const roomCoordinationNoticesLines = await this.getRoomCoordinationNoticesSection(
|
||||
agent,
|
||||
run.id,
|
||||
pendingRoomMessages.entries,
|
||||
audit,
|
||||
);
|
||||
|
||||
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||
if (this.messageStore) {
|
||||
@@ -2679,6 +2775,7 @@ export class HeartbeatMonitor {
|
||||
...pendingMessagesLines,
|
||||
...pendingRoomMessagesLines,
|
||||
...roomAmbiguityNoticesLines,
|
||||
...roomCoordinationNoticesLines,
|
||||
"",
|
||||
"Your soul, instructions, and memory are already loaded in the system prompt.",
|
||||
"Focus on work that benefits the project without requiring a specific task context.",
|
||||
@@ -2767,6 +2864,7 @@ export class HeartbeatMonitor {
|
||||
...pendingMessagesLines,
|
||||
...pendingRoomMessagesLines,
|
||||
...roomAmbiguityNoticesLines,
|
||||
...roomCoordinationNoticesLines,
|
||||
...(reportsHealthSection ? ["", reportsHealthSection] : []),
|
||||
"",
|
||||
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
|
||||
|
||||
205
packages/engine/src/room-coordination.ts
Normal file
205
packages/engine/src/room-coordination.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { ChatRoomMember, ChatRoomMessage } from "@fusion/core";
|
||||
|
||||
/**
|
||||
* FN-5425: prompt-layer multi-agent coordination advisory for explicit task-filing room messages.
|
||||
* This module stays deterministic and fail-open: it only detects intent + suggests claim/defer
|
||||
* prompt branches, while authoritative duplicate prevention remains in intake safeguards.
|
||||
*/
|
||||
export interface TaskFilingIntentDetection {
|
||||
isTaskFilingIntent: boolean;
|
||||
cues: string[];
|
||||
subject: string | null;
|
||||
}
|
||||
|
||||
export interface RoomCoordinationDecision {
|
||||
branch: "claim" | "defer-suggested";
|
||||
memberCount: number;
|
||||
detection: TaskFilingIntentDetection;
|
||||
priorClaimMessageId?: string;
|
||||
priorClaimSenderId?: string | null;
|
||||
priorTaskId?: string;
|
||||
}
|
||||
|
||||
export interface RenderCoordinationOptions {
|
||||
deicticMessageId?: never;
|
||||
pendingMessageId: string;
|
||||
}
|
||||
|
||||
const MAX_INTENT_CONTENT_LENGTH = 800;
|
||||
const MAX_SUBJECT_LENGTH = 140;
|
||||
const DEICTIC_NOUN_FOLLOWUP_PATTERN = /\b(?:it|that|this)\s+(?:as|for|to)\b/i;
|
||||
const TASK_ID_PATTERN = /\b(FN-\d{1,6})\b/i;
|
||||
const PRIOR_CLAIM_RE = /^\s*claiming[:-]/i;
|
||||
const TASK_ANNOUNCED_RE = /\b(?:filed|created|opened|tracked|added)\b[\s\S]{0,60}?\b(FN-\d{1,6})\b|\b(FN-\d{1,6})\b[\s\S]{0,60}?\b(?:filed|created|opened|tracked|added)\b/i;
|
||||
|
||||
const TASK_CONTEXT_SUFFIX = "(?=$|[.?!,:;-]|\\s+(?:for|about|to|on|regarding)\\b)";
|
||||
|
||||
const TASK_FILING_CUES: ReadonlyArray<[string, RegExp]> = [
|
||||
["file a task", new RegExp(`\\bfile\\w*\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["filed a task", /\bfiled\s+(?:a\s+|the\s+)?task\b/i],
|
||||
["create a task", new RegExp(`\\bcreate\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["open a task", new RegExp(`\\bopen\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["add a task", new RegExp(`\\badd\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["track this/that as a task", new RegExp(`\\btrack\\s+(?:this|that)\\s+(?:as\\s+)?(?:a\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["start a task", new RegExp(`\\bstart\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
["make a task", new RegExp(`\\bmake\\s+(?:a\\s+|the\\s+)?task${TASK_CONTEXT_SUFFIX}`, "i")],
|
||||
];
|
||||
|
||||
function normalizeMessageContent(content: string): string {
|
||||
return content.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function truncateWithEllipsis(value: string, maxLength = MAX_SUBJECT_LENGTH): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function cleanExtractedSubject(subject: string): string {
|
||||
return truncateWithEllipsis(
|
||||
subject
|
||||
.replace(/^\s*["'`]+|["'`]+\s*$/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function extractSubject(normalized: string): string | null {
|
||||
const preferred = normalized.match(
|
||||
/(?:file\w*|create|open|add|track|start|make)\s+(?:a\s+|the\s+)?task\s+(?:for|about|to|on|regarding)\s+(.+?)(?:[.?!]|$)/i,
|
||||
);
|
||||
if (preferred?.[1]) {
|
||||
const cleaned = cleanExtractedSubject(preferred[1]);
|
||||
return cleaned || null;
|
||||
}
|
||||
|
||||
const fallback = normalized.match(
|
||||
/(?:file\w*|create|open|add|track|start|make)\s+(?:a\s+|the\s+)?task(?:\s+|\s*[:-]\s*)(.+?)(?:[.?!]|$)/i,
|
||||
);
|
||||
if (fallback?.[1]) {
|
||||
const cleaned = cleanExtractedSubject(fallback[1]);
|
||||
return cleaned || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectTaskFilingIntent(content: string): TaskFilingIntentDetection {
|
||||
const normalized = normalizeMessageContent(content);
|
||||
if (!normalized || normalized.length > MAX_INTENT_CONTENT_LENGTH) {
|
||||
return { isTaskFilingIntent: false, cues: [], subject: null };
|
||||
}
|
||||
|
||||
const cueMatches: Array<{ cue: string; index: number }> = [];
|
||||
for (const [cue, pattern] of TASK_FILING_CUES) {
|
||||
const match = pattern.exec(normalized);
|
||||
if (match?.index != null) {
|
||||
cueMatches.push({ cue, index: match.index });
|
||||
}
|
||||
}
|
||||
|
||||
if (cueMatches.length === 0) {
|
||||
return { isTaskFilingIntent: false, cues: [], subject: null };
|
||||
}
|
||||
|
||||
if (/\bfiled\s+(?:a\s+|the\s+)?task\s+report\b/i.test(normalized)) {
|
||||
return { isTaskFilingIntent: false, cues: [], subject: null };
|
||||
}
|
||||
|
||||
const earliestCueIndex = cueMatches.reduce((min, current) => Math.min(min, current.index), Number.POSITIVE_INFINITY);
|
||||
const deicticMatch = DEICTIC_NOUN_FOLLOWUP_PATTERN.exec(normalized);
|
||||
const deicticIndex = deicticMatch?.index ?? -1;
|
||||
if (deicticIndex >= 0 && deicticIndex < earliestCueIndex) {
|
||||
return { isTaskFilingIntent: false, cues: [], subject: null };
|
||||
}
|
||||
|
||||
const subject = extractSubject(normalized);
|
||||
|
||||
// Intentional trade-off: the advisory is harmless under occasional tense false positives,
|
||||
// and FN-5152/FN-5220 intake dedup remains the deterministic backstop.
|
||||
return {
|
||||
isTaskFilingIntent: true,
|
||||
cues: cueMatches.sort((a, b) => a.index - b.index).map((entry) => entry.cue),
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
export function countActiveAgentMembers(members: ChatRoomMember[]): number {
|
||||
// ChatStore.removeRoomMember hard-deletes rows, so listRoomMembers is already active-only.
|
||||
const uniqueAgentIds = new Set(members.map((member) => member.agentId).filter((agentId): agentId is string => Boolean(agentId)));
|
||||
return uniqueAgentIds.size;
|
||||
}
|
||||
|
||||
export function decideRoomCoordination(args: {
|
||||
detection: TaskFilingIntentDetection;
|
||||
members: ChatRoomMember[];
|
||||
recentMessages: ChatRoomMessage[];
|
||||
pendingSenderAgentId: string | null;
|
||||
}): RoomCoordinationDecision | null {
|
||||
const { detection, members, recentMessages, pendingSenderAgentId } = args;
|
||||
if (!detection.isTaskFilingIntent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberCount = countActiveAgentMembers(members);
|
||||
if (memberCount < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookback = recentMessages.slice(-15);
|
||||
let priorClaimMessageId: string | undefined;
|
||||
let priorClaimSenderId: string | null | undefined;
|
||||
let priorTaskId: string | undefined;
|
||||
|
||||
for (const message of lookback) {
|
||||
if (!message.senderAgentId) {
|
||||
continue;
|
||||
}
|
||||
if (pendingSenderAgentId && message.senderAgentId === pendingSenderAgentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const claimMatch = PRIOR_CLAIM_RE.test(message.content);
|
||||
const announcedMatch = TASK_ANNOUNCED_RE.exec(message.content);
|
||||
if (!claimMatch && !announcedMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
priorClaimMessageId = message.id;
|
||||
priorClaimSenderId = message.senderAgentId;
|
||||
const captured = announcedMatch?.[1] ?? announcedMatch?.[2] ?? message.content.match(TASK_ID_PATTERN)?.[1];
|
||||
priorTaskId = captured?.toUpperCase();
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
branch: priorClaimMessageId ? "defer-suggested" : "claim",
|
||||
memberCount,
|
||||
detection,
|
||||
priorClaimMessageId,
|
||||
priorClaimSenderId,
|
||||
priorTaskId,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderRoomCoordinationPromptBlock(
|
||||
decision: RoomCoordinationDecision,
|
||||
pendingMessage: Pick<ChatRoomMessage, "id">,
|
||||
): string[] {
|
||||
if (decision.branch === "claim") {
|
||||
return [
|
||||
`Multi-agent room (${decision.memberCount} agents). Before calling fn_task_create for this request, coordinate:`,
|
||||
`1. Post a ONE-LINE claim to the room first via fn_post_room_message: "Claiming: filing task for <subject>" (reply_to_message_id = ${pendingMessage.id}).`,
|
||||
"2. Then call fn_task_create. The deterministic / near-duplicate / explicit-marker intake guards (FN-4918 / FN-4829 / FN-5152 / FN-5220) are your authoritative backstop — do NOT pass acknowledgedDuplicates or bypassDuplicateCheck to silence a duplicate match for a room request.",
|
||||
`3. After fn_task_create returns, post the resulting FN-NNNN id back to the room via fn_post_room_message (reply_to_message_id = ${pendingMessage.id}).`,
|
||||
`Detected subject: ${decision.detection.subject ?? "(none extracted — restate the user's request in your claim)"}.`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`Multi-agent room (${decision.memberCount} agents). A peer agent (${decision.priorClaimSenderId ?? "unknown"}) already posted a claim or task announcement in this room (message ${decision.priorClaimMessageId}${decision.priorTaskId ? `, task ${decision.priorTaskId}` : ""}).`,
|
||||
`Do NOT call fn_task_create for this request. Reply once via fn_post_room_message (reply_to_message_id = ${pendingMessage.id}) acknowledging the existing claim${decision.priorTaskId ? ` and echoing ${decision.priorTaskId}` : ""}.`,
|
||||
"If you believe the peer's task does NOT cover this request, say so explicitly in your reply and wait for human disambiguation rather than filing in parallel.",
|
||||
];
|
||||
}
|
||||
@@ -307,7 +307,8 @@ export type DatabaseMutationType =
|
||||
* ```
|
||||
*/
|
||||
| "worktree:pool-double-lease-detected"
|
||||
| "room:ambiguity:branch";
|
||||
| "room:ambiguity:branch"
|
||||
| "room:coordination:branch";
|
||||
|
||||
// ── Filesystem mutation types ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user