From c15d1294eb5e1778e18a1dc0fd54e0ad0ad4619e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 11:17:03 -0700 Subject: [PATCH] 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) --- .../FN-7115-heartbeat-tool-injection.md | 7 ++ docs/agents.md | 6 +- .../src/__tests__/agent-action-gate.test.ts | 86 ++++++++++++++++++- .../agent-workflow-tools-exposure.test.ts | 16 ++-- .../src/__tests__/heartbeat-executor.test.ts | 31 ++++--- .../heartbeat-session-prompt.test.ts | 69 ++++++++++++++- packages/engine/src/agent-heartbeat.ts | 22 +++-- 7 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 .changeset/FN-7115-heartbeat-tool-injection.md diff --git a/.changeset/FN-7115-heartbeat-tool-injection.md b/.changeset/FN-7115-heartbeat-tool-injection.md new file mode 100644 index 0000000000..b8dd534e35 --- /dev/null +++ b/.changeset/FN-7115-heartbeat-tool-injection.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index f0956820f8..ab8633dca2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -34,7 +34,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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. - 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 @@ -499,12 +499,12 @@ This layered behavior is shared by heartbeat agents and task-scoped sessions tha ## 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_list` — list recent runs and statuses - `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. diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 02cf0c4cde..8cbb315661 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { addToExemptTools, computeApprovalDedupeKey, @@ -135,6 +135,90 @@ describe("agent-action-gate", () => { 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) => { const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy }); expect(decision.disposition).toBe("allow"); diff --git a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts index 6f90de23c7..1410947abd 100644 --- a/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts +++ b/packages/engine/src/__tests__/agent-workflow-tools-exposure.test.ts @@ -8,16 +8,19 @@ import { createWorkflowUpdateTool, createWorkflowDeleteTool, } from "../index.js"; +import { createWorkflowSettingsTool } from "../agent-tools.js"; import type { TaskStore } from "@fusion/core"; /** * 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 - * `fn_workflow_*` tools. The lanes assemble their toolset from - * `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors the - * same factories — so asserting factory completeness here guards every lane's - * source of truth. Lane-wiring (that chat/planning actually pass these to - * createFnAgent) is asserted in packages/dashboard's exposure test. + * chat, planning, and the task executor all share must always expose the core + * `fn_workflow_*` authoring tools. The lanes assemble most of their toolset + * from `createWorkflowAuthoringTools` (chat/planning) and the executor mirrors + * the same factories — so asserting factory completeness here guards every + * lane's source of truth. `fn_workflow_settings` remains a standalone factory + * 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 * 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(createWorkflowUpdateTool(fakeStore).name).toBe("fn_workflow_update"); expect(createWorkflowDeleteTool(fakeStore).name).toBe("fn_workflow_delete"); + expect(createWorkflowSettingsTool(fakeStore).name).toBe("fn_workflow_settings"); }); }); diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 71413dbfe7..8e2d0d00da 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -3030,8 +3030,8 @@ describe("executeHeartbeat", () => { expect(callArgs.systemPrompt).toContain("fn_task_document_write"); expect(callArgs.tools).toBe("coding"); // 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. - expect(callArgs.customTools).toHaveLength(32); + // task read discovery, workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. + expect(callArgs.customTools).toHaveLength(39); expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); 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![20]!.name).toBe("fn_workflow_list"); expect(callArgs.customTools![21]!.name).toBe("fn_workflow_get"); - expect(callArgs.customTools![22]!.name).toBe("fn_trait_list"); - expect(callArgs.customTools![23]!.name).toBe("fn_ask_question"); - expect(callArgs.customTools![24]!.name).toBe("fn_research_run"); - expect(callArgs.customTools![25]!.name).toBe("fn_research_list"); - expect(callArgs.customTools![26]!.name).toBe("fn_research_get"); - expect(callArgs.customTools![27]!.name).toBe("fn_web_fetch"); - expect(callArgs.customTools![28]!.name).toBe("fn_memory_search"); - expect(callArgs.customTools![29]!.name).toBe("fn_memory_get"); - expect(callArgs.customTools![30]!.name).toBe("fn_memory_append"); + expect(callArgs.customTools![22]!.name).toBe("fn_workflow_create"); + expect(callArgs.customTools![23]!.name).toBe("fn_workflow_update"); + expect(callArgs.customTools![24]!.name).toBe("fn_workflow_delete"); + expect(callArgs.customTools![25]!.name).toBe("fn_workflow_settings"); + expect(callArgs.customTools![26]!.name).toBe("fn_trait_list"); + expect(callArgs.customTools![27]!.name).toBe("fn_ask_question"); + expect(callArgs.customTools![28]!.name).toBe("fn_research_run"); + expect(callArgs.customTools![29]!.name).toBe("fn_research_list"); + 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) - 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 () => { diff --git a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts index 64ff414c73..294ffb5218 100644 --- a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts +++ b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts @@ -137,10 +137,15 @@ describe("createHeartbeatTools", () => { "fn_reflect_on_performance", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_create", + "fn_workflow_update", + "fn_workflow_delete", + "fn_workflow_settings", "fn_trait_list", "fn_research_run", "fn_research_list", "fn_research_get", + "fn_research_cancel", "fn_ask_question", "fn_heartbeat_done", ] as const; @@ -183,7 +188,7 @@ describe("createHeartbeatTools", () => { 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[1]!.name).toBe("fn_task_log"); expect(tools[2]!.name).toBe("fn_task_document_write"); @@ -207,14 +212,67 @@ describe("createHeartbeatTools", () => { "fn_task_search", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_create", + "fn_workflow_update", + "fn_workflow_delete", + "fn_workflow_settings", "fn_trait_list", "fn_ask_question", "fn_research_run", "fn_research_list", "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_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); + 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 () => { @@ -810,18 +868,23 @@ describe("no-task heartbeat tool surface", () => { "fn_artifact_view", "fn_workflow_list", "fn_workflow_get", + "fn_workflow_create", + "fn_workflow_update", + "fn_workflow_delete", + "fn_workflow_settings", "fn_trait_list", "fn_ask_question", "fn_research_run", "fn_research_list", "fn_research_get", + "fn_research_cancel", ])); expect(capturedCustomTools).not.toEqual(expect.arrayContaining([ "fn_task_log", "fn_task_document_write", "fn_task_document_read", - "fn_research_cancel", "fn_run_verification", + "fn_acquire_repo_worktree", "fn_workflow_select", "fn_task_promote", ])); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 6d2dc9917d..f62b217676 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; 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 { 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. 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="..."). 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_read_evaluations and fn_update_identity (available in no-task runs) - 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_research_run, fn_research_list, and fn_research_get for bounded research when configured +- 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, fn_research_get, and fn_research_cancel for bounded research when configured - fn_ask_question to ask the dashboard user for structured clarification - fn_web_fetch - fn_memory_search, fn_memory_get, and fn_memory_append @@ -3423,6 +3423,12 @@ export class HeartbeatMonitor { * * 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. + * + * 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[] { const rootDir = this.rootDir ?? process.cwd(); @@ -3430,12 +3436,16 @@ export class HeartbeatMonitor { store: taskStore, rootDir, getSettings: () => taskStore.getSettings(), - }).filter((tool) => tool.name !== "fn_research_cancel"); + }); return [ ...createTaskReadTools(taskStore), createWorkflowListTool(taskStore), createWorkflowGetTool(taskStore), + createWorkflowCreateTool(taskStore, { stripApprovalFlags: true }), + createWorkflowUpdateTool(taskStore, { stripApprovalFlags: true }), + createWorkflowDeleteTool(taskStore), + createWorkflowSettingsTool(taskStore), createTraitListTool(), createAskQuestionTool(), ...researchTools, @@ -3541,6 +3551,8 @@ export class HeartbeatMonitor { } tools.push(...this.createSharedHeartbeatWorkTools(taskStore)); + tools.push(createWorkflowSelectTool(taskStore, taskId)); + tools.push(createTaskPromoteTool(taskStore, taskId)); return tools; }