FN-7115: expose governed heartbeat workflow tools

Expose the missing governed workflow and promotion tools to heartbeat agent sessions.\n\n- Add workflow authoring/settings, research cancellation, and task promotion/select tools to the shared heartbeat work surface.\n- Keep task-read discovery and executor-only worktree tools correctly scoped while documenting the heartbeat lane surface.\n- Cover tool injection, permission-gate behavior, and workflow factory exposure with regression tests.\n\nFiles changed:\n .changeset/FN-7115-heartbeat-tool-injection.md     |  7 ++\n docs/agents.md                                     |  6 +-\n .../engine/src/__tests__/agent-action-gate.test.ts | 86 +++++++++++++++++++++-\n .../agent-workflow-tools-exposure.test.ts          | 16 ++--\n .../src/__tests__/heartbeat-executor.test.ts       | 31 +++++---\n .../src/__tests__/heartbeat-session-prompt.test.ts | 69 ++++++++++++++++-\n packages/engine/src/agent-heartbeat.ts             | 22 ++++--\n 7 files changed, 207 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-7115

Fusion-Task-Lineage: 0883767b-7982-4f10-a466-1467b867dea1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 11:17:03 -07:00
parent 8f0f020caa
commit c15d1294eb
7 changed files with 207 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Permanent/custom agents can use governed workflow and task-promotion tools.
category: feature
dev: Injects the FN-7111-classified mutating tools (fn_workflow_create/update/delete/settings/select, fn_task_promote) into the heartbeat agent-work lane (packages/engine/src/agent-heartbeat.ts), governed by AgentPermissionPolicy via wrapToolsWithActionGate. Executor-only tools requiring worktree/workspace context (fn_run_verification, fn_acquire_repo_worktree) remain intentionally excluded from the ambient lane. Hermetic readonly lanes and automation allowedTools are unchanged.

View File

