feat(FN-2051): merge fusion/fn-2051
This commit is contained in:
@@ -1250,6 +1250,222 @@ describe("HeartbeatMonitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Identity Agents Without Tasks ─────────────────────────────────────────────
|
||||
// FN-2051: Agents with identity (soul, instructions, memory) should run heartbeat
|
||||
// sessions even without a task assignment, enabling them to do ambient work like
|
||||
// messaging, memory management, task creation, and delegation.
|
||||
describe("identity agents without tasks", () => {
|
||||
it("agent WITH soul but no task creates session and completes successfully", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator agent who monitors project health" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
// Should create a session
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
// Reason should indicate identity run
|
||||
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
||||
});
|
||||
|
||||
it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
||||
});
|
||||
|
||||
it("agent WITH memory but no task creates session and completes successfully", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, memory: "Last week we shipped the new API. Watch for integration issues." });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
expect(result.resultJson).toEqual(expect.objectContaining({ reason: "no_assignment_identity_run" }));
|
||||
});
|
||||
|
||||
it("ephemeral agent with soul but no task still bails with no_assignment", async () => {
|
||||
// Ephemeral agents (agentKind: "task-worker") should NOT run no-task sessions
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: undefined,
|
||||
soul: "I am a task worker",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
// Ephemeral agents should NOT create a session
|
||||
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
|
||||
// Should still exit with no_assignment (not no_assignment_identity_run)
|
||||
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
||||
});
|
||||
|
||||
it("identity agent without task receives correct tools (task_create, list_agents, delegate_task, heartbeat_done)", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
||||
|
||||
// Should have task_create, list_agents, delegate_task
|
||||
expect(toolNames).toContain("task_create");
|
||||
expect(toolNames).toContain("list_agents");
|
||||
expect(toolNames).toContain("delegate_task");
|
||||
// Should have heartbeat_done
|
||||
expect(toolNames).toContain("heartbeat_done");
|
||||
// Should have memory tools
|
||||
expect(toolNames).toContain("memory_search");
|
||||
expect(toolNames).toContain("memory_append");
|
||||
|
||||
// Should NOT have task_log, task_document_write, task_document_read (they require taskId)
|
||||
expect(toolNames).not.toContain("task_log");
|
||||
expect(toolNames).not.toContain("task_document_write");
|
||||
expect(toolNames).not.toContain("task_document_read");
|
||||
});
|
||||
|
||||
it("identity agent without task receives no-task execution prompt mentioning 'no assigned task'", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
// The execution prompt is passed to session.prompt by promptWithFallback mock
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1]![0]!;
|
||||
|
||||
// Should mention no assigned task
|
||||
expect(executionPrompt).toContain("No assigned task");
|
||||
// Should describe ambient work capabilities
|
||||
expect(executionPrompt).toContain("ambient work");
|
||||
expect(executionPrompt).toContain("task_create");
|
||||
expect(executionPrompt).toContain("list_agents");
|
||||
expect(executionPrompt).toContain("delegate_task");
|
||||
// Should NOT include task-specific content
|
||||
expect(executionPrompt).not.toContain("Assigned task:");
|
||||
expect(executionPrompt).not.toContain("Task description:");
|
||||
});
|
||||
|
||||
it("identity agent without task gets soul in system prompt", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a CEO who prioritizes high-impact work" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
// Soul should be in the system prompt
|
||||
expect(callArgs.systemPrompt).toContain("## Soul");
|
||||
expect(callArgs.systemPrompt).toContain("I am a CEO who prioritizes high-impact work");
|
||||
});
|
||||
|
||||
it("agent WITHOUT identity (no soul, instructions, memory) still exits with no_assignment", async () => {
|
||||
// Agent with empty strings should also exit gracefully
|
||||
const store = createStoreWithAgentForExec({
|
||||
taskId: undefined,
|
||||
soul: "",
|
||||
instructionsText: "",
|
||||
memory: "",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe("completed");
|
||||
// Should NOT create a session for agents without identity
|
||||
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
|
||||
expect(result.resultJson).toEqual({ reason: "no_assignment" });
|
||||
});
|
||||
|
||||
it("identity agent without task includes messaging tools when messageStore is available", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const messageStore = {
|
||||
setMessageToAgentHook: vi.fn(),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
markAllAsRead: vi.fn(),
|
||||
} as unknown as MessageStore;
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store,
|
||||
messageStore,
|
||||
taskStore: mockTaskStore,
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
||||
|
||||
// Should have messaging tools when messageStore is available
|
||||
expect(toolNames).toContain("send_message");
|
||||
expect(toolNames).toContain("read_messages");
|
||||
});
|
||||
|
||||
it("identity agent without task does NOT include messaging tools when messageStore is unavailable", async () => {
|
||||
const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" });
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
|
||||
const callArgs = mockedCreateKbAgent.mock.calls[0]![0]!;
|
||||
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
|
||||
|
||||
// Should NOT have messaging tools when messageStore is not available
|
||||
expect(toolNames).not.toContain("send_message");
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocked-task dedup", () => {
|
||||
const buildContextHash = (blockedBy: string, taskDetail: Partial<TaskDetail>): string => {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions } from "@fusion/core";
|
||||
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
@@ -751,6 +751,10 @@ export class HeartbeatMonitor {
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
// Check if agent has identity (used later for no-task run decisions)
|
||||
const agentHasIdentity = hasAgentIdentity(agent);
|
||||
const isAgentEphemeral = isEphemeralAgent(agent);
|
||||
|
||||
// Resolve task assignment (explicit override → existing assignment → inbox-lite selection)
|
||||
let taskId = explicitTaskId ?? agent.taskId;
|
||||
let inboxSelection: InboxTask | null = null;
|
||||
@@ -802,103 +806,120 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
heartbeatLog.log(`Agent ${agentId} has no task assignment — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "no_assignment" },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
// Validate agent state
|
||||
const validStates = ["active", "running", "idle"];
|
||||
if (!validStates.includes(agent.state)) {
|
||||
heartbeatLog.log(`Agent ${agentId} state is "${agent.state}" — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "invalid_state", state: agent.state },
|
||||
skipStateTransition: true,
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
// Fetch task context
|
||||
let taskDetail: TaskDetail;
|
||||
try {
|
||||
taskDetail = await taskStore.getTask(taskId);
|
||||
} catch {
|
||||
heartbeatLog.warn(`Task ${taskId} not found — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "task_not_found", taskId },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
// Checkout enforcement: agent must hold the lease to work on this task.
|
||||
// The heartbeat only validates existing checkout state — it does NOT attempt
|
||||
// to acquire a checkout itself. The calling system (scheduler, API trigger)
|
||||
// is responsible for checking out the task before the heartbeat starts.
|
||||
if (taskDetail.checkedOutBy && taskDetail.checkedOutBy !== agentId) {
|
||||
heartbeatLog.warn(
|
||||
`Agent ${agentId} does not hold checkout for ${taskId} (held by ${taskDetail.checkedOutBy}) — graceful exit`
|
||||
);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: {
|
||||
reason: "checkout_conflict",
|
||||
taskId,
|
||||
checkedOutBy: taskDetail.checkedOutBy,
|
||||
},
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedBy = typeof taskDetail.blockedBy === "string" ? taskDetail.blockedBy.trim() : "";
|
||||
const isBlockedTask = taskDetail.status === "queued" && blockedBy.length > 0;
|
||||
|
||||
if (isBlockedTask) {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = taskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
|
||||
const contextHash = Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
|
||||
const currentBlockedState: BlockedStateSnapshot = {
|
||||
taskId,
|
||||
blockedBy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
contextHash,
|
||||
};
|
||||
|
||||
const previousBlockedState = await this.store.getLastBlockedState(agentId);
|
||||
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
|
||||
heartbeatLog.log(`Task ${taskId} is still blocked by ${blockedBy} (duplicate state) — skipping comment`);
|
||||
// Agents with identity (soul, instructions, memory) should run a full heartbeat
|
||||
// session even without a task, so they can do ambient work like messaging,
|
||||
// memory management, task creation, and delegation.
|
||||
// Ephemeral agents and agents without identity still exit gracefully.
|
||||
if (!agentHasIdentity || isAgentEphemeral) {
|
||||
heartbeatLog.log(`Agent ${agentId} has no task assignment — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked_duplicate", taskId, blockedBy },
|
||||
resultJson: { reason: "no_assignment" },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
heartbeatLog.log(`Agent ${agentId} has no task but has identity — running no-task heartbeat`);
|
||||
}
|
||||
const isNoTaskRun = !taskId;
|
||||
|
||||
// Validate agent state (only for task-scoped runs)
|
||||
if (!isNoTaskRun) {
|
||||
const validStates = ["active", "running", "idle"];
|
||||
if (!validStates.includes(agent.state)) {
|
||||
heartbeatLog.log(`Agent ${agentId} state is "${agent.state}" — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "invalid_state", state: agent.state },
|
||||
skipStateTransition: true,
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch task context (only for task-scoped runs)
|
||||
let taskDetail: TaskDetail | undefined;
|
||||
if (!isNoTaskRun) {
|
||||
// taskId is guaranteed to be defined here because isNoTaskRun = !taskId
|
||||
const resolvedTaskId = taskId!;
|
||||
try {
|
||||
taskDetail = await taskStore.getTask(resolvedTaskId);
|
||||
} catch {
|
||||
heartbeatLog.warn(`Task ${resolvedTaskId} not found — graceful exit`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "task_not_found", taskId: resolvedTaskId },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
|
||||
await taskStore.addComment(taskId, blockedMessage, "agent", undefined, runContext);
|
||||
// Audit trail: record comment mutation (FN-1404)
|
||||
await audit.database({ type: "task:comment:add", target: taskId, metadata: { blockedBy } });
|
||||
await this.store.setLastBlockedState(agentId, currentBlockedState);
|
||||
// Checkout enforcement: agent must hold the lease to work on this task.
|
||||
// The heartbeat only validates existing checkout state — it does NOT attempt
|
||||
// to acquire a checkout itself. The calling system (scheduler, API trigger)
|
||||
// is responsible for checking out the task before the heartbeat starts.
|
||||
if (taskDetail.checkedOutBy && taskDetail.checkedOutBy !== agentId) {
|
||||
heartbeatLog.warn(
|
||||
`Agent ${agentId} does not hold checkout for ${resolvedTaskId} (held by ${taskDetail.checkedOutBy}) — graceful exit`
|
||||
);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: {
|
||||
reason: "checkout_conflict",
|
||||
taskId: resolvedTaskId,
|
||||
checkedOutBy: taskDetail.checkedOutBy,
|
||||
},
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
heartbeatLog.log(`Task ${taskId} is blocked by ${blockedBy} — recorded blocked state`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked", taskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
const blockedBy = typeof taskDetail.blockedBy === "string" ? taskDetail.blockedBy.trim() : "";
|
||||
const isBlockedTask = taskDetail.status === "queued" && blockedBy.length > 0;
|
||||
|
||||
if (isBlockedTask) {
|
||||
const commentCount = (taskDetail.comments?.length ?? 0) + (taskDetail.steeringComments?.length ?? 0);
|
||||
const lastCommentId = taskDetail.comments?.at(-1)?.id;
|
||||
const lastSteeringCommentId = taskDetail.steeringComments?.at(-1)?.id;
|
||||
const contextHash = Buffer.from(
|
||||
JSON.stringify({ commentCount, lastCommentId, lastSteeringCommentId, blockedBy }),
|
||||
)
|
||||
.toString("base64")
|
||||
.slice(0, 16);
|
||||
|
||||
const currentBlockedState: BlockedStateSnapshot = {
|
||||
taskId: resolvedTaskId,
|
||||
blockedBy,
|
||||
recordedAt: new Date().toISOString(),
|
||||
contextHash,
|
||||
};
|
||||
|
||||
const previousBlockedState = await this.store.getLastBlockedState(agentId);
|
||||
if (previousBlockedState && isBlockedStateDuplicate(currentBlockedState, previousBlockedState)) {
|
||||
heartbeatLog.log(`Task ${resolvedTaskId} is still blocked by ${blockedBy} (duplicate state) — skipping comment`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked_duplicate", taskId: resolvedTaskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
|
||||
const blockedMessage = `Task is blocked by ${blockedBy}; waiting for dependency/context changes before retrying.`;
|
||||
await taskStore.addComment(resolvedTaskId, blockedMessage, "agent", undefined, runContext);
|
||||
// Audit trail: record comment mutation (FN-1404)
|
||||
await audit.database({ type: "task:comment:add", target: resolvedTaskId, metadata: { blockedBy } });
|
||||
await this.store.setLastBlockedState(agentId, currentBlockedState);
|
||||
|
||||
heartbeatLog.log(`Task ${resolvedTaskId} is blocked by ${blockedBy} — recorded blocked state`);
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "blocked", taskId: resolvedTaskId, blockedBy },
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
}
|
||||
|
||||
await this.store.clearLastBlockedState(agentId);
|
||||
// Clear blocked state when task is no longer blocked (only for task-scoped runs)
|
||||
if (!isNoTaskRun) {
|
||||
await this.store.clearLastBlockedState(agentId);
|
||||
}
|
||||
|
||||
// Track usage via callbacks
|
||||
const STDOUT_EXCERPT_LIMIT = 4000;
|
||||
@@ -940,8 +961,30 @@ export class HeartbeatMonitor {
|
||||
const { buildSessionSkillContextSync } = await import("./session-skill-context.js");
|
||||
|
||||
// Build tools with task creation tracking and run context for mutation correlation
|
||||
// Pass messageStore for messaging tools (send_message, read_messages)
|
||||
const heartbeatTools = this.createHeartbeatTools(agentId, taskStore, taskId, runContext, audit, this.messageStore);
|
||||
// For no-task runs, exclude task_log and document tools (they require a taskId)
|
||||
let heartbeatTools: ToolDefinition[];
|
||||
if (isNoTaskRun) {
|
||||
// No-task runs: task_create, list_agents, delegate_task, messaging, memory, heartbeat_done
|
||||
heartbeatTools = [];
|
||||
|
||||
// task_create tool (no tracking needed for no-task runs)
|
||||
heartbeatTools.push(createTaskCreateTool(taskStore));
|
||||
|
||||
// Agent delegation tools
|
||||
heartbeatTools.push(createListAgentsTool(this.store));
|
||||
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore));
|
||||
|
||||
// Messaging tools — when MessageStore is available
|
||||
if (this.messageStore) {
|
||||
heartbeatTools.push(createSendMessageTool(this.messageStore, agentId));
|
||||
heartbeatTools.push(createReadMessagesTool(this.messageStore, agentId));
|
||||
}
|
||||
} else {
|
||||
// Task-scoped runs: full tool set including task_log and document tools
|
||||
// taskId is guaranteed to be defined here because isNoTaskRun = !taskId
|
||||
heartbeatTools = this.createHeartbeatTools(agentId, taskStore, taskId!, runContext, audit, this.messageStore);
|
||||
}
|
||||
|
||||
let memorySettings: Settings | undefined;
|
||||
try {
|
||||
memorySettings = await getHeartbeatMemorySettings(taskStore);
|
||||
@@ -958,11 +1001,14 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
heartbeatTools.push(heartbeatDoneTool);
|
||||
|
||||
agentLogger = new AgentLogger({
|
||||
store: taskStore,
|
||||
taskId,
|
||||
agent: agent.role as AgentRole,
|
||||
});
|
||||
// AgentLogger requires a taskId — only create for task-scoped runs
|
||||
if (!isNoTaskRun && taskId) {
|
||||
agentLogger = new AgentLogger({
|
||||
store: taskStore,
|
||||
taskId,
|
||||
agent: agent.role as AgentRole,
|
||||
});
|
||||
}
|
||||
|
||||
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
|
||||
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
|
||||
@@ -1014,75 +1060,132 @@ export class HeartbeatMonitor {
|
||||
|
||||
try {
|
||||
// Build execution prompt
|
||||
const taskTitle = taskDetail.title ?? taskDetail.description.slice(0, 100);
|
||||
|
||||
// Fetch unread messages when woken by message trigger
|
||||
let pendingMessages: Message[] = [];
|
||||
if (triggerDetail === "wake-on-message" && this.messageStore) {
|
||||
try {
|
||||
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
||||
} catch {
|
||||
// Non-critical — if message fetch fails, proceed without messages
|
||||
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message`);
|
||||
}
|
||||
}
|
||||
let executionPrompt: string;
|
||||
|
||||
const triggeringCommentLines: string[] = [];
|
||||
if (effectiveTriggeringCommentIds && effectiveTriggeringCommentIds.length > 0) {
|
||||
const commentLookup = new Map<string, { author: string; text: string }>();
|
||||
for (const comment of taskDetail.comments ?? []) {
|
||||
commentLookup.set(comment.id, { author: comment.author, text: comment.text });
|
||||
}
|
||||
for (const steeringComment of taskDetail.steeringComments ?? []) {
|
||||
commentLookup.set(steeringComment.id, { author: steeringComment.author, text: steeringComment.text });
|
||||
}
|
||||
|
||||
const formatCommentText = (text: string): string => text.replace(/\s+/g, " ").trim();
|
||||
|
||||
for (const commentId of effectiveTriggeringCommentIds) {
|
||||
const comment = commentLookup.get(commentId);
|
||||
if (comment) {
|
||||
triggeringCommentLines.push(`- [${comment.author}]: "${formatCommentText(comment.text)}"`);
|
||||
if (isNoTaskRun) {
|
||||
// No-task heartbeat: agent has identity but no assigned task
|
||||
// Fetch unread messages when woken by message trigger
|
||||
if (triggerDetail === "wake-on-message" && this.messageStore) {
|
||||
try {
|
||||
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
||||
} catch {
|
||||
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message`);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggeringCommentLines.length > 0) {
|
||||
triggeringCommentLines.unshift(
|
||||
// Build pending messages section
|
||||
const pendingMessagesLines: string[] = [];
|
||||
if (pendingMessages.length > 0) {
|
||||
pendingMessagesLines.push(
|
||||
"",
|
||||
"You were woken because of new comments on this task. Review them and take appropriate action.",
|
||||
`Triggering comment type: ${effectiveTriggeringCommentType ?? "task"}`,
|
||||
"New comments since last run:",
|
||||
"Pending Messages:",
|
||||
...pendingMessages.map((msg) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build pending messages section
|
||||
const pendingMessagesLines: string[] = [];
|
||||
if (pendingMessages.length > 0) {
|
||||
pendingMessagesLines.push(
|
||||
executionPrompt = [
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
"",
|
||||
"Pending Messages:",
|
||||
...pendingMessages.map((msg) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
"**No assigned task** — This heartbeat run has no task assignment.",
|
||||
"",
|
||||
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
|
||||
"useful ambient work. Here are some things you can do:",
|
||||
"",
|
||||
"1. **Check your messages** — Use read_messages to review any pending messages",
|
||||
" and use send_message to respond or communicate with other agents.",
|
||||
"",
|
||||
"2. **Create new tasks** — Use task_create to spawn follow-up work that needs",
|
||||
" to be done. This is useful for surfacing issues or ideas you discover.",
|
||||
"",
|
||||
"3. **Delegate work** — Use list_agents to discover available agents and",
|
||||
" delegate_task to assign work to them.",
|
||||
"",
|
||||
"4. **Update your memory** — Use memory_append to persist important learnings",
|
||||
" or context that will help you in future sessions.",
|
||||
"",
|
||||
"5. **Monitor the project** — Review the task board and identify any issues",
|
||||
" or opportunities that should be addressed.",
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
"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.",
|
||||
"Call heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
} else {
|
||||
// Task-scoped heartbeat: agent has an assigned task
|
||||
const taskTitle = taskDetail!.title ?? taskDetail!.description.slice(0, 100);
|
||||
|
||||
const executionPrompt = [
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`Assigned task: ${taskId} — ${taskTitle}`,
|
||||
"",
|
||||
"Task description:",
|
||||
taskDetail.description,
|
||||
"",
|
||||
taskDetail.prompt ? `PROMPT.md:\n${taskDetail.prompt}` : "No PROMPT.md available.",
|
||||
...triggeringCommentLines,
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
"Review the task status and take appropriate action. Call heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
// Fetch unread messages when woken by message trigger
|
||||
if (triggerDetail === "wake-on-message" && this.messageStore) {
|
||||
try {
|
||||
pendingMessages = this.messageStore.getInbox(agentId, "agent", { read: false, limit: 10 });
|
||||
} catch {
|
||||
heartbeatLog.warn(`Failed to fetch inbox messages for ${agentId} during wake-on-message`);
|
||||
}
|
||||
}
|
||||
|
||||
const triggeringCommentLines: string[] = [];
|
||||
if (effectiveTriggeringCommentIds && effectiveTriggeringCommentIds.length > 0) {
|
||||
const commentLookup = new Map<string, { author: string; text: string }>();
|
||||
for (const comment of taskDetail!.comments ?? []) {
|
||||
commentLookup.set(comment.id, { author: comment.author, text: comment.text });
|
||||
}
|
||||
for (const steeringComment of taskDetail!.steeringComments ?? []) {
|
||||
commentLookup.set(steeringComment.id, { author: steeringComment.author, text: steeringComment.text });
|
||||
}
|
||||
|
||||
const formatCommentText = (text: string): string => text.replace(/\s+/g, " ").trim();
|
||||
|
||||
for (const commentId of effectiveTriggeringCommentIds) {
|
||||
const comment = commentLookup.get(commentId);
|
||||
if (comment) {
|
||||
triggeringCommentLines.push(`- [${comment.author}]: "${formatCommentText(comment.text)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggeringCommentLines.length > 0) {
|
||||
triggeringCommentLines.unshift(
|
||||
"",
|
||||
"You were woken because of new comments on this task. Review them and take appropriate action.",
|
||||
`Triggering comment type: ${effectiveTriggeringCommentType ?? "task"}`,
|
||||
"New comments since last run:",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build pending messages section
|
||||
const pendingMessagesLines: string[] = [];
|
||||
if (pendingMessages.length > 0) {
|
||||
pendingMessagesLines.push(
|
||||
"",
|
||||
"Pending Messages:",
|
||||
...pendingMessages.map((msg) => {
|
||||
const timestamp = new Date(msg.createdAt).toLocaleString();
|
||||
return `- [from: ${msg.fromId}] ${msg.content} (${timestamp})`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
executionPrompt = [
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`Assigned task: ${taskId} — ${taskTitle}`,
|
||||
"",
|
||||
"Task description:",
|
||||
taskDetail!.description,
|
||||
"",
|
||||
taskDetail!.prompt ? `PROMPT.md:\n${taskDetail!.prompt}` : "No PROMPT.md available.",
|
||||
...triggeringCommentLines,
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
"Review the task status and take appropriate action. Call heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Execute
|
||||
await promptWithFallback(session, executionPrompt);
|
||||
@@ -1106,7 +1209,10 @@ export class HeartbeatMonitor {
|
||||
summary: heartbeatSummary,
|
||||
toolCallCount,
|
||||
};
|
||||
if (inboxSelection) {
|
||||
if (isNoTaskRun) {
|
||||
// Identity agents without tasks get a special reason for observability
|
||||
completionResultJson.reason = "no_assignment_identity_run";
|
||||
} else if (inboxSelection) {
|
||||
completionResultJson.reason = "inbox_selected";
|
||||
completionResultJson.priority = inboxSelection.priority;
|
||||
completionResultJson.taskId = taskId;
|
||||
|
||||
Reference in New Issue
Block a user