FN-7440: add agent update tool

Add a Fusion extension tool for updating existing agent configuration in place.

- Register fn_agent_update with editable agent fields, validation, and non-ephemeral guards.
- Enforce org-scoped authorization for agent callers and privileged manager-clearing semantics for operators.
- Cover update success, hierarchy denial, cycle prevention, and runtime/instruction edits in extension tests.
- Document the new tool in agent and Fusion skill references and add a published package changeset.

Files changed:
 .changeset/fn-7440-agent-update-tool.md            |   7 +
 docs/agents.md                                     |  21 +-
 package.json                                       |   1 +
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |  21 ++
 .../skill/fusion/references/fusion-capabilities.md |   1 +
 .../src/__tests__/extension-agent-update.test.ts   | 351 +++++++++++++++++++++
 packages/cli/src/extension.ts                      | 230 ++++++++++++++
 pnpm-lock.yaml                                     |   1 +
 9 files changed, 628 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7440

Fusion-Task-Lineage: a6f4c928-5a63-4cf1-aa0b-4e38b536e965

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 13:16:07 -07:00
parent aa8f1f32ee
commit 91fb53f3c7
9 changed files with 628 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let chat update existing agents without delete/recreate.
category: feature
dev: Adds the fn_agent_update Pi extension tool for scoped AgentStore.updateAgent config edits.

View File

