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** 1. **System prompt**
- Task-scoped runs use the task heartbeat 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). - 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** 2. **Agent identity and instructions bundle**
- Inline instructions (`instructionsText`) - Inline instructions (`instructionsText`)
- File-backed instructions (`instructionsPath`) - File-backed instructions (`instructionsPath`)

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; 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 { import {
fetchDockerConfigDiff, fetchDockerConfigDiff,
fetchDockerNodeConfig, fetchDockerNodeConfig,

View File

@@ -486,6 +486,7 @@ describe("executeHeartbeat", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledOnce(); expect(mockedCreateFnAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!; const callArgs = mockedCreateFnAgent.mock.calls[0]![0]!;
expect(callArgs.tools).toBe("coding");
const toolNames = callArgs.customTools!.map((tool: any) => tool.name); const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
// Should have fn_task_create, fn_list_agents, fn_delegate_task // 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_memory_search");
expect(callArgs.systemPrompt).toContain("fn_task_log"); expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write"); 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, // 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 // 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); expect(callArgs.customTools).toHaveLength(12);

View File

@@ -88,6 +88,19 @@ describe("createHeartbeatTools", () => {
mockTaskStore = createMockTaskStoreForTools(); 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", () => { it("returns task, delegation, and agent-config tools", () => {
const store = createMockStore(); const store = createMockStore();
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); 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. - 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. 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="..."). **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.
@@ -253,7 +253,7 @@ Examples of ONE useful action:
- DON'T: attempt implementation work that requires task-scoped tooling/context. - 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. 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_task_create
- fn_list_agents and fn_delegate_task - fn_list_agents and fn_delegate_task
- fn_get_agent_config and fn_update_agent_config (for direct reports only) - 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: * Implements the Paperclip-style execution model:
* 1. Wake — start a heartbeat run record * 1. Wake — start a heartbeat run record
* 2. Check inbox — resolve the agent's assigned task * 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 * 4. Exit — record results and complete the run
* *
* Budget governance: * Budget governance:
@@ -1462,7 +1462,7 @@ export class HeartbeatMonitor {
pluginRunner: this.pluginRunner, pluginRunner: this.pluginRunner,
cwd: rootDir, cwd: rootDir,
systemPrompt, systemPrompt,
tools: "readonly", tools: "coding",
customTools: heartbeatTools, customTools: heartbeatTools,
...(() => { ...(() => {
const { provider, modelId } = extractRuntimeModel(agent.runtimeConfig); const { provider, modelId } = extractRuntimeModel(agent.runtimeConfig);