FN-6712: add managed agent instructions tool
Add a scoped extension tool for managers to update report instructions through AgentStore. - Register fn_agent_set_instructions with validation, hierarchy authorization, and config-revision persistence. - Cover direct, indirect, privileged, denied, missing-target, and field-clearing behaviors with CLI extension tests. - Document the new agent-management tool in agent docs, Fusion skill references, and a published package changeset. Files changed: .changeset/fn-6712-agent-set-instructions-tool.md | 5 + docs/agents.md | 12 ++ packages/cli/skill/fusion/SKILL.md | 2 +- .../cli/skill/fusion/references/extension-tools.md | 10 + .../skill/fusion/references/fusion-capabilities.md | 1 + .../extension-agent-set-instructions.test.ts | 235 +++++++++++++++++++++ packages/cli/src/extension.ts | 94 +++++++++ 7 files changed, 358 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-6712 Fusion-Task-Lineage: 1fac1ff0-283d-4911-8fbf-f15c5de6b902
This commit is contained in:
5
.changeset/fn-6712-agent-set-instructions-tool.md
Normal file
5
.changeset/fn-6712-agent-set-instructions-tool.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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<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;
|
||||
agentStore: AgentStore;
|
||||
ids: { manager: string; middle: string; leaf: string; peer: string };
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user