FN-7108: broaden heartbeat agent tool access

Heartbeat agents now receive safe coordination and discovery tools while risky calls remain permission-gated.

- Add shared heartbeat workflow, research, and clarification tools for task-scoped and no-task runs.
- Give no-task permanent agents artifact registry access alongside existing delegation, messaging, memory, goal, and identity tools.
- Cover the expanded tool surface and action-gate behavior with heartbeat and permission-policy tests.
- Document the permission-governed heartbeat tool model and add a minor changeset.

Files changed:
 .changeset/FN-7108-agent-tool-access.md            |  7 ++
 docs/agents.md                                     |  9 ++-
 .../src/__tests__/heartbeat-executor.test.ts       | 24 +++---
 .../src/__tests__/heartbeat-session-prompt.test.ts | 89 +++++++++++++++++++++-
 .../src/__tests__/pi-create-fn-agent.test.ts       | 67 ++++++++++++++++
 packages/engine/src/agent-heartbeat.ts             | 41 +++++++++-
 6 files changed, 221 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-7108

Fusion-Task-Lineage: 83302055-72be-47f8-a41d-70164d12f010

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 05:34:55 -07:00
parent c17d745665
commit 1a30ddd6e8
6 changed files with 221 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Agents now receive more tools, with dangerous actions governed by each agent's permission policy.
category: feature
dev: Heartbeat agent-work lane (packages/engine/src/agent-heartbeat.ts) assembles the broadened toolset; access remains gated by AgentPermissionPolicy via wrapToolsWithActionGate. Hermetic readonly lanes and automation allowedTools are unchanged.

View File

@@ -34,6 +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`.
- 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: 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. 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.
### Artifact registry tools
@@ -498,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:
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.
- `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
- `fn_research_cancel` — cancel an active run (triage/executor research surface only; heartbeat withholds cancel until it has explicit action-gate classification)
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.
@@ -511,7 +512,7 @@ Expected behavior and boundaries:
- Agents should use research only when repository/local context is insufficient
- Queries should stay narrow and task-scoped; avoid open-ended exploration
- When `experimentalFeatures.researchView` is disabled, sessions do not register `fn_research_*` tools and prompts do not advertise research capabilities
- When `experimentalFeatures.researchView` is disabled, triage/executor sessions do not register `fn_research_*` tools and prompts do not advertise research capabilities; heartbeat sessions keep the safe research tools registered but return setup guidance at execution time
- If the research surface is enabled but an explicitly selected external provider is misconfigured (or web search is explicitly disabled), tools return actionable `setup` responses instead of crashing
- Durable conclusions should be persisted with `fn_task_document_write` (for example, `key="research"`)
- Research runs require the project engine to be running for processing; `fn_research_run` creates the run but does not block for completion unless `wait_for_completion` is set
@@ -888,7 +889,7 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id
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).
- Engine-owned heartbeat tools are layered on top for both task-scoped and no-task runs. Permanent/custom agents get the safe coordination/work-discovery surface (task creation/delegation, agent config/provisioning, artifacts, memory, messaging, goals/evaluations/identity/reflection, workflow discovery, bounded research, and `fn_ask_question`), while task-only tools such as `fn_task_log` and task documents stay limited to task-scoped runs.
2. **Agent identity and instructions bundle**
- Inline instructions (`instructionsText`)
- File-backed instructions (`instructionsPath`)

View File

