feat(FN-3459): enable tool mode for heartbeat agent sessions

This merge enables heartbeat agents to use coding sessions with tool access (FN-3459). It adds heartbeat session prompt tests, updates the heartbeat executor to support tool mode, modifies dashboard node components and hooks, and includes a fix for a typecheck regression. A changeset is included for

Fusion-Task-Id: FN-3459
This commit is contained in:
Fusion
2026-05-04 20:43:09 -07:00
committed by gsxdsm
parent 783d834b91
commit a0c1e5b982
7 changed files with 32 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Give autonomous heartbeat agent sessions coding-capable workspace tools (read/write/edit/bash within worktree boundaries) while preserving heartbeat-specific custom tools and readonly safety for non-heartbeat readonly flows.

View File

@@ -456,6 +456,10 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id
1. **System prompt**
- Task-scoped runs use the task heartbeat system prompt.
- No-task runs use the ambient/no-task heartbeat system prompt (tool-aligned: no task-scoped tools).
2. **Workspace tool mode**
- Heartbeat sessions are created with coding-capable workspace tools (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`) inside worktree boundary guards.
- Heartbeat behavior still stays lightweight: one concrete action per run, then `fn_heartbeat_done`.
- Engine-owned heartbeat tools are still layered on top (task creation/log/docs for task-scoped runs; ambient/delegation/memory tools for no-task runs).
2. **Agent identity and instructions bundle**
- Inline instructions (`instructionsText`)
- File-backed instructions (`instructionsPath`)

View File

@@ -15,7 +15,7 @@ import {
Upload,
X,
} from "lucide-react";
import type { ContainerStatusInfo, DockerNodeConfigInfo as DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ContainerStatusInfo, DockerNodeConfigInfo, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
@@ -595,7 +595,7 @@ export function NodeDetailModal({
<details>
<summary>Volume Mounts</summary>
<div className="node-detail-modal__docker-list">
{dockerConfigDraft.volumeMounts.map((mount, index) => (
{dockerConfigDraft.volumeMounts.map((mount: DockerNodeConfigInfo["volumeMounts"][number], index: number) => (
<div key={`${mount.hostPath}-${mount.containerPath}-${index}`} className="node-detail-modal__docker-row">
<input className="input" value={mount.hostPath} placeholder="Host path" onChange={(event) => {
const next = [...dockerConfigDraft.volumeMounts];
@@ -686,7 +686,7 @@ export function NodeDetailModal({
<details>
<summary>Extra CLIs</summary>
<div className="node-detail-modal__docker-list">
{(dockerConfigDraft.extraClis ?? []).map((cli, index) => (
{(dockerConfigDraft.extraClis ?? []).map((cli: string, index: number) => (
<div key={`${cli}-${index}`} className="node-detail-modal__docker-row">
<input className="input" value={cli} onChange={(event) => {
const next = [...(dockerConfigDraft.extraClis ?? [])];

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { DockerNodeConfigInfo as DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
import type { DockerNodeConfigInfo, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
import {
fetchDockerConfigDiff,
fetchDockerNodeConfig,

View File

@@ -486,6 +486,7 @@ describe("executeHeartbeat", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
expect(callArgs.tools).toBe("coding");
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should have fn_task_create, fn_list_agents, fn_delegate_task
@@ -1793,7 +1794,7 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_memory_search");
expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("readonly");
expect(callArgs.tools).toBe("coding");
// Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task,
// fn_get_agent_config, fn_update_agent_config, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(12);

View File

@@ -88,6 +88,19 @@ describe("createHeartbeatTools", () => {
mockTaskStore = createMockTaskStoreForTools();
});
it("heartbeat task-scoped system prompt documents coding-capable workspace access", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("coding-capable workspace tools");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_log");
expect(HEARTBEAT_SYSTEM_PROMPT).toContain("fn_task_document_write");
});
it("heartbeat no-task system prompt documents coding-capable workspace access without task-scoped tools", () => {
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain("coding-capable workspace tools");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_write");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_document_read");
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).not.toContain("fn_task_log");
});
it("returns task, delegation, and agent-config tools", () => {
const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });

View File

@@ -171,7 +171,7 @@ Examples of ONE useful action:
- DON'T: create vague tasks like "investigate stuff" without actionable scope.
Keep work lightweight — this is a single-pass check, not a full implementation run.
You have readonly file access plus fn_task_create, fn_task_log, and fn_task_document tools.
You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus fn_task_create, fn_task_log, and fn_task_document 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.
@@ -253,7 +253,7 @@ Examples of ONE useful action:
- DON'T: attempt implementation work that requires task-scoped tooling/context.
Keep work lightweight — this is a single-pass ambient check, not a full implementation run.
You have readonly file access plus:
You have coding-capable workspace tools (read/write/edit/bash within worktree boundaries) plus:
- fn_task_create
- fn_list_agents and fn_delegate_task
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
@@ -934,7 +934,7 @@ export class HeartbeatMonitor {
* Implements the Paperclip-style execution model:
* 1. Wake — start a heartbeat run record
* 2. Check inbox — resolve the agent's assigned task
* 3. Work — run a lightweight agent session with readonly tools + fn_task_create/fn_task_log
* 3. Work — run a lightweight agent session with coding-capable tools + fn_task_create/fn_task_log
* 4. Exit — record results and complete the run
*
* Budget governance:
@@ -1462,7 +1462,7 @@ export class HeartbeatMonitor {
pluginRunner: this.pluginRunner,
cwd: rootDir,
systemPrompt,
tools: "readonly",
tools: "coding",
customTools: heartbeatTools,
...(() => {
const { provider, modelId } = extractRuntimeModel(agent.runtimeConfig);