feat(FN-2232): check mailbox on every heartbeat run
- Fetch unread inbox messages whenever messageStore is available, not only wake-on-message triggers - Include pending messages in execution prompts for task and no-task heartbeat runs across timer, assignment, and on-demand sources - Extend heartbeat messaging tests to cover run-wide mailbox prefetch and read-marking behavior - Document heartbeat mailbox checking semantics and messageResponseMode behavior in agents docs - Replace Header mailbox badge class with existing header-badge style token
This commit is contained in:
@@ -105,7 +105,7 @@ The `runtimeConfig` field on agents supports the following options:
|
|||||||
| `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) |
|
| `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) |
|
||||||
| `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) |
|
| `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) |
|
||||||
| `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent |
|
| `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent |
|
||||||
| `messageResponseMode` | `"immediate" \| "on-heartbeat"` | `"immediate"` | How the agent responds to messages |
|
| `messageResponseMode` | `"immediate" \| "on-heartbeat"` | `"immediate"` | Whether agent wakes immediately on message (immediate) or processes during heartbeat (on-heartbeat). See [Heartbeat Run Mailbox Checking](#heartbeat-run-mailbox-checking) |
|
||||||
| `modelProvider` | `string` | — | AI provider override for heartbeat session |
|
| `modelProvider` | `string` | — | AI provider override for heartbeat session |
|
||||||
| `modelId` | `string` | — | AI model ID override for heartbeat session |
|
| `modelId` | `string` | — | AI model ID override for heartbeat session |
|
||||||
| `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings |
|
| `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings |
|
||||||
@@ -284,6 +284,37 @@ fn message delete MSG-123
|
|||||||
fn agent mailbox AGENT-001
|
fn agent mailbox AGENT-001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Heartbeat Run Mailbox Checking
|
||||||
|
|
||||||
|
When messaging tools are enabled for an agent, heartbeat runs check for unread mailbox messages during execution regardless of the trigger type. This ensures agents can see and respond to incoming messages without needing an explicit wake-on-message trigger.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Message Prefetch**: When `messageStore` is available, heartbeat runs fetch up to 10 unread inbox messages for the agent
|
||||||
|
2. **Prompt Injection**: Pending messages are injected into the execution prompt with sender and timestamp information
|
||||||
|
3. **Mark as Read**: After successful heartbeat completion, messages are marked as read
|
||||||
|
4. **Failed Runs**: If the heartbeat execution fails, messages remain unread for retry on the next run
|
||||||
|
|
||||||
|
### Message Response Modes
|
||||||
|
|
||||||
|
The `messageResponseMode` runtime configuration controls when agents are triggered by incoming messages:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `immediate` | Agent wakes immediately when a message arrives (via hook callback) |
|
||||||
|
| `on-heartbeat` | Agent processes messages during normal heartbeat runs only |
|
||||||
|
|
||||||
|
**Important**: Both modes include messages in the execution prompt. The `immediate` mode additionally triggers an immediate heartbeat run when a message arrives, while `on-heartbeat` relies on the agent's next scheduled heartbeat.
|
||||||
|
|
||||||
|
### Message Visibility
|
||||||
|
|
||||||
|
- **Timer-triggered runs**: Check mailbox and include pending messages
|
||||||
|
- **Assignment-triggered runs**: Check mailbox and include pending messages
|
||||||
|
- **On-demand runs**: Check mailbox and include pending messages
|
||||||
|
- **Wake-on-message triggers**: Check mailbox and include pending messages (same as other triggers, but triggered immediately)
|
||||||
|
|
||||||
|
This ensures inter-agent and user-to-agent communication is visible to agents on each run, avoiding stale coordination, missed instructions, and delayed responses.
|
||||||
|
|
||||||
## Agent Spawning
|
## Agent Spawning
|
||||||
|
|
||||||
Executor sessions can spawn child agents through `spawn_agent`.
|
Executor sessions can spawn child agents through `spawn_agent`.
|
||||||
|
|||||||
@@ -85,28 +85,32 @@ describe("build-exe", () => {
|
|||||||
expect(existsSync(join(cliRoot, "dist", "package.json"))).toBe(false);
|
expect(existsSync(join(cliRoot, "dist", "package.json"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("binary runs --help without a co-located package.json", () => {
|
it(
|
||||||
const { binary, dir, cleanup } = createIsolatedDir();
|
"binary runs --help without a co-located package.json",
|
||||||
try {
|
() => {
|
||||||
// Verify no package.json in the isolated dir
|
const { binary, dir, cleanup } = createIsolatedDir();
|
||||||
expect(existsSync(join(dir, "package.json"))).toBe(false);
|
try {
|
||||||
|
// Verify no package.json in the isolated dir
|
||||||
|
expect(existsSync(join(dir, "package.json"))).toBe(false);
|
||||||
|
|
||||||
const result = spawnSync(binary, ["--help"], {
|
const result = spawnSync(binary, ["--help"], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
if (hasKnownBunSqliteLimitation(result)) {
|
if (hasKnownBunSqliteLimitation(result)) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout).toContain("fn — AI-orchestrated task board");
|
||||||
|
expect(result.stdout).toContain("dashboard");
|
||||||
|
expect(result.stdout).toContain("task create");
|
||||||
|
expect(result.stdout).toContain("task list");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
expect(result.status).toBe(0);
|
},
|
||||||
expect(result.stdout).toContain("fn — AI-orchestrated task board");
|
20_000,
|
||||||
expect(result.stdout).toContain("dashboard");
|
);
|
||||||
expect(result.stdout).toContain("task create");
|
|
||||||
expect(result.stdout).toContain("task list");
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("binary runs 'task list' without crashing", () => {
|
it("binary runs 'task list' without crashing", () => {
|
||||||
const { binary, cleanup } = createIsolatedDir();
|
const { binary, cleanup } = createIsolatedDir();
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ export function Header({
|
|||||||
>
|
>
|
||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
{mailboxUnreadCount > 0 && (
|
{mailboxUnreadCount > 0 && (
|
||||||
<span className="btn-icon-indicator" data-testid="header-mailbox-badge">
|
<span className="header-badge" data-testid="header-mailbox-badge">
|
||||||
{mailboxUnreadCount > 9 ? "9+" : mailboxUnreadCount}
|
{mailboxUnreadCount > 9 ? "9+" : mailboxUnreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1671,6 +1671,58 @@ describe("HeartbeatMonitor", () => {
|
|||||||
expect(toolNames).not.toContain("send_message");
|
expect(toolNames).not.toContain("send_message");
|
||||||
expect(toolNames).not.toContain("read_messages");
|
expect(toolNames).not.toContain("read_messages");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("identity agent without task fetches messages and includes them in prompt for timer trigger", async () => {
|
||||||
|
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
id: "msg-notask-1",
|
||||||
|
fromId: "user-1",
|
||||||
|
content: "Please check the task board",
|
||||||
|
}),
|
||||||
|
createMessage({
|
||||||
|
id: "msg-notask-2",
|
||||||
|
fromId: "agent-5",
|
||||||
|
content: "Delegating FN-100 to you",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const messageStore = {
|
||||||
|
setMessageToAgentHook: vi.fn(),
|
||||||
|
getInbox: vi.fn().mockReturnValue(messages),
|
||||||
|
markAllAsRead: vi.fn(),
|
||||||
|
} as unknown as MessageStore;
|
||||||
|
|
||||||
|
const monitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
messageStore,
|
||||||
|
taskStore: mockTaskStore,
|
||||||
|
rootDir: "/tmp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await monitor.executeHeartbeat({
|
||||||
|
agentId: "agent-001",
|
||||||
|
source: "timer",
|
||||||
|
triggerDetail: "scheduled",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
// Messages should be fetched for no-task runs too
|
||||||
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
||||||
|
// Messages should be marked as read after successful execution
|
||||||
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
||||||
|
|
||||||
|
// Verify execution prompt included the messages
|
||||||
|
const promptCalls = mockSession.prompt.mock.calls;
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0);
|
||||||
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||||
|
expect(executionPrompt).toContain("Pending Messages:");
|
||||||
|
expect(executionPrompt).toContain("Please check the task board");
|
||||||
|
expect(executionPrompt).toContain("Delegating FN-100 to you");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("blocked-task dedup", () => {
|
describe("blocked-task dedup", () => {
|
||||||
@@ -2087,14 +2139,22 @@ describe("HeartbeatMonitor", () => {
|
|||||||
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
|
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not fetch messages when not wake-on-message trigger", async () => {
|
it("fetches messages for timer-triggered runs when messageStore is available", async () => {
|
||||||
const store = createStoreWithAgentForExec();
|
const store = createStoreWithAgentForExec();
|
||||||
const mockSession = createMockAgentSession();
|
const mockSession = createMockAgentSession();
|
||||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "agent-2",
|
||||||
|
content: "Reminder about task FN-001",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const messageStore = {
|
const messageStore = {
|
||||||
setMessageToAgentHook: vi.fn(),
|
setMessageToAgentHook: vi.fn(),
|
||||||
getInbox: vi.fn(),
|
getInbox: vi.fn().mockReturnValue(messages),
|
||||||
markAllAsRead: vi.fn(),
|
markAllAsRead: vi.fn(),
|
||||||
} as unknown as MessageStore;
|
} as unknown as MessageStore;
|
||||||
|
|
||||||
@@ -2105,15 +2165,159 @@ describe("HeartbeatMonitor", () => {
|
|||||||
rootDir: "/tmp",
|
rootDir: "/tmp",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use a regular trigger (not wake-on-message)
|
// Use a timer trigger (not wake-on-message)
|
||||||
await monitor.executeHeartbeat({
|
const result = await monitor.executeHeartbeat({
|
||||||
agentId: "agent-001",
|
agentId: "agent-001",
|
||||||
source: "timer",
|
source: "timer",
|
||||||
triggerDetail: "scheduled",
|
triggerDetail: "scheduled",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(messageStore.getInbox).not.toHaveBeenCalled();
|
expect(result.status).toBe("completed");
|
||||||
expect(messageStore.markAllAsRead).not.toHaveBeenCalled();
|
// Messages should be fetched even for timer triggers
|
||||||
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
||||||
|
// Messages should be marked as read after successful execution
|
||||||
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
||||||
|
|
||||||
|
// Verify execution prompt included the messages
|
||||||
|
const promptCalls = mockSession.prompt.mock.calls;
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0);
|
||||||
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||||
|
expect(executionPrompt).toContain("Pending Messages:");
|
||||||
|
expect(executionPrompt).toContain("Reminder about task FN-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches messages for assignment-triggered runs when messageStore is available", async () => {
|
||||||
|
const store = createStoreWithAgentForExec();
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
id: "msg-assign-1",
|
||||||
|
fromId: "user-1",
|
||||||
|
content: "Please work on this task",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const messageStore = {
|
||||||
|
setMessageToAgentHook: vi.fn(),
|
||||||
|
getInbox: vi.fn().mockReturnValue(messages),
|
||||||
|
markAllAsRead: vi.fn(),
|
||||||
|
} as unknown as MessageStore;
|
||||||
|
|
||||||
|
const monitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
messageStore,
|
||||||
|
taskStore: mockTaskStore,
|
||||||
|
rootDir: "/tmp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use an assignment trigger
|
||||||
|
const result = await monitor.executeHeartbeat({
|
||||||
|
agentId: "agent-001",
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "task-assigned",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
||||||
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
||||||
|
|
||||||
|
// Verify execution prompt included the messages
|
||||||
|
const promptCalls = mockSession.prompt.mock.calls;
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0);
|
||||||
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||||
|
expect(executionPrompt).toContain("Pending Messages:");
|
||||||
|
expect(executionPrompt).toContain("Please work on this task");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches messages for on-demand runs without wake-on-message trigger", async () => {
|
||||||
|
const store = createStoreWithAgentForExec();
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
id: "msg-od-1",
|
||||||
|
fromId: "agent-3",
|
||||||
|
content: "Status update: task FN-002 is complete",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const messageStore = {
|
||||||
|
setMessageToAgentHook: vi.fn(),
|
||||||
|
getInbox: vi.fn().mockReturnValue(messages),
|
||||||
|
markAllAsRead: vi.fn(),
|
||||||
|
} as unknown as MessageStore;
|
||||||
|
|
||||||
|
const monitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
messageStore,
|
||||||
|
taskStore: mockTaskStore,
|
||||||
|
rootDir: "/tmp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use on-demand trigger without wake-on-message
|
||||||
|
const result = await monitor.executeHeartbeat({
|
||||||
|
agentId: "agent-001",
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
||||||
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
||||||
|
|
||||||
|
// Verify execution prompt included the messages
|
||||||
|
const promptCalls = mockSession.prompt.mock.calls;
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0);
|
||||||
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||||
|
expect(executionPrompt).toContain("Pending Messages:");
|
||||||
|
expect(executionPrompt).toContain("Status update: task FN-002 is complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fetches messages for wake-on-message triggers", async () => {
|
||||||
|
const store = createStoreWithAgentForExec();
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
createMessage({
|
||||||
|
id: "msg-wom-1",
|
||||||
|
fromId: "agent-2",
|
||||||
|
content: "Hello from agent-2",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const messageStore = {
|
||||||
|
setMessageToAgentHook: vi.fn(),
|
||||||
|
getInbox: vi.fn().mockReturnValue(messages),
|
||||||
|
markAllAsRead: vi.fn(),
|
||||||
|
} as unknown as MessageStore;
|
||||||
|
|
||||||
|
const monitor = new HeartbeatMonitor({
|
||||||
|
store,
|
||||||
|
messageStore,
|
||||||
|
taskStore: mockTaskStore,
|
||||||
|
rootDir: "/tmp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await monitor.executeHeartbeat({
|
||||||
|
agentId: "agent-001",
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "wake-on-message",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
expect(messageStore.getInbox).toHaveBeenCalledWith("agent-001", "agent", { read: false, limit: 10 });
|
||||||
|
expect(messageStore.markAllAsRead).toHaveBeenCalledWith("agent-001", "agent");
|
||||||
|
|
||||||
|
// Verify execution prompt included the messages
|
||||||
|
const promptCalls = mockSession.prompt.mock.calls;
|
||||||
|
expect(promptCalls.length).toBeGreaterThan(0);
|
||||||
|
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||||
|
expect(executionPrompt).toContain("Pending Messages:");
|
||||||
|
expect(executionPrompt).toContain("Hello from agent-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("end-to-end agent-to-agent message flow", () => {
|
describe("end-to-end agent-to-agent message flow", () => {
|
||||||
|
|||||||
@@ -1171,12 +1171,12 @@ export class HeartbeatMonitor {
|
|||||||
|
|
||||||
if (isNoTaskRun) {
|
if (isNoTaskRun) {
|
||||||
// No-task heartbeat: agent has identity but no assigned task
|
// No-task heartbeat: agent has identity but no assigned task
|
||||||
// Fetch unread messages when woken by message trigger
|
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||||
if (triggerDetail === "wake-on-message" && this.messageStore) {
|
if (this.messageStore) {
|
||||||
try {
|
try {
|
||||||
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
||||||
} catch (inboxErr) {
|
} catch (inboxErr) {
|
||||||
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
|
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId}: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,12 +1226,12 @@ export class HeartbeatMonitor {
|
|||||||
// Task-scoped heartbeat: agent has an assigned task
|
// Task-scoped heartbeat: agent has an assigned task
|
||||||
const taskTitle = taskDetail!.title ?? taskDetail!.description.slice(0, 100);
|
const taskTitle = taskDetail!.title ?? taskDetail!.description.slice(0, 100);
|
||||||
|
|
||||||
// Fetch unread messages when woken by message trigger
|
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||||
if (triggerDetail === "wake-on-message" && this.messageStore) {
|
if (this.messageStore) {
|
||||||
try {
|
try {
|
||||||
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
||||||
} catch (inboxErr) {
|
} catch (inboxErr) {
|
||||||
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
|
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId}: ${inboxErr instanceof Error ? inboxErr.message : String(inboxErr)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user