@@ -3029,10 +3029,9 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("coding");
// fn_artifact_register, fn_artifact_list, fn_artifact_view, fn_get_agent_config, fn_update_agent_config,
// fn_agent_create, fn_agent_delete, fn_goal_list, fn_goal_show, fn_read_evaluations, fn_update_identity,
// fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(22);
// fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity,
// workflow discovery, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
expect(callArgs.customTools).toHaveLength(29);
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");
@@ -3050,12 +3049,19 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![14]!.name).toBe("fn_goal_show");
expect(callArgs.customTools![15]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![16]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![17]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![18]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![19]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![20]!.name).toBe("fn_memory_append");
expect(callArgs.customTools![17]!.name).toBe("fn_workflow_list");
expect(callArgs.customTools![18]!.name).toBe("fn_workflow_get");
expect(callArgs.customTools![19]!.name).toBe("fn_trait_list");
expect(callArgs.customTools![20]!.name).toBe("fn_ask_question");
expect(callArgs.customTools![21]!.name).toBe("fn_research_run");
expect(callArgs.customTools![22]!.name).toBe("fn_research_list");
expect(callArgs.customTools![23]!.name).toBe("fn_research_get");
expect(callArgs.customTools![24]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![25]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![26]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![27]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![21]!.name).toBe("fn_heartbeat_done");
expect(callArgs.customTools![28]!.name).toBe("fn_heartbeat_done");
});
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {

View File

@@ -119,6 +119,9 @@ describe("createHeartbeatTools", () => {
"fn_update_agent_config",
"fn_agent_create",
"fn_agent_delete",
"fn_artifact_register",
"fn_artifact_list",
"fn_artifact_view",
"fn_send_message",
"fn_read_messages",
"fn_post_room_message",
@@ -129,6 +132,13 @@ describe("createHeartbeatTools", () => {
"fn_read_evaluations",
"fn_update_identity",
"fn_reflect_on_performance",
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
"fn_research_run",
"fn_research_list",
"fn_research_get",
"fn_ask_question",
"fn_heartbeat_done",
] as const;
@@ -170,7 +180,7 @@ describe("createHeartbeatTools", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(17);
expect(tools).toHaveLength(24);
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");
@@ -188,6 +198,17 @@ describe("createHeartbeatTools", () => {
expect(tools[14]!.name).toBe("fn_goal_show");
expect(tools[15]!.name).toBe("fn_read_evaluations");
expect(tools[16]!.name).toBe("fn_update_identity");
expect(tools.slice(17).map((tool) => tool.name)).toEqual([
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
"fn_ask_question",
"fn_research_run",
"fn_research_list",
"fn_research_get",
]);
expect(tools.map((tool) => tool.name)).not.toContain("fn_research_cancel");
expect(tools.map((tool) => tool.name)).not.toContain("fn_run_verification");
});
it("fn_task_create tool creates a task in triage via TaskStore", async () => {
@@ -740,6 +761,72 @@ describe("clearRunState", () => {
});
});
describe("no-task heartbeat tool surface", () => {
it("adds the approved workflow, research, and clarification tools without task-scoped duplicates", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "hb-no-task-tools-"));
const globalDir = mkdtempSync(join(tmpdir(), "hb-no-task-global-"));
const taskStore = new RealTaskStore(rootDir, globalDir, { inMemoryDb: true });
await taskStore.init();
const agentStore = new RealAgentStore({ rootDir: taskStore.getFusionDir(), taskStore, inMemoryDb: true });
const agent = await agentStore.createAgent({
name: "No Task Tool Agent",
role: "engineer",
soul: "Audits ambient project state.",
runtimeConfig: { enabled: true },
});
let capturedCustomTools: string[] = [];
const createSessionSpy = vi.spyOn(sessionHelpers, "createResolvedAgentSession").mockImplementation(async (options: any) => {
capturedCustomTools = (options.customTools ?? []).map((tool: any) => tool.name);
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }),
},
options,
} as any;
});
try {
const monitor = new HeartbeatMonitor({
store: agentStore as unknown as AgentStore,
taskStore: taskStore as unknown as TaskStore,
rootDir,
});
await monitor.executeHeartbeat({ agentId: agent.id, source: "timer" as any });
expect(capturedCustomTools).toEqual(expect.arrayContaining([
"fn_artifact_register",
"fn_artifact_list",
"fn_artifact_view",
"fn_workflow_list",
"fn_workflow_get",
"fn_trait_list",
"fn_ask_question",
"fn_research_run",
"fn_research_list",
"fn_research_get",
]));
expect(capturedCustomTools).not.toEqual(expect.arrayContaining([
"fn_task_log",
"fn_task_document_write",
"fn_task_document_read",
"fn_research_cancel",
"fn_run_verification",
"fn_workflow_select",
"fn_task_promote",
]));
} finally {
createSessionSpy.mockRestore();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
}
});
});
describe("room-message prompt injection", () => {
it("includes pending room messages and excludes self-authored room traffic", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "hb-room-prompt-"));

View File

@@ -867,6 +867,73 @@ describe("wrapToolsWithActionGate", () => {
expect(tool.execute).toHaveBeenCalled();
});
it("governs newly exposed heartbeat network tools by policy instead of withholding them", async () => {
const execute = vi.fn().mockResolvedValue({ ok: true });
const tool = { name: "fn_research_run", label: "Run Research", 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: {
presetId: "unrestricted",
rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
},
},
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await expect((unrestricted[0] as any).execute("run-allow", { query: "q" })).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: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
const blocked = await (locked[0] as any).execute("run-block", { query: "q" });
expect((blocked as any).isError).toBe(true);
expect((blocked as any).decision).toEqual(expect.objectContaining({
category: "network_api",
disposition: "block",
toolName: "fn_research_run",
}));
expect(execute).toHaveBeenCalledTimes(1);
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-research-1" });
const approval = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest,
findApprovalByDedupeKey: vi.fn().mockResolvedValue(null),
pauseForApproval: vi.fn(),
});
const pending = await (approval[0] as any).execute("run-approval", { query: "q" });
expect((pending as any).isError).toBe(true);
expect((pending as any).decision).toEqual(expect.objectContaining({
category: "network_api",
disposition: "require-approval",
toolName: "fn_research_run",
metadata: expect.objectContaining({ approvalRequestId: "apr-research-1" }),
}));
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledTimes(1);
});
it("creates request once and pauses once while pending", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });

