FN-5652: add goal retrieval tool for agent execution
Expose goal details to agents during task execution workflows. - add fn_goal_show tool to the CLI extension with goal lookup, formatted output, and not-found error details - add focused tests for goal tool registration and execution behavior - mark fn_goal_show as execution-safe in engine gating classifications and test coverage - update Fusion skill/reference docs to include the new goal retrieval capability - add a changeset for @runfusion/fusion patch release Files changed: .changeset/fn-5652-goal-show.md | 7 + packages/cli/skill/fusion/SKILL.md | 2 +- .../cli/skill/fusion/references/extension-tools.md | 16 +++ .../skill/fusion/references/fusion-capabilities.md | 1 + .../cli/src/__tests__/extension-goal-tools.test.ts | 148 +++++++++++++++++++++ packages/cli/src/extension.ts | 43 ++++++ .../src/__tests__/gating-classifications.test.ts | 7 + packages/engine/src/gating-classifications.ts | 2 + 8 files changed, 225 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-5652 Fusion-Task-Lineage: 98c2c417-0887-4794-81b1-6b47ab0f708f
This commit is contained in:
@@ -30,7 +30,7 @@ Mission → Milestone → Slice → Feature → Task
|
||||
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
|
||||
- **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_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
|
||||
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`
|
||||
- **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`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
|
||||
|
||||
@@ -301,6 +301,14 @@ Archive a goal by ID.
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Goal ID (G-…) to archive |
|
||||
|
||||
### fn_goal_show
|
||||
|
||||
Show full details for a single goal by ID.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Goal ID (G-…) |
|
||||
|
||||
## Agent Tools
|
||||
|
||||
### fn_agent_stop
|
||||
@@ -523,6 +531,14 @@ Group kept experiment runs into reviewable branches and finalize the session. Us
|
||||
| `summary` | string | — | Optional finalize summary |
|
||||
|
||||
<!-- END: extension-tools -->
|
||||
|
||||
## Goal retrieval contract notes
|
||||
|
||||
### Goal-show response contract
|
||||
|
||||
- **Success:** returns `details.goal` as the complete goal JSON object (`id`, `title`, optional `description`, `status`, `createdAt`, `updatedAt`) and `content[0].text` with those same human-readable fields.
|
||||
- **Not found:** returns `isError: true` with `details.code: "GOAL_NOT_FOUND"` and `details.goalId` set to the requested id.
|
||||
|
||||
## Dashboard Command
|
||||
|
||||
### /fn
|
||||
|
||||
@@ -46,6 +46,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_goal_list` | List goals by status with active-goal warning details. |
|
||||
| `fn_goal_create` | Create a new project goal. |
|
||||
| `fn_goal_archive` | Archive a goal by ID. |
|
||||
| `fn_goal_show` | Show full details for a single goal by ID. |
|
||||
| `fn_mission_show` | Show mission details with full hierarchy: milestones → slices → features. |
|
||||
| `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. |
|
||||
| `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. |
|
||||
|
||||
148
packages/cli/src/__tests__/extension-goal-tools.test.ts
Normal file
148
packages/cli/src/__tests__/extension-goal-tools.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension from "../extension.js";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
|
||||
const api = {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
};
|
||||
|
||||
return api as any;
|
||||
}
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
describe("extension goal retrieval tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-goal-tools-"));
|
||||
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers fn_goal_show with expected schema", () => {
|
||||
const tool = api.tools.get("fn_goal_show");
|
||||
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.name).toBe("fn_goal_show");
|
||||
expect(tool?.description).toBe("Show full details for a single goal by ID.");
|
||||
expect(tool?.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "Goal ID (G-…)",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns goal details with stable details.goal payload", async () => {
|
||||
const createTool = api.tools.get("fn_goal_create");
|
||||
const tool = api.tools.get("fn_goal_show");
|
||||
expect(createTool).toBeDefined();
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const created = await createTool!.execute(
|
||||
"goal-create-1",
|
||||
{ title: "Improve reliability", description: "Reduce flaky test retries" },
|
||||
undefined,
|
||||
undefined,
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
|
||||
const goalId = created.details.goalId as string;
|
||||
const result = await tool!.execute("goal-show-1", { id: goalId }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.details.goal).toMatchObject({
|
||||
id: goalId,
|
||||
title: "Improve reliability",
|
||||
description: "Reduce flaky test retries",
|
||||
status: "active",
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(result.content[0].text).toContain(goalId);
|
||||
expect(result.content[0].text).toContain("Improve reliability");
|
||||
expect(result.content[0].text).toContain("Status: active");
|
||||
expect(result.content[0].text).toContain("Created:");
|
||||
expect(result.content[0].text).toContain("Updated:");
|
||||
});
|
||||
|
||||
it("returns GOAL_NOT_FOUND error for unknown id", async () => {
|
||||
const tool = api.tools.get("fn_goal_show");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool!.execute("goal-show-404", { id: "G-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toBe("Goal G-404 not found");
|
||||
expect(result.details).toEqual({ code: "GOAL_NOT_FOUND", goalId: "G-404" });
|
||||
});
|
||||
|
||||
it("supports list to show retrieval with stable json shape", async () => {
|
||||
const createTool = api.tools.get("fn_goal_create");
|
||||
const listTool = api.tools.get("fn_goal_list");
|
||||
const showTool = api.tools.get("fn_goal_show");
|
||||
expect(createTool).toBeDefined();
|
||||
expect(listTool).toBeDefined();
|
||||
expect(showTool).toBeDefined();
|
||||
|
||||
await createTool!.execute("goal-create-2", { title: "Ship slice 2" }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
const listResult = await listTool!.execute("goal-list-1", { status: "active" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(Array.isArray(listResult.details.goals)).toBe(true);
|
||||
expect(listResult.details.goals.length).toBeGreaterThan(0);
|
||||
|
||||
const listedGoal = listResult.details.goals[0] as { id: string };
|
||||
const showResult = await showTool!.execute("goal-show-2", { id: listedGoal.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(showResult.details.goal.id).toBe(listedGoal.id);
|
||||
expect(showResult.details.goal).toMatchObject({
|
||||
id: listedGoal.id,
|
||||
title: expect.any(String),
|
||||
status: "active",
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2522,6 +2522,49 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_goal_show",
|
||||
label: "fn: Show Goal",
|
||||
description: "Show full details for a single goal by ID.",
|
||||
promptSnippet: "Show goal details by ID",
|
||||
promptGuidelines: [
|
||||
"Use to inspect a specific goal after listing or referencing its ID",
|
||||
"Cite the goal ID and status when summarizing or planning from this output",
|
||||
"Read-only retrieval; does not mutate goals",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Goal ID (G-…)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const goalStore = store.getGoalStore();
|
||||
const goal = goalStore.getGoal(params.id);
|
||||
|
||||
if (!goal) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Goal ${params.id} not found` }],
|
||||
details: { code: "GOAL_NOT_FOUND", goalId: params.id },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`${goal.id}: ${goal.title}`);
|
||||
lines.push(`Status: ${goal.status}`);
|
||||
lines.push(`Created: ${goal.createdAt}`);
|
||||
lines.push(`Updated: ${goal.updatedAt}`);
|
||||
if (goal.description) {
|
||||
lines.push(`Description: ${goal.description}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { goal },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_mission_show ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user