@@ -34,7 +34,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. - Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`.
- Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. - Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change.
- Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency. - Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency.
- Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery (`fn_workflow_list`, `fn_workflow_get`, `fn_trait_list`), bounded research (`fn_research_run`, `fn_research_list`, `fn_research_get`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding safe tools from the session. - Permanent/custom heartbeat agents receive the broad coordination and work-discovery tool surface instead of a narrowly curated subset: read-only task discovery (`fn_task_list`, `fn_task_show`, `fn_task_search`) for work discovery and duplicate avoidance, workflow discovery and authoring (`fn_workflow_list`, `fn_workflow_get`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`), governed research (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`), structured clarification (`fn_ask_question`), artifact, memory, messaging, goal, evaluation, identity, and delegation tools. Task-scoped heartbeat sessions also expose current-task workflow selection and promotion (`fn_workflow_select`, `fn_task_promote`); no-task heartbeats omit those because they have no ambient task. Executor-only worktree/workspace tools such as `fn_run_verification` and `fn_acquire_repo_worktree` remain out of the ambient heartbeat lane until that lane owns the required worktree/workspace context. The task read tools are store-backed, text-only, and action-gate-recognized as read-only; dangerous actions are controlled at invocation time by each agent's `AgentPermissionPolicy` through the action gate (allow / require approval / block), not by withholding governed tools from the session.
### Artifact registry tools ### Artifact registry tools
@@ -499,12 +499,12 @@ This layered behavior is shared by heartbeat agents and task-scoped sessions tha
## Research Tools in Planning/Execution Sessions ## Research Tools in Planning/Execution Sessions
Triage and executor runtime sessions include a bounded research tool surface only when `experimentalFeatures.researchView` is enabled for the project. Permanent/custom heartbeat sessions always register the safe research discovery/run tools (`fn_research_run`, `fn_research_list`, `fn_research_get`) so permission policy and runtime setup responses govern use; `fn_research_run` remains `network_api`-gated by `AgentPermissionPolicy`, and disabled or misconfigured research returns an actionable setup result instead of removing the tool. Triage and executor runtime sessions include a bounded research tool surface only when `experimentalFeatures.researchView` is enabled for the project. Permanent/custom heartbeat sessions always register the governed research discovery/run/cancel tools (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`) so permission policy and runtime setup responses govern use; `fn_research_run` and `fn_research_cancel` remain `network_api`-gated by `AgentPermissionPolicy`, and disabled or misconfigured research returns an actionable setup result instead of removing the tool.
- `fn_research_run` — create/start a bounded research run for a focused query - `fn_research_run` — create/start a bounded research run for a focused query
- `fn_research_list` — list recent runs and statuses - `fn_research_list` — list recent runs and statuses
- `fn_research_get` — fetch one run's structured findings payload - `fn_research_get` — fetch one run's structured findings payload
- `fn_research_cancel` — cancel an active run (triage/executor research surface only; heartbeat withholds cancel until it has explicit action-gate classification) - `fn_research_cancel` — cancel an active run
These tools return structured metadata (`runId`, `status`, `summary`, `findings`, `citations`, `error`, `setup`) in addition to concise text so downstream model steps can consume results deterministically. These tools return structured metadata (`runId`, `status`, `summary`, `findings`, `citations`, `error`, `setup`) in addition to concise text so downstream model steps can consume results deterministically.

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
addToExemptTools, addToExemptTools,
computeApprovalDedupeKey, computeApprovalDedupeKey,
@@ -135,6 +135,90 @@ describe("agent-action-gate", () => {
expect(blockedDecision.disposition).toBe("block"); expect(blockedDecision.disposition).toBe("block");
}); });
it.each([
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_workflow_select",
"fn_task_promote",
] as const)("governs newly injected heartbeat mutating tool %s by task-agent policy", (toolName) => {
const allowedDecision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: unrestrictedPolicy });
const approvalDecision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: approvalPolicy });
const blockedDecision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(allowedDecision.category).toBe("task_agent_mutation");
expect(allowedDecision.disposition).toBe("allow");
expect(approvalDecision.category).toBe("task_agent_mutation");
expect(approvalDecision.disposition).toBe("require-approval");
expect(blockedDecision.category).toBe("task_agent_mutation");
expect(blockedDecision.disposition).toBe("block");
});
it("executes or withholds newly injected workflow tools according to AgentPermissionPolicy", async () => {
const execute = vi.fn().mockResolvedValue({ ok: true });
const tool = { name: "fn_workflow_delete", label: "Delete Workflow", description: "", parameters: {}, execute };
const { wrapToolsWithActionGate } = await import("../pi.js");
const unrestricted = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: unrestrictedPolicy,
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await expect((unrestricted[0] as any).execute("delete-allow", { workflow_id: "WF-1" })).resolves.toEqual({ ok: true });
expect(execute).toHaveBeenCalledTimes(1);
const locked = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: lockedDownPolicy,
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
const blocked = await (locked[0] as any).execute("delete-block", { workflow_id: "WF-1" });
expect(blocked).toEqual(expect.objectContaining({
isError: true,
decision: expect.objectContaining({
category: "task_agent_mutation",
disposition: "block",
toolName: "fn_workflow_delete",
}),
}));
expect(execute).toHaveBeenCalledTimes(1);
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-workflow-1" });
const pauseForApproval = vi.fn();
const approval = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: approvalPolicy,
createApprovalRequest,
findApprovalByDedupeKey: vi.fn().mockResolvedValue(null),
pauseForApproval,
});
const pending = await (approval[0] as any).execute("delete-approval", { workflow_id: "WF-1" });
expect(pending).toEqual(expect.objectContaining({
isError: true,
decision: expect.objectContaining({
category: "task_agent_mutation",
disposition: "require-approval",
toolName: "fn_workflow_delete",
metadata: expect.objectContaining({ approvalRequestId: "apr-workflow-1" }),
}),
}));
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(pauseForApproval).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledTimes(1);
});
it.each(FN_3548_COORDINATION_TOOLS)("always allows newly exempt internal tool %s under locked-down policies", (toolName) => { it.each(FN_3548_COORDINATION_TOOLS)("always allows newly exempt internal tool %s under locked-down policies", (toolName) => {
const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy }); const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(decision.disposition).toBe("allow"); expect(decision.disposition).toBe("allow");

View File

@@ -8,16 +8,19 @@ import {
createWorkflowUpdateTool, createWorkflowUpdateTool,
createWorkflowDeleteTool, createWorkflowDeleteTool,
} from "../index.js"; } from "../index.js";
import { createWorkflowSettingsTool } from "../agent-tools.js";
import type { TaskStore } from "@fusion/core"; import type { TaskStore } from "@fusion/core";
/** /**
* U11 / R12 drift guard (engine half): the workflow-authoring tool surface that * U11 / R12 drift guard (engine half): the workflow-authoring tool surface that
* chat, planning, and the task executor all share must always expose the six * chat, planning, and the task executor all share must always expose the core
* `fn_workflow_*` tools. The lanes assemble their toolset from * `fn_workflow_*` authoring tools. The lanes assemble most of their toolset
* `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors the * from `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors
* same factories — so asserting factory completeness here guards every lane's * the same factories — so asserting factory completeness here guards every
* source of truth. Lane-wiring (that chat/planning actually pass these to * lane's source of truth. `fn_workflow_settings` remains a standalone factory
* createFnAgent) is asserted in packages/dashboard's exposure test. * that heartbeat/executor wire explicitly. Lane-wiring (that chat/planning
* actually pass these to createFnAgent) is asserted in packages/dashboard's
* exposure test.
* *
* We invoke the REAL factories with a fake store — never mock the factories * We invoke the REAL factories with a fake store — never mock the factories
* themselves — so a renamed/removed tool name is caught. * themselves — so a renamed/removed tool name is caught.
@@ -52,6 +55,7 @@ describe("workflow tool exposure (engine factories)", () => {
expect(createWorkflowCreateTool(fakeStore).name).toBe("fn_workflow_create"); expect(createWorkflowCreateTool(fakeStore).name).toBe("fn_workflow_create");
expect(createWorkflowUpdateTool(fakeStore).name).toBe("fn_workflow_update"); expect(createWorkflowUpdateTool(fakeStore).name).toBe("fn_workflow_update");
expect(createWorkflowDeleteTool(fakeStore).name).toBe("fn_workflow_delete"); expect(createWorkflowDeleteTool(fakeStore).name).toBe("fn_workflow_delete");
expect(createWorkflowSettingsTool(fakeStore).name).toBe("fn_workflow_settings");
}); });
}); });

View File

@@ -3030,8 +3030,8 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_task_document_write"); expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("coding"); expect(callArgs.tools).toBe("coding");
// fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity, // fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity,
// task read discovery, workflow discovery, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. // task read discovery, workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
expect(callArgs.customTools).toHaveLength(32); expect(callArgs.customTools).toHaveLength(39);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write"); expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
@@ -3054,17 +3054,24 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![19]!.name).toBe("fn_task_search"); expect(callArgs.customTools![19]!.name).toBe("fn_task_search");
expect(callArgs.customTools![20]!.name).toBe("fn_workflow_list"); expect(callArgs.customTools![20]!.name).toBe("fn_workflow_list");
expect(callArgs.customTools![21]!.name).toBe("fn_workflow_get"); expect(callArgs.customTools![21]!.name).toBe("fn_workflow_get");
expect(callArgs.customTools![22]!.name).toBe("fn_trait_list"); expect(callArgs.customTools![22]!.name).toBe("fn_workflow_create");
expect(callArgs.customTools![23]!.name).toBe("fn_ask_question"); expect(callArgs.customTools![23]!.name).toBe("fn_workflow_update");
expect(callArgs.customTools![24]!.name).toBe("fn_research_run"); expect(callArgs.customTools![24]!.name).toBe("fn_workflow_delete");
expect(callArgs.customTools![25]!.name).toBe("fn_research_list"); expect(callArgs.customTools![25]!.name).toBe("fn_workflow_settings");
expect(callArgs.customTools![26]!.name).toBe("fn_research_get"); expect(callArgs.customTools![26]!.name).toBe("fn_trait_list");
expect(callArgs.customTools![27]!.name).toBe("fn_web_fetch"); expect(callArgs.customTools![27]!.name).toBe("fn_ask_question");
expect(callArgs.customTools![28]!.name).toBe("fn_memory_search"); expect(callArgs.customTools![28]!.name).toBe("fn_research_run");
expect(callArgs.customTools![29]!.name).toBe("fn_memory_get"); expect(callArgs.customTools![29]!.name).toBe("fn_research_list");
expect(callArgs.customTools![30]!.name).toBe("fn_memory_append"); expect(callArgs.customTools![30]!.name).toBe("fn_research_get");
expect(callArgs.customTools![31]!.name).toBe("fn_research_cancel");
expect(callArgs.customTools![32]!.name).toBe("fn_workflow_select");
expect(callArgs.customTools![33]!.name).toBe("fn_task_promote");
expect(callArgs.customTools![34]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![35]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![36]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![37]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool) // fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![31]!.name).toBe("fn_heartbeat_done"); expect(callArgs.customTools![38]!.name).toBe("fn_heartbeat_done");
}); });
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => { it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {

View File

@@ -137,10 +137,15 @@ describe("createHeartbeatTools", () => {
"fn_reflect_on_performance", "fn_reflect_on_performance",
"fn_workflow_list", "fn_workflow_list",
"fn_workflow_get", "fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list", "fn_trait_list",
"fn_research_run", "fn_research_run",
"fn_research_list", "fn_research_list",
"fn_research_get", "fn_research_get",
"fn_research_cancel",
"fn_ask_question", "fn_ask_question",
"fn_heartbeat_done", "fn_heartbeat_done",
] as const; ] as const;
@@ -183,7 +188,7 @@ describe("createHeartbeatTools", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001"); const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(27); expect(tools).toHaveLength(34);
expect(tools[0]!.name).toBe("fn_task_create"); expect(tools[0]!.name).toBe("fn_task_create");
expect(tools[1]!.name).toBe("fn_task_log"); expect(tools[1]!.name).toBe("fn_task_log");
expect(tools[2]!.name).toBe("fn_task_document_write"); expect(tools[2]!.name).toBe("fn_task_document_write");
@@ -207,14 +212,67 @@ describe("createHeartbeatTools", () => {
"fn_task_search", "fn_task_search",
"fn_workflow_list", "fn_workflow_list",
"fn_workflow_get", "fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list", "fn_trait_list",
"fn_ask_question", "fn_ask_question",
"fn_research_run", "fn_research_run",
"fn_research_list", "fn_research_list",
"fn_research_get", "fn_research_get",
"fn_research_cancel",
"fn_workflow_select",
"fn_task_promote",
]); ]);
expect(tools.map((tool) => tool.name)).not.toContain("fn_research_cancel");
expect(tools.map((tool) => tool.name)).not.toContain("fn_run_verification"); expect(tools.map((tool) => tool.name)).not.toContain("fn_run_verification");
expect(tools.map((tool) => tool.name)).not.toContain("fn_acquire_repo_worktree");
});
it("heartbeat workflow create/update tools strip approval-bypass flags", async () => {
const store = createMockStore();
const captured: { createIr?: any; updateIr?: any } = {};
const taskStore = createMockTaskStoreForTools({
createWorkflowDefinition: vi.fn().mockImplementation(async (input: any) => {
captured.createIr = input.ir;
return { id: "WF-001", name: input.name };
}),
updateWorkflowDefinition: vi.fn().mockImplementation(async (_id: string, input: any) => {
captured.updateIr = input.ir;
return { id: "WF-001", name: input.name ?? "wf" };
}),
} as Partial<TaskStore>);
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp" });
const tools = monitor.createHeartbeatTools("agent-001", taskStore, "FN-001");
const createTool = tools.find((tool) => tool.name === "fn_workflow_create")!;
const updateTool = tools.find((tool) => tool.name === "fn_workflow_update")!;
const irWithFlags = {
version: "v1",
name: "wf",
nodes: [
{ id: "prompt", kind: "prompt", config: { cliSkipApproval: true } },
{
id: "foreach",
kind: "foreach",
config: {
template: {
nodes: [{ id: "inner", kind: "step-execute", config: { autoApprove: true } }],
edges: [],
},
},
},
],
edges: [],
};
await createTool.execute("call-create", { name: "wf", ir: irWithFlags }, undefined as any, undefined as any, undefined as any);
await updateTool.execute("call-update", { workflow_id: "WF-001", ir: irWithFlags }, undefined as any, undefined as any, undefined as any);
expect(captured.createIr.nodes[0].config.cliSkipApproval).toBeUndefined();
expect(captured.createIr.nodes[1].config.template.nodes[0].config.autoApprove).toBeUndefined();
expect(captured.updateIr.nodes[0].config.cliSkipApproval).toBeUndefined();
expect(captured.updateIr.nodes[1].config.template.nodes[0].config.autoApprove).toBeUndefined();
}); });
it("fn_task_create tool creates a task in triage via TaskStore", async () => { it("fn_task_create tool creates a task in triage via TaskStore", async () => {
@@ -810,18 +868,23 @@ describe("no-task heartbeat tool surface", () => {
"fn_artifact_view", "fn_artifact_view",
"fn_workflow_list", "fn_workflow_list",
"fn_workflow_get", "fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list", "fn_trait_list",
"fn_ask_question", "fn_ask_question",
"fn_research_run", "fn_research_run",
"fn_research_list", "fn_research_list",
"fn_research_get", "fn_research_get",
"fn_research_cancel",
])); ]));
expect(capturedCustomTools).not.toEqual(expect.arrayContaining([ expect(capturedCustomTools).not.toEqual(expect.arrayContaining([
"fn_task_log", "fn_task_log",
"fn_task_document_write", "fn_task_document_write",
"fn_task_document_read", "fn_task_document_read",
"fn_research_cancel",
"fn_run_verification", "fn_run_verification",
"fn_acquire_repo_worktree",
"fn_workflow_select", "fn_workflow_select",
"fn_task_promote", "fn_task_promote",
])); ]));

View File

@@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai"; import { Type, type Static } from "@earendil-works/pi-ai";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { import {
resolveAgentInstructionsWithRatings, resolveAgentInstructionsWithRatings,
@@ -409,7 +409,7 @@ Examples of ONE useful coordination action:
Keep work lightweight — this is a single-pass coordination check, not an implementation run. Keep work lightweight — this is a single-pass coordination check, not an implementation run.
You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools, You have workspace read tools (for context gathering) plus fn_task_create, fn_task_log, fn_task_document tools,
fn_send_message, fn_read_messages, fn_post_room_message, fn_list_agents, fn_delegate_task, workflow discovery, bounded research, fn_ask_question, and memory tools. fn_send_message, fn_read_messages, fn_post_room_message, fn_list_agents, fn_delegate_task, workflow discovery/authoring, task promotion, bounded research, fn_ask_question, and memory tools.
**Task Documents:** Save important findings with fn_task_document_write(key="...", content="..."). **Task Documents:** Save important findings with fn_task_document_write(key="...", content="...").
Documents persist across sessions and are visible in the dashboard's Documents tab. Documents persist across sessions and are visible in the dashboard's Documents tab.
@@ -507,8 +507,8 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo
- fn_artifact_register, fn_artifact_list, and fn_artifact_view - fn_artifact_register, fn_artifact_list, and fn_artifact_view
- fn_read_evaluations and fn_update_identity (available in no-task runs) - fn_read_evaluations and fn_update_identity (available in no-task runs)
- fn_reflect_on_performance when reflection is enabled for this run - fn_reflect_on_performance when reflection is enabled for this run
- fn_workflow_list, fn_workflow_get, and fn_trait_list for workflow discovery - fn_workflow_list, fn_workflow_get, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring
- fn_research_run, fn_research_list, and fn_research_get for bounded research when configured - fn_research_run, fn_research_list, fn_research_get, and fn_research_cancel for bounded research when configured
- fn_ask_question to ask the dashboard user for structured clarification - fn_ask_question to ask the dashboard user for structured clarification
- fn_web_fetch - fn_web_fetch
- fn_memory_search, fn_memory_get, and fn_memory_append - fn_memory_search, fn_memory_get, and fn_memory_append
@@ -3423,6 +3423,12 @@ export class HeartbeatMonitor {
* *
* FNXC:AgentTooling 2026-06-27-14:21: * FNXC:AgentTooling 2026-06-27-14:21:
* Read-only task discovery tools are part of this shared heartbeat-safe surface so both no-task and task-scoped permanent/custom heartbeat runs can list, show, and search tasks for duplicate avoidance without bespoke tool copies. * Read-only task discovery tools are part of this shared heartbeat-safe surface so both no-task and task-scoped permanent/custom heartbeat runs can list, show, and search tasks for duplicate avoidance without bespoke tool copies.
*
* FNXC:AgentTooling 2026-06-27-15:30:
* FN-7115 requires classified mutating workflow tools and governed research cancellation to be injected into the heartbeat lane instead of being withheld. Executor-only tools that need a task worktree or workspace task stay excluded because this ambient lane cannot supply that context safely.
*
* FNXC:AgentTooling 2026-06-27-23:04:
* Heartbeat agents are autonomous and prompt-injectable, so workflow create/update tools must strip embedded approval-bypass flags before persisting IR. Permission policy governs whether the tool call may happen; stripApprovalFlags prevents the resulting workflow from weakening future approval gates.
*/ */
private createSharedHeartbeatWorkTools(taskStore: TaskStore): ToolDefinition[] { private createSharedHeartbeatWorkTools(taskStore: TaskStore): ToolDefinition[] {
const rootDir = this.rootDir ?? process.cwd(); const rootDir = this.rootDir ?? process.cwd();
@@ -3430,12 +3436,16 @@ export class HeartbeatMonitor {
store: taskStore, store: taskStore,
rootDir, rootDir,
getSettings: () => taskStore.getSettings(), getSettings: () => taskStore.getSettings(),
}).filter((tool) => tool.name !== "fn_research_cancel"); });
return [ return [
...createTaskReadTools(taskStore), ...createTaskReadTools(taskStore),
createWorkflowListTool(taskStore), createWorkflowListTool(taskStore),
createWorkflowGetTool(taskStore), createWorkflowGetTool(taskStore),
createWorkflowCreateTool(taskStore, { stripApprovalFlags: true }),
createWorkflowUpdateTool(taskStore, { stripApprovalFlags: true }),
createWorkflowDeleteTool(taskStore),
createWorkflowSettingsTool(taskStore),
createTraitListTool(), createTraitListTool(),
createAskQuestionTool(), createAskQuestionTool(),
...researchTools, ...researchTools,
@@ -3541,6 +3551,8 @@ export class HeartbeatMonitor {
} }
tools.push(...this.createSharedHeartbeatWorkTools(taskStore)); tools.push(...this.createSharedHeartbeatWorkTools(taskStore));
tools.push(createWorkflowSelectTool(taskStore, taskId));
tools.push(createTaskPromoteTool(taskStore, taskId));
return tools; return tools;
} }