View File

@@ -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, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, 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 { 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, and memory 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.
**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.
@@ -502,8 +502,12 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo
- fn_list_agents and fn_delegate_task
- fn_get_agent_config and fn_update_agent_config (for direct reports only)
- fn_agent_create and fn_agent_delete (for direct reports only)
- 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_ask_question to ask the dashboard user for structured clarification
- fn_web_fetch
- fn_memory_search, fn_memory_get, and fn_memory_append
- fn_heartbeat_done
@@ -2390,6 +2394,14 @@ export class HeartbeatMonitor {
sourceRunId: runContext?.runId,
}, { rootDir: this.rootDir }));
/*
FNXC:AgentTooling 2026-06-27-11:45:
No-task permanent agents must keep artifact registry parity with task-scoped heartbeat agents; artifact registrations can remain agent-authored or explicitly include a taskId, so withholding these tools would violate the permission-policy governance model.
*/
heartbeatTools.push(createArtifactRegisterTool(taskStore, agentId, this.messageStore));
heartbeatTools.push(createArtifactListTool(taskStore));
heartbeatTools.push(createArtifactViewTool(taskStore));
// Agent delegation tools
heartbeatTools.push(createListAgentsTool(this.store));
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir }));
@@ -2413,6 +2425,8 @@ export class HeartbeatMonitor {
if (this.reflectionService) {
heartbeatTools.push(createReflectOnPerformanceTool(this.reflectionService, agentId));
}
heartbeatTools.push(...this.createSharedHeartbeatWorkTools(taskStore));
} else {
// Task-scoped runs: full tool set including fn_task_log and document tools
// taskId is guaranteed to be defined here because isNoTaskRun = !taskId
@@ -3401,6 +3415,27 @@ export class HeartbeatMonitor {
// Heartbeat tools: createHeartbeatTools / clearRunState
// ─────────────────────────────────────────────────────────────────────────
/**
* FNXC:AgentTooling 2026-06-27-04:20:
* Permanent/custom heartbeat agents should receive the full safe coordination and work-discovery surface they may need; risky actions are governed at call time by AgentPermissionPolicy through wrapToolsWithActionGate, not by hiding tools from the session. Only expose mutating factories here when their tool names are classified by the action gate or are intentional benign coordination primitives.
*/
private createSharedHeartbeatWorkTools(taskStore: TaskStore): ToolDefinition[] {
const rootDir = this.rootDir ?? process.cwd();
const researchTools = createResearchTools({
store: taskStore,
rootDir,
getSettings: () => taskStore.getSettings(),
}).filter((tool) => tool.name !== "fn_research_cancel");
return [
createWorkflowListTool(taskStore),
createWorkflowGetTool(taskStore),
createTraitListTool(),
createAskQuestionTool(),
...researchTools,
];
}
/**
* Create the tool set for a heartbeat agent session.
*
@@ -3499,6 +3534,8 @@ export class HeartbeatMonitor {
tools.push(createReflectOnPerformanceTool(this.reflectionService, agentId));
}
tools.push(...this.createSharedHeartbeatWorkTools(taskStore));
return tools;
}