diff --git a/.changeset/fn-6712-agent-set-instructions-tool.md b/.changeset/fn-6712-agent-set-instructions-tool.md new file mode 100644 index 0000000000..28835efc9b --- /dev/null +++ b/.changeset/fn-6712-agent-set-instructions-tool.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add the `fn_agent_set_instructions` extension tool so managing agents can update direct or indirect reports' inline or file-backed instructions with org-hierarchy authorization. diff --git a/docs/agents.md b/docs/agents.md index a9ef5989d3..e506d82beb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -49,6 +49,18 @@ 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`). +## Agent instruction 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: + +- `agent_id` — target agent ID or resolvable agent name. +- `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`. + +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. + +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 Every first-class editable agent field has a defined create/edit/import/template behavior. This ensures consistent round-tripping across all surfaces. diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 2a76fe16a3..92293fc9ed 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -31,7 +31,7 @@ Mission → Milestone → Slice → Feature → Task - **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` - **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_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_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` - **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` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 34845be5d9..1ef669d999 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -418,6 +418,16 @@ Create a new non-ephemeral agent. | `max_concurrent_runs` | number | — | | | `message_response_mode` | union | — | | +### 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. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `agent_id` | string | ✓ | Target agent whose instructions to set | +| `instructions_text` | string | — | Inline instructions. Pass an empty string to clear. | +| `instructions_path` | string | — | Path to a markdown instructions file. Pass an empty string to clear. | + ### fn_agent_delete Delete a non-ephemeral agent. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 7430fc20b6..bfb8800676 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -67,6 +67,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_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_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_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_delegate_task` | Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs. | diff --git a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts new file mode 100644 index 0000000000..4b504c499b --- /dev/null +++ b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } 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 from "../extension.js"; + +function createMockAPI() { + const tools = new Map(); + 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; + agentStore: AgentStore; + ids: { manager: string; middle: string; leaf: string; peer: string }; + }) => Promise, +): Promise { + const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-instructions-")); + 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, + metadata: {}, + }); + const leaf = await agentStore.createAgent({ + name: "leaf-agent", + role: "executor", + reportsTo: middle.id, + metadata: {}, + }); + const peer = await agentStore.createAgent({ name: "peer-agent", role: "executor", metadata: {} }); + + const api = createMockAPI(); + kbExtension(api); + const tool = api.tools.get("fn_agent_set_instructions"); + expect(tool).toBeTruthy(); + + await run({ + cwd, + tool, + agentStore, + ids: { manager: manager.id, middle: middle.id, leaf: leaf.id, peer: peer.id }, + }); + } finally { + agentStore.close(); + await rm(cwd, { recursive: true, force: true }); + } +} + +describe("fn_agent_set_instructions", () => { + it("allows a manager to set inline instructions for a direct report", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-1", + { agent_id: ids.middle, instructions_text: "Direct report instructions" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.middle }); + expect(result.details.updatedFields).toEqual(["instructionsText"]); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "Direct report instructions", + }); + }); + }); + + it("allows a manager to set instructions for an indirect report", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-2", + { agent_id: ids.leaf, instructions_text: "Grandchild instructions" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.leaf }); + await expect(agentStore.getAgent(ids.leaf)).resolves.toMatchObject({ + instructionsText: "Grandchild instructions", + }); + }); + }); + + it("rejects peer or unrelated targets and leaves instructions unchanged", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + await agentStore.updateAgent(ids.peer, { instructionsText: "Original peer instructions" }); + + const result = await tool.execute( + "call-3", + { agent_id: ids.peer, instructions_text: "Unauthorized edit" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).toBe(true); + expect(result.details).toMatchObject({ outcome: "denied", rule: "direct-or-indirect-reports-only" }); + await expect(agentStore.getAgent(ids.peer)).resolves.toMatchObject({ + instructionsText: "Original peer instructions", + }); + }); + }); + + it("rejects self-targeting", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-4", + { agent_id: ids.manager, instructions_text: "Self edit" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("direct or indirect reports"); + expect((await agentStore.getAgent(ids.manager))?.instructionsText).toBeUndefined(); + }); + }); + + it("rejects upward edits from a subordinate to its manager", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-5", + { agent_id: ids.manager, instructions_text: "Upward edit" }, + undefined, + undefined, + { cwd, agentId: ids.leaf }, + ); + + expect(result.isError).toBe(true); + expect(result.details).toMatchObject({ outcome: "denied", rule: "direct-or-indirect-reports-only" }); + expect((await agentStore.getAgent(ids.manager))?.instructionsText).toBeUndefined(); + }); + }); + + it("allows privileged user calls without ctx.agentId to update any agent", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-6", + { agent_id: ids.peer, instructions_text: "Privileged user edit" }, + undefined, + undefined, + { cwd }, + ); + + expect(result.isError).not.toBe(true); + await expect(agentStore.getAgent(ids.peer)).resolves.toMatchObject({ + instructionsText: "Privileged user edit", + }); + }); + }); + + it("sets instructions_path without changing text and clears fields with explicit empty strings", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + await agentStore.updateAgent(ids.middle, { + instructionsText: "Keep this text", + instructionsPath: "old.md", + }); + + const setPathResult = await tool.execute( + "call-7", + { agent_id: ids.middle, instructions_path: "new.md" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(setPathResult.isError).not.toBe(true); + expect(setPathResult.details.updatedFields).toEqual(["instructionsPath"]); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "Keep this text", + instructionsPath: "new.md", + }); + + const clearResult = await tool.execute( + "call-8", + { agent_id: ids.middle, instructions_text: "", instructions_path: "" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(clearResult.isError).not.toBe(true); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "", + instructionsPath: "", + }); + }); + }); + + it("returns validation errors for missing agents and omitted instruction fields", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const missingTarget = await tool.execute( + "call-9", + { agent_id: "agent-does-not-exist", instructions_text: "No target" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + expect(missingTarget.isError).toBe(true); + expect(missingTarget.details.outcome).toBe("not_found"); + + const missingFields = await tool.execute( + "call-10", + { agent_id: ids.middle }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + expect(missingFields.isError).toBe(true); + expect(missingFields.details.outcome).toBe("invalid"); + expect((await agentStore.getAgent(ids.middle))?.instructionsText).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 71981c957c..c50b590c9d 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -3916,6 +3916,100 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + // ── fn_agent_set_instructions ─────────────────────────────────── + + /** + * FNXC:AgentManagement 2026-06-19-06:58: + * Managing agents need a scoped runtime tool for updating a direct or indirect report's operating instructions without granting peer, ancestor, or self-mutation rights. + * The no-agent caller path remains privileged for CLI/user control, while agent callers must appear as an ancestor in the target's chain of command so AgentStore config revisions preserve an auditable record of each instruction edit. + */ + pi.registerTool({ + name: "fn_agent_set_instructions", + label: "fn: Set Agent Instructions", + description: + "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.", + promptSnippet: "Update operating instructions for an agent in your management subtree", + promptGuidelines: [ + "Use to update operating instructions for an agent in your management subtree", + "You can only target your own direct or indirect reports, not yourself, peers, or ancestors", + "Provide instructions_text, instructions_path, or both; use an explicit empty string to clear a field", + ], + parameters: Type.Object({ + agent_id: Type.String({ description: "Target agent whose instructions to set" }), + instructions_text: Type.Optional( + Type.String({ description: "Inline instructions. Pass an empty string to clear." }), + ), + instructions_path: Type.Optional( + Type.String({ description: "Path to a markdown instructions file. Pass an empty string to clear." }), + ), + }), + 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 hasInstructionsText = params.instructions_text !== undefined; + const hasInstructionsPath = params.instructions_path !== undefined; + if (!hasInstructionsText && !hasInstructionsPath) { + return { + content: [{ type: "text" as const, text: "ERROR: Provide instructions_text and/or instructions_path to update agent instructions." }], + isError: true, + details: { outcome: "invalid", error: "instructions_text or instructions_path is required" }, + }; + } + + const target = 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 }, + }; + } + + const fnCtx = ctx as typeof ctx & { agentId?: string }; + const callerAgentId = fnCtx.agentId; + if (callerAgentId) { + if (callerAgentId === target.id) { + return { + content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports, not yourself." }], + isError: true, + details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" }, + }; + } + + const chain = await agentStore.getChainOfCommand(target.id); + const callerIndex = chain.findIndex((agent) => agent.id === callerAgentId); + if (callerIndex < 1) { + return { + content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports." }], + isError: true, + details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" }, + }; + } + } + + const updatedFields: string[] = []; + if (hasInstructionsText) updatedFields.push("instructionsText"); + if (hasInstructionsPath) updatedFields.push("instructionsPath"); + + const updated = await agentStore.updateAgent(target.id, { + ...(hasInstructionsText ? { instructionsText: params.instructions_text } : {}), + ...(hasInstructionsPath ? { instructionsPath: params.instructions_path } : {}), + }); + + return { + content: [{ + type: "text" as const, + text: `Updated ${updated.name} (${updated.id}) instructions: ${updatedFields.join(", ")}`, + }], + details: { outcome: "updated", agentId: updated.id, updatedFields }, + }; + }, + }); + // ── fn_agent_delete ───────────────────────────────────────────── pi.registerTool({