@@ -72,17 +72,26 @@ printf "deploy report" | fn chat agent-abc123 --once --non-interactive
> Replies require a running engine for the same project (for example `fn` dashboard or `fn serve`). > Replies require a running engine for the same project (for example `fn` dashboard or `fn serve`).
## Agent instruction updates from agents ## Agent configuration updates from agents
The `fn_agent_set_instructions` extension tool lets a managing agent update a report's operating instructions without opening the dashboard. It accepts: The `fn_agent_update` extension tool lets chat/extension callers update existing non-ephemeral agents in place instead of deleting and recreating them. It accepts `agent_id` plus any editable subset of:
- Identity fields: `name`, `role` (`triage`, `executor`, `reviewer`, `merger`, `engineer`, `custom`), `title`, `icon`, and `soul`.
- Instruction fields: `instructions_text`, `instructions_path`, and `heartbeat_procedure_path`.
- Hierarchy field: `reportsTo` as a manager agent ID/name; privileged CLI/user calls may pass `reportsTo: ""` to clear the manager.
- Heartbeat/runtime fields: `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, and `message_response_mode` (`immediate` or `on-heartbeat`). Runtime updates merge into the existing `runtimeConfig` and preserve unrelated keys.
At least one update field must be provided. The tool rejects missing targets, ephemeral/runtime agents, self-targeting, string/number limit violations (`soul` 10,000 chars, `instructions_text` 50,000 chars, path fields 500 chars, heartbeat interval ≥1000ms, heartbeat timeout ≥5000ms, max concurrent runs ≥1), missing managers, self-manager assignments, and hierarchy cycles before mutating storage. Successful calls persist through one `AgentStore.updateAgent` call, so normal config revision history records the full edit.
Authorization is scoped to the org hierarchy. When the caller is an agent (`ctx.agentId` is present), the target must already be one of that caller's direct or indirect reports; self-targeting, peer/unrelated targets, and ancestors are rejected. Reparenting must stay inside the caller's subtree: the new manager may be the caller or one of the caller's direct/indirect reports, but not an unrelated agent or ancestor. Direct CLI/user calls that do not carry `ctx.agentId` are treated as privileged operator actions and may update any non-ephemeral agent, including clearing `reportsTo`.
The legacy `fn_agent_set_instructions` extension tool remains available for backward compatibility and narrower instruction-only edits. It accepts:
- `agent_id` — target agent ID or resolvable agent name. - `agent_id` — target agent ID or resolvable agent name.
- `instructions_text` — optional inline instructions; pass an explicit empty string to clear `instructionsText`. - `instructions_text` — optional inline instructions; pass an explicit empty string to clear `instructionsText`.
- `instructions_path` — optional markdown file path; pass an explicit empty string to clear `instructionsPath`. - `instructions_path` — optional markdown file path; pass an explicit empty string to clear `instructionsPath`.
At least one instruction field must be provided. The tool persists changes through `AgentStore.updateAgent`, so instruction edits are captured as normal agent config revisions. At least one instruction field must be provided. The legacy tool uses the same direct/indirect-report authorization model for agent callers and persists changes through `AgentStore.updateAgent`, so instruction edits are captured as normal agent config revisions.
Authorization is scoped to the org hierarchy. When the caller is an agent (`ctx.agentId` is present), the target must be one of that caller's direct or indirect reports; self-targeting, peer/unrelated targets, and ancestors are rejected. Direct CLI/user calls that do not carry `ctx.agentId` are treated as privileged operator actions and may update any agent.
## Agent Field Parity Matrix ## Agent Field Parity Matrix
@@ -187,7 +196,7 @@ Approval pause/resume lifecycle (FN-3548):
Agent provisioning approvals (`agent_provisioning` category): Agent provisioning approvals (`agent_provisioning` category):
- `fn_agent_create` / `fn_agent_delete` can return `pending_approval` under `projectSettings.agentProvisioning` policy (`approvalMode`, trusted roles/IDs, `alwaysApproveDelete`). - `fn_agent_create` / `fn_agent_delete` can return `pending_approval` under `projectSettings.agentProvisioning` policy (`approvalMode`, trusted roles/IDs, `alwaysApproveDelete`). `fn_agent_update` is an in-place configuration edit for existing agents, so it uses org-hierarchy authorization and `AgentStore.updateAgent` revision auditing rather than the create/delete provisioning approval policy.
- Dashboard surface: Project Settings → Agent Permissions → **Agent Provisioning Approvals** editor (project-scoped only). - Dashboard surface: Project Settings → Agent Permissions → **Agent Provisioning Approvals** editor (project-scoped only).
- Approval request is persisted with provisioning context (`tool` + `params`) and visible in mailbox/API approval queues. - Approval request is persisted with provisioning context (`tool` + `params`) and visible in mailbox/API approval queues.
- Dashboard/API decision route `POST /api/approvals/:id/decision` executes deferred provisioning on `approve` via engine dispatcher (`executeApprovedAgentProvisioning`) and never executes on `deny`. - Dashboard/API decision route `POST /api/approvals/:id/decision` executes deferred provisioning on `approve` via engine dispatcher (`executeApprovedAgentProvisioning`) and never executes on `deny`.

View File

@@ -94,6 +94,7 @@
"protobufjs" "protobufjs"
], ],
"overrides": { "overrides": {
"@aws-sdk/core": "3.974.26",
"@types/node": "^25.5.2", "@types/node": "^25.5.2",
"protobufjs": "^7.5.8" "protobufjs": "^7.5.8"
} }

View File

@@ -32,7 +32,7 @@ Mission → Milestone → Slice → Feature → Task
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_update`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
- **Skills tools** — `fn_skills_search`, `fn_skills_install` - **Skills tools** — `fn_skills_search`, `fn_skills_install`
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show` - **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
- **Other tools** — `fn_web_fetch`, `fn_secret_get`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`, `fn_experiment_finalize` - **Other tools** — `fn_web_fetch`, `fn_secret_get`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`, `fn_experiment_finalize`

View File

@@ -555,6 +555,27 @@ Create a new non-ephemeral agent.
| `max_concurrent_runs` | number | — | | | `max_concurrent_runs` | number | — | |
| `message_response_mode` | union | — | | | `message_response_mode` | union | — | |
### fn_agent_update
Update editable configuration for an existing non-ephemeral agent. Agent callers can only update direct or indirect reports inside their management subtree; user/operator calls are privileged.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | ✓ | Target agent ID or name to update |
| `name` | string | — | New display name |
| `role` | union | — | Agent role/capability |
| `title` | string | — | Optional title shown for the agent |
| `icon` | string | — | Optional compact icon/emoji |
| `soul` | string | — | Agent personality/identity text |
| `instructions_text` | string | — | Inline custom instructions |
| `instructions_path` | string | — | Path to instructions markdown |
| `heartbeat_procedure_path` | string | — | Path to heartbeat procedure markdown |
| `reportsTo` | string | — | Manager agent ID/name. Pass empty string to clear for privileged user/operator calls. |
| `heartbeat_interval_ms` | number | — | Heartbeat polling interval in ms |
| `heartbeat_timeout_ms` | number | — | Heartbeat timeout in ms |
| `max_concurrent_runs` | number | — | Max concurrent heartbeat runs |
| `message_response_mode` | union | — | How agent responds to messages |
### fn_agent_set_instructions ### fn_agent_set_instructions
Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision. Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision.

View File

@@ -81,6 +81,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. | | `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. |
| `fn_agent_start` | Start a stopped agent — resumes its execution. Transitions the agent from paused to active state. | | `fn_agent_start` | Start a stopped agent — resumes its execution. Transitions the agent from paused to active state. |
| `fn_agent_create` | Create a new non-ephemeral agent. | | `fn_agent_create` | Create a new non-ephemeral agent. |
| `fn_agent_update` | Update editable configuration for an existing non-ephemeral agent. Agent callers can only update direct or indirect reports inside their management subtree; user/operator calls are privileged. |
| `fn_agent_set_instructions` | Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision. | | `fn_agent_set_instructions` | Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision. |
| `fn_agent_delete` | Delete a non-ephemeral agent. | | `fn_agent_delete` | Delete a non-ephemeral agent. |
| `fn_list_agents` | List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work. | | `fn_list_agents` | List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work. |

