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:
5
.changeset/fn-3459-agent-tool-access.md
Normal file
5
.changeset/fn-3459-agent-tool-access.md
Normal 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.
|
||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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 ?? [])];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user