View File

@@ -0,0 +1,351 @@
import { describe, it, expect, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
import kbExtension, { closeCachedStores } from "../extension.js";
function createMockAPI() {
const tools = new Map<string, any>();
return {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
async function withOrg(
run: (ctx: {
cwd: string;
tool: any;
setInstructionsTool: any;
agentStore: AgentStore;
ids: {
manager: string;
middle: string;
leaf: string;
sibling: string;
peer: string;
ephemeral: string;
};
}) => Promise<void>,
): Promise<void> {
const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-update-"));
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
try {
await agentStore.init();
const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} });
const middle = await agentStore.createAgent({
name: "middle-manager",
role: "engineer",
reportsTo: manager.id,
runtimeConfig: { preservedFlag: true },
metadata: {},
});
const leaf = await agentStore.createAgent({
name: "leaf-agent",
role: "executor",
reportsTo: middle.id,
metadata: {},
});
const sibling = await agentStore.createAgent({
name: "sibling-manager",
role: "engineer",
reportsTo: manager.id,
metadata: {},
});
const peer = await agentStore.createAgent({ name: "peer-agent", role: "executor", metadata: {} });
const ephemeral = await agentStore.createAgent({
name: "executor-runtime",
role: "executor",
metadata: { agentKind: "task-worker" },
});
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_agent_update");
const setInstructionsTool = api.tools.get("fn_agent_set_instructions");
expect(tool).toBeTruthy();
expect(setInstructionsTool).toBeTruthy();
await run({
cwd,
tool,
setInstructionsTool,
agentStore,
ids: {
manager: manager.id,
middle: middle.id,
leaf: leaf.id,
sibling: sibling.id,
peer: peer.id,
ephemeral: ephemeral.id,
},
});
} finally {
await closeCachedStores();
agentStore.close();
await rm(cwd, { recursive: true, force: true });
}
}
describe("fn_agent_update", () => {
it("allows privileged operator calls to update config fields and preserve runtime keys", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const updateSpy = vi.spyOn(AgentStore.prototype, "updateAgent");
const result = await tool.execute(
"call-1",
{
agent_id: ids.middle,
role: "reviewer",
instructions_text: "Review thoroughly.",
instructions_path: "docs/reviewer.md",
reportsTo: ids.peer,
heartbeat_interval_ms: 2000,
heartbeat_timeout_ms: 6000,
max_concurrent_runs: 2,
message_response_mode: "on-heartbeat",
},
undefined,
undefined,
{ cwd },
);
expect(result.isError).not.toBe(true);
expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.middle });
expect(result.details.updatedFields).toEqual([
"role",
"instructionsText",
"instructionsPath",
"reportsTo",
"runtimeConfig",
]);
expect(updateSpy).toHaveBeenCalledTimes(1);
await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({
role: "reviewer",
instructionsText: "Review thoroughly.",
instructionsPath: "docs/reviewer.md",
reportsTo: ids.peer,
});
const persisted = await agentStore.getAgent(ids.middle);
expect(persisted?.runtimeConfig).toMatchObject({
preservedFlag: true,
heartbeatIntervalMs: 2000,
heartbeatTimeoutMs: 6000,
maxConcurrentRuns: 2,
messageResponseMode: "on-heartbeat",
});
expect(result.details.agent).toMatchObject({ id: ids.middle, role: "reviewer" });
updateSpy.mockRestore();
});
});
it("allows managers to edit direct and indirect reports", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const direct = await tool.execute(
"call-2",
{ agent_id: ids.middle, instructions_text: "Direct report update" },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(direct.isError).not.toBe(true);
const indirect = await tool.execute(
"call-3",
{ agent_id: ids.leaf, soul: "Indirectly managed specialist" },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(indirect.isError).not.toBe(true);
await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ instructionsText: "Direct report update" });
await expect(agentStore.getAgent(ids.leaf)).resolves.toMatchObject({ soul: "Indirectly managed specialist" });
});
});
it("rejects peer, unrelated, self, and ancestor edits without mutating targets", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
await agentStore.updateAgent(ids.peer, { instructionsText: "peer original" });
await agentStore.updateAgent(ids.manager, { soul: "manager original" });
const peerResult = await tool.execute(
"call-4",
{ agent_id: ids.peer, instructions_text: "peer edit" },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(peerResult.isError).toBe(true);
expect(peerResult.details).toMatchObject({ outcome: "denied", rule: "direct-or-indirect-reports-only" });
const selfResult = await tool.execute(
"call-5",
{ agent_id: ids.manager, soul: "self edit" },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(selfResult.isError).toBe(true);
const ancestorResult = await tool.execute(
"call-6",
{ agent_id: ids.manager, soul: "ancestor edit" },
undefined,
undefined,
{ cwd, agentId: ids.leaf },
);
expect(ancestorResult.isError).toBe(true);
await expect(agentStore.getAgent(ids.peer)).resolves.toMatchObject({ instructionsText: "peer original" });
await expect(agentStore.getAgent(ids.manager)).resolves.toMatchObject({ soul: "manager original" });
});
});
it("validates reportsTo targets, cycle prevention, and subtree reparenting", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const missingManager = await tool.execute(
"call-7",
{ agent_id: ids.leaf, reportsTo: "missing-manager" },
undefined,
undefined,
{ cwd },
);
expect(missingManager.isError).toBe(true);
expect(missingManager.details).toMatchObject({ outcome: "invalid", field: "reportsTo" });
const selfManager = await tool.execute(
"call-8",
{ agent_id: ids.leaf, reportsTo: ids.leaf },
undefined,
undefined,
{ cwd },
);
expect(selfManager.isError).toBe(true);
const cycle = await tool.execute(
"call-9",
{ agent_id: ids.manager, reportsTo: ids.leaf },
undefined,
undefined,
{ cwd },
);
expect(cycle.isError).toBe(true);
expect(cycle.details.error).toContain("cycle");
const outsideSubtree = await tool.execute(
"call-10",
{ agent_id: ids.leaf, reportsTo: ids.peer },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(outsideSubtree.isError).toBe(true);
expect(outsideSubtree.details).toMatchObject({ rule: "reparent-within-subtree-only" });
const insideSubtree = await tool.execute(
"call-11",
{ agent_id: ids.leaf, reportsTo: ids.sibling },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(insideSubtree.isError).not.toBe(true);
await expect(agentStore.getAgent(ids.leaf)).resolves.toMatchObject({ reportsTo: ids.sibling });
});
});
it("allows privileged operator calls to clear reportsTo explicitly", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const result = await tool.execute(
"call-12",
{ agent_id: ids.leaf, reportsTo: "" },
undefined,
undefined,
{ cwd },
);
expect(result.isError).not.toBe(true);
expect((await agentStore.getAgent(ids.leaf))?.reportsTo).toBeUndefined();
});
});
it("rejects invalid and no-op inputs before successful mutation", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const updateSpy = vi.spyOn(AgentStore.prototype, "updateAgent");
const original = await agentStore.getAgent(ids.middle);
const missingTarget = await tool.execute(
"call-13",
{ agent_id: "missing-agent", soul: "No target" },
undefined,
undefined,
{ cwd },
);
expect(missingTarget.isError).toBe(true);
expect(missingTarget.details.outcome).toBe("not_found");
const noFields = await tool.execute("call-14", { agent_id: ids.middle }, undefined, undefined, { cwd });
expect(noFields.isError).toBe(true);
expect(noFields.details).toMatchObject({ outcome: "invalid", field: "fields" });
const ephemeral = await tool.execute(
"call-15",
{ agent_id: ids.ephemeral, soul: "Should fail" },
undefined,
undefined,
{ cwd },
);
expect(ephemeral.isError).toBe(true);
expect(ephemeral.details.error).toContain("ephemeral");
for (const params of [
{ soul: "s".repeat(10001) },
{ instructions_text: "i".repeat(50001) },
{ instructions_path: "p".repeat(501) },
{ heartbeat_procedure_path: "h".repeat(501) },
{ heartbeat_interval_ms: 999 },
{ heartbeat_timeout_ms: 4999 },
{ max_concurrent_runs: 0 },
]) {
const result = await tool.execute(
"call-invalid",
{ agent_id: ids.middle, ...params },
undefined,
undefined,
{ cwd },
);
expect(result.isError).toBe(true);
expect(result.details.outcome).toBe("invalid");
}
expect(updateSpy).not.toHaveBeenCalled();
expect(await agentStore.getAgent(ids.middle)).toMatchObject({
role: original?.role,
reportsTo: original?.reportsTo,
runtimeConfig: original?.runtimeConfig,
});
updateSpy.mockRestore();
});
});
it("keeps the legacy instruction-only tool functional", async () => {
await withOrg(async ({ cwd, setInstructionsTool, agentStore, ids }) => {
const result = await setInstructionsTool.execute(
"call-16",
{ agent_id: ids.middle, instructions_text: "Legacy update still works" },
undefined,
undefined,
{ cwd, agentId: ids.manager },
);
expect(result.isError).not.toBe(true);
await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({
instructionsText: "Legacy update still works",
});
});
});
});

View File

@@ -18,8 +18,11 @@ import {
type InsightRunTrigger, type InsightRunTrigger,
type ResearchRun, type ResearchRun,
type ResearchRunStatus, type ResearchRunStatus,
type AgentCapability,
type AgentUpdateInput,
RESEARCH_RUN_STATUSES, RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled, isResearchExperimentalEnabled,
isEphemeralAgent,
resolveResearchSettings, resolveResearchSettings,
canAgentTakeImplementationTaskForExplicitRouting, canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason, formatRoleMismatchReason,
@@ -4235,6 +4238,233 @@ export default function kbExtension(pi: ExtensionAPI) {
}, },
}); });
// ── fn_agent_update ─────────────────────────────────────────────
/**
* FNXC:AgentManagement 2026-07-02-12:00:
* Chat operators need broad in-place edits for existing non-ephemeral agent configuration so they can change role, instructions, manager links, and heartbeat settings without delete/recreate churn.
* Keep the update org-scoped for agent callers and funnel successful edits through one AgentStore.updateAgent call so hierarchy checks and config revisions stay auditable.
*/
pi.registerTool({
name: "fn_agent_update",
label: "fn: Update Agent",
description:
"Update editable configuration for an existing non-ephemeral agent. " +
"Agent callers can only update direct or indirect reports inside their management subtree; user/operator calls are privileged.",
promptSnippet: "Update an existing Fusion agent without deleting and recreating it",
promptGuidelines: [
"Use to update editable agent configuration such as role, instructions, manager, and heartbeat settings",
"Agent callers can only target direct or indirect reports, never themselves, peers, ancestors, or unrelated agents",
"Use reportsTo as an agent ID/name for a new manager; privileged user/operator calls may pass reportsTo: \"\" to clear the manager",
],
parameters: Type.Object({
agent_id: Type.String({ description: "Target agent ID or name to update" }),
name: Type.Optional(Type.String({ description: "New display name" })),
role: Type.Optional(Type.Union([
Type.Literal("triage"),
Type.Literal("executor"),
Type.Literal("reviewer"),
Type.Literal("merger"),
Type.Literal("engineer"),
Type.Literal("custom"),
], { description: "Agent role/capability" })),
title: Type.Optional(Type.String({ description: "Optional title shown for the agent" })),
icon: Type.Optional(Type.String({ description: "Optional compact icon/emoji" })),
soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })),
instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions", maxLength: 50000 })),
instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown", maxLength: 500 })),
heartbeat_procedure_path: Type.Optional(Type.String({ description: "Path to heartbeat procedure markdown", maxLength: 500 })),
reportsTo: Type.Optional(Type.String({ description: "Manager agent ID/name. Pass empty string to clear for privileged user/operator calls." })),
heartbeat_interval_ms: Type.Optional(Type.Number({ minimum: 1000, description: "Heartbeat polling interval in ms" })),
heartbeat_timeout_ms: Type.Optional(Type.Number({ minimum: 5000, description: "Heartbeat timeout in ms" })),
max_concurrent_runs: Type.Optional(Type.Number({ minimum: 1, description: "Max concurrent heartbeat runs" })),
message_response_mode: Type.Optional(Type.Union([
Type.Literal("immediate"),
Type.Literal("on-heartbeat"),
], { description: "How agent responds to messages" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const updateParamKeys = [
"name",
"role",
"title",
"icon",
"soul",
"instructions_text",
"instructions_path",
"heartbeat_procedure_path",
"reportsTo",
"heartbeat_interval_ms",
"heartbeat_timeout_ms",
"max_concurrent_runs",
"message_response_mode",
] as const;
const providedKeys = updateParamKeys.filter((key) => params[key] !== undefined);
const invalid = (field: string, message: string, extra: Record<string, unknown> = {}) => ({
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
isError: true,
details: { outcome: "invalid", field, error: message, ...extra },
});
const denied = (message: string, extra: Record<string, unknown> = {}) => ({
content: [{ type: "text" as const, text: `ERROR: ${message}` }],
isError: true,
details: { outcome: "denied", error: message, ...extra },
});
if (providedKeys.length === 0) {
return invalid("fields", "Provide at least one field to update");
}
if (params.soul !== undefined && params.soul.length > 10000) {
return invalid("soul", "soul exceeds 10000 character limit");
}
if (params.instructions_text !== undefined && params.instructions_text.length > 50000) {
return invalid("instructions_text", "instructions_text exceeds 50000 character limit");
}
if (params.instructions_path !== undefined && params.instructions_path.length > 500) {
return invalid("instructions_path", "instructions_path exceeds 500 character limit");
}
if (params.heartbeat_procedure_path !== undefined && params.heartbeat_procedure_path.length > 500) {
return invalid("heartbeat_procedure_path", "heartbeat_procedure_path exceeds 500 character limit");
}
if (params.heartbeat_interval_ms !== undefined && params.heartbeat_interval_ms < 1000) {
return invalid("heartbeat_interval_ms", "heartbeat_interval_ms must be at least 1000");
}
if (params.heartbeat_timeout_ms !== undefined && params.heartbeat_timeout_ms < 5000) {
return invalid("heartbeat_timeout_ms", "heartbeat_timeout_ms must be at least 5000");
}
if (params.max_concurrent_runs !== undefined && params.max_concurrent_runs < 1) {
return invalid("max_concurrent_runs", "max_concurrent_runs must be at least 1");
}
const target = (await agentStore.getAgent(params.agent_id)) ?? (await agentStore.resolveAgent(params.agent_id));
if (!target) {
return {
content: [{ type: "text" as const, text: `Agent '${params.agent_id}' not found` }],
isError: true,
details: { outcome: "not_found", error: "Agent not found", agentId: params.agent_id },
};
}
if (isEphemeralAgent(target)) {
return invalid("agent_id", `Cannot update ephemeral/runtime agent ${target.id}`, { agentId: target.id });
}
const fnCtx = ctx as typeof ctx & { agentId?: string };
const callerAgentId = fnCtx.agentId;
const targetChain = await agentStore.getChainOfCommand(target.id);
if (callerAgentId) {
if (callerAgentId === target.id) {
return denied("You can only update your own direct or indirect reports, not yourself.", {
agentId: target.id,
callerAgentId,
rule: "direct-or-indirect-reports-only",
});
}
const callerIndex = targetChain.findIndex((agent) => agent.id === callerAgentId);
if (callerIndex < 1) {
return denied("You can only update your own direct or indirect reports.", {
agentId: target.id,
callerAgentId,
rule: "direct-or-indirect-reports-only",
});
}
}
let resolvedReportsTo: string | undefined;
let managerForCycleCheck: string | undefined;
if (params.reportsTo !== undefined) {
if (params.reportsTo === "") {
if (callerAgentId) {
return denied("Only privileged user/operator calls can clear an agent's manager.", {
agentId: target.id,
callerAgentId,
rule: "privileged-clear-manager-only",
});
}
resolvedReportsTo = undefined;
} else {
const manager = await agentStore.resolveAgent(params.reportsTo);
if (!manager) {
return invalid("reportsTo", `Manager '${params.reportsTo}' not found`, { agentId: target.id });
}
if (manager.id === target.id) {
return invalid("reportsTo", "An agent cannot report to itself", { agentId: target.id });
}
const managerChain = await agentStore.getChainOfCommand(manager.id);
if (managerChain.some((agent) => agent.id === target.id)) {
return invalid("reportsTo", "reportsTo would create a management cycle", {
agentId: target.id,
managerId: manager.id,
});
}
if (callerAgentId) {
const managerCallerIndex = managerChain.findIndex((agent) => agent.id === callerAgentId);
if (manager.id !== callerAgentId && managerCallerIndex < 1) {
return denied("You can only reparent reports to yourself or another agent in your management subtree.", {
agentId: target.id,
callerAgentId,
managerId: manager.id,
rule: "reparent-within-subtree-only",
});
}
}
resolvedReportsTo = manager.id;
managerForCycleCheck = manager.id;
}
}
const hasRuntimeConfigUpdates = [
params.heartbeat_interval_ms,
params.heartbeat_timeout_ms,
params.max_concurrent_runs,
params.message_response_mode,
].some((value) => value !== undefined);
const updateInput: AgentUpdateInput = {};
const updatedFields: string[] = [];
const setField = <K extends keyof AgentUpdateInput>(field: K, value: AgentUpdateInput[K]) => {
updateInput[field] = value;
updatedFields.push(String(field));
};
if (params.name !== undefined) setField("name", params.name);
if (params.role !== undefined) setField("role", params.role as AgentCapability);
if (params.title !== undefined) setField("title", params.title);
if (params.icon !== undefined) setField("icon", params.icon);
if (params.soul !== undefined) setField("soul", params.soul);
if (params.instructions_text !== undefined) setField("instructionsText", params.instructions_text);
if (params.instructions_path !== undefined) setField("instructionsPath", params.instructions_path);
if (params.heartbeat_procedure_path !== undefined) setField("heartbeatProcedurePath", params.heartbeat_procedure_path);
if (params.reportsTo !== undefined) {
setField("reportsTo", resolvedReportsTo);
}
if (hasRuntimeConfigUpdates) {
setField("runtimeConfig", {
...((target.runtimeConfig ?? {}) as Record<string, unknown>),
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
});
}
if (managerForCycleCheck && managerForCycleCheck === target.id) {
return invalid("reportsTo", "An agent cannot report to itself", { agentId: target.id });
}
const updated = await agentStore.updateAgent(target.id, updateInput);
return {
content: [{
type: "text" as const,
text: `Updated ${updated.name} (${updated.id}): ${updatedFields.join(", ")}`,
}],
details: { outcome: "updated", agentId: updated.id, updatedFields, agent: updated },
};
},
});
// ── fn_agent_set_instructions ─────────────────────────────────── // ── fn_agent_set_instructions ───────────────────────────────────
/** /**

1
pnpm-lock.yaml generated
View File

@@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
'@aws-sdk/core': 3.974.26
'@types/node': ^25.5.2 '@types/node': ^25.5.2
protobufjs: ^7.5.8 protobufjs: ^7.5.8