FN-5977: expose goal retrieval tools across engine agents

Make goal lookup tools available and citable throughout engine-driven agent sessions.

- add shared engine goal list/show tools with concise list snippets, full detail lookup, and retrieval audit coverage
- expose fn_goal_list and fn_goal_show in executor, heartbeat, and triage tool surfaces and classify them as readonly coordination-exempt tools
- extend CLI and engine regression coverage, refresh tool reference docs, and add a published changeset for the CLI package

Files changed:
 .changeset/FN-5977-goal-retrieval-engine-tools.md  |   7 +
 packages/cli/skill/fusion/references/engine-tools.md    |   2 +
 packages/cli/src/__tests__/extension-goal-tools-audit.test.ts   |   8 +-
 packages/cli/src/__tests__/extension-goal-tools.test.ts | 118 +++++++++++-
 packages/cli/src/__tests__/goal-store-resolution.test.ts    |   2 +-
 packages/cli/src/extension.ts                      |  38 +++-
 packages/engine/src/__tests__/agent-tools-goal.test.ts  | 213 +++++++++++++++++++++
 packages/engine/src/__tests__/gating-classifications.test.ts   |   4 +
 packages/engine/src/__tests__/heartbeat-executor.test.ts       |  22 ++-
 packages/engine/src/__tests__/heartbeat-session-prompt.test.ts |   8 +-
 packages/engine/src/agent-heartbeat.ts             |   4 +-
 packages/engine/src/agent-tools.ts                 | 180 ++++++++++++++++-
 packages/engine/src/executor.ts                    |   8 +
 packages/engine/src/gating-classifications.ts      |   2 +
 packages/engine/src/triage.ts                      |  26 ++-
 15 files changed, 600 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-5977

Fusion-Task-Lineage: 46f7e777-da4d-4e9c-b014-329a5cb1e3da
This commit is contained in:
gsxdsm
2026-06-07 15:11:17 -07:00
parent 61d687440b
commit fab8a62b55
15 changed files with 600 additions and 42 deletions

View File

@@ -15,6 +15,8 @@ These tools are **not** part of the user-invokable extension surface. They are i
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_goal_list` | triage, executor, heartbeat | List goals with concise citation-ready snippets and active-goal warning details | `status?` (`active` \| `archived` \| `all`) |
| `fn_goal_show` | triage, executor, heartbeat | Show one goal's full detail on demand, including the full description body | `id` (string) |
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
| `fn_workflow_get` | executor | Fetch one workflow definition by id — name, description, builtin flag, and the full IR (nodes/edges/columns/artifacts/fields/settings) as JSON | `workflow_id` (string) |
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import { TaskStore, collectCitedGoalIdsFromAudit } from "@fusion/core";
import kbExtension from "../extension.js";
import { GOAL_RETRIEVAL_INVOKED } from "@fusion/engine";
@@ -61,5 +61,11 @@ describe("extension goal tools retrieval audit", () => {
expect(goalAuditCalls[0]).toMatchObject({ metadata: expect.objectContaining({ toolName: "fn_goal_list", count: 1, goalIds: [goalId] }) });
expect(goalAuditCalls[1]).toMatchObject({ target: goalId, metadata: expect.objectContaining({ toolName: "fn_goal_show", count: 1, goalIds: [goalId], notFound: false }) });
expect(goalAuditCalls[2]).toMatchObject({ target: "G-404", metadata: expect.objectContaining({ toolName: "fn_goal_show", count: 0, goalIds: [], notFound: true }) });
const citedGoalCalls = goalAuditCalls.filter((event) => event.metadata?.notFound !== true);
expect(collectCitedGoalIdsFromAudit(citedGoalCalls as any)).toEqual({
injectedGoalIds: [],
retrievedGoalIds: [goalId],
citedGoalIds: [goalId],
});
});
});

View File

@@ -42,6 +42,11 @@ function makeCtx(cwd: string) {
return { cwd } as any;
}
function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
const first = result.content[0];
return first && first.type === "text" ? (first.text ?? "") : "";
}
describe("extension goal retrieval tools", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
@@ -101,11 +106,11 @@ describe("extension goal retrieval tools", () => {
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:");
expect(textOf(result)).toContain(goalId);
expect(textOf(result)).toContain("Improve reliability");
expect(textOf(result)).toContain("Status: active");
expect(textOf(result)).toContain("Created:");
expect(textOf(result)).toContain("Updated:");
});
it("returns GOAL_NOT_FOUND error for unknown id", async () => {
@@ -115,10 +120,109 @@ describe("extension goal retrieval tools", () => {
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(textOf(result)).toBe("Goal G-404 not found");
expect(result.details).toEqual({ code: "GOAL_NOT_FOUND", goalId: "G-404" });
});
it("keeps fn_goal_list concise for empty and single-goal states", async () => {
const createTool = api.tools.get("fn_goal_create");
const listTool = api.tools.get("fn_goal_list");
expect(createTool).toBeDefined();
expect(listTool).toBeDefined();
const emptyResult = await listTool!.execute("goal-list-empty", {}, undefined, undefined, makeCtx(tmpDir));
expect(textOf(emptyResult)).toBe(["Goals (0) [filter: active]", "Active: 0/5", "", "No goals found."].join("\n"));
expect(emptyResult.details).toEqual({ goals: [], activeCount: 0, softWarning: false, hardLimit: 5 });
const created = await createTool!.execute(
"goal-create-2",
{ title: "Ship slice 2" },
undefined,
undefined,
makeCtx(tmpDir),
);
const goalId = created.details.goalId as string;
const result = await listTool!.execute("goal-list-single", { status: "active" }, undefined, undefined, makeCtx(tmpDir));
expect(textOf(result)).toBe([
"Goals (1) [filter: active]",
"Active: 1/5",
"",
`- ${goalId} [active] Ship slice 2`,
].join("\n"));
expect(result.details.goals).toEqual([{ id: goalId, title: "Ship slice 2", status: "active" }]);
});
it("truncates goal descriptions in fn_goal_list while fn_goal_show keeps full detail", 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();
const created = await createTool!.execute(
"goal-create-3",
{
title: "Cite goals by ID",
description: "First line with extra spaces that should collapse before truncation because it is intentionally very long and verbose.\nSecond line must never appear in fn_goal_list.",
},
undefined,
undefined,
makeCtx(tmpDir),
);
const goalId = created.details.goalId as string;
const listResult = await listTool!.execute("goal-list-long", {}, undefined, undefined, makeCtx(tmpDir));
const listText = textOf(listResult);
expect(listText).toContain(`- ${goalId} [active] Cite goals by ID — First line with extra spaces`);
expect(listText).toContain("…");
expect(listText).not.toContain("Second line must never appear");
expect(listResult.details.goals).toEqual([
{
id: goalId,
title: "Cite goals by ID",
status: "active",
snippet: "First line with extra spaces that should collapse before truncation because it…",
},
]);
const showResult = await showTool!.execute("goal-show-long", { id: goalId }, undefined, undefined, makeCtx(tmpDir));
expect(textOf(showResult)).toContain("Description: First line with extra spaces");
expect(textOf(showResult)).toContain("Second line must never appear in fn_goal_list.");
expect(showResult.details.goal.description).toContain("Second line must never appear in fn_goal_list.");
});
it("supports archived and all filters with soft-warning output", async () => {
const createTool = api.tools.get("fn_goal_create");
const archiveTool = api.tools.get("fn_goal_archive");
const listTool = api.tools.get("fn_goal_list");
expect(createTool).toBeDefined();
expect(archiveTool).toBeDefined();
expect(listTool).toBeDefined();
const archivedGoal = await createTool!.execute("goal-create-4", { title: "Archive me", description: "one line" }, undefined, undefined, makeCtx(tmpDir));
await archiveTool!.execute("goal-archive-1", { id: archivedGoal.details.goalId }, undefined, undefined, makeCtx(tmpDir));
await createTool!.execute("goal-create-5", { title: "One" }, undefined, undefined, makeCtx(tmpDir));
await createTool!.execute("goal-create-6", { title: "Two" }, undefined, undefined, makeCtx(tmpDir));
await createTool!.execute("goal-create-7", { title: "Three" }, undefined, undefined, makeCtx(tmpDir));
const archivedResult = await listTool!.execute("goal-list-archived", { status: "archived" }, undefined, undefined, makeCtx(tmpDir));
const allResult = await listTool!.execute("goal-list-all", { status: "all" }, undefined, undefined, makeCtx(tmpDir));
expect(textOf(archivedResult)).toBe([
"Goals (1) [filter: archived]",
"Active: 3/5",
"⚠ 3/5 active goals — soft warning at 3, hard cap at 5",
"",
`- ${archivedGoal.details.goalId} [archived] Archive me — one line`,
].join("\n"));
expect(textOf(allResult)).toContain("Goals (4) [filter: all]");
expect(textOf(allResult)).toContain("⚠ 3/5 active goals — soft warning at 3, hard cap at 5");
expect((allResult.details.goals as Array<{ id: string }>).map((goal) => goal.id)).toContain(archivedGoal.details.goalId);
});
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");
@@ -127,7 +231,7 @@ describe("extension goal retrieval tools", () => {
expect(listTool).toBeDefined();
expect(showTool).toBeDefined();
await createTool!.execute("goal-create-2", { title: "Ship slice 2" }, undefined, undefined, makeCtx(tmpDir));
await createTool!.execute("goal-create-8", { 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);

View File

@@ -78,7 +78,7 @@ describe("extension goal tools store resolution", () => {
expect.objectContaining({
id: goal.id,
title: "Canonical goal",
description: "Created in the project root store",
snippet: "Created in the project root store",
status: "active",
}),
]);

View File

@@ -2398,6 +2398,28 @@ export default function kbExtension(pi: ExtensionAPI) {
// ── Goal Tools ───────────────────────────────────────────────
// Author-facing goal management
const GOAL_LIST_HARD_LIMIT = 5;
const GOAL_LIST_SOFT_WARNING_THRESHOLD = 3;
const GOAL_SNIPPET_MAX_CHARS = 80;
const buildGoalSnippet = (description?: string): string | undefined => {
const firstLine = description?.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim();
if (!firstLine) return undefined;
if (firstLine.length <= GOAL_SNIPPET_MAX_CHARS) return firstLine;
return `${firstLine.slice(0, GOAL_SNIPPET_MAX_CHARS - 1).trimEnd()}…`;
};
const buildGoalListEntry = (goal: { id: string; title: string; status: string; description?: string }) => {
const snippet = buildGoalSnippet(goal.description);
return snippet
? { id: goal.id, title: goal.title, status: goal.status, snippet }
: { id: goal.id, title: goal.title, status: goal.status };
};
const formatGoalListLine = (goal: { id: string; title: string; status: string; snippet?: string }) => (
`- ${goal.id} [${goal.status}] ${goal.title}${goal.snippet ? ` — ${goal.snippet}` : ""}`
);
pi.registerTool({
name: "fn_goal_list",
label: "fn: List Goals",
@@ -2429,7 +2451,8 @@ export default function kbExtension(pi: ExtensionAPI) {
const status = params.status ?? "active";
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
const activeCount = goalStore.listGoals({ status: "active" }).length;
const softWarning = activeCount >= 3;
const softWarning = activeCount >= GOAL_LIST_SOFT_WARNING_THRESHOLD;
const goalEntries = goals.map(buildGoalListEntry);
emitGoalRetrievalAudit(store, fnCtx, {
toolName: "fn_goal_list",
@@ -2439,23 +2462,20 @@ export default function kbExtension(pi: ExtensionAPI) {
const lines: string[] = [];
lines.push(`Goals (${goals.length}) [filter: ${status}]`);
lines.push(`Active: ${activeCount}/5`);
lines.push(`Active: ${activeCount}/${GOAL_LIST_HARD_LIMIT}`);
if (softWarning) {
lines.push("⚠ 3/5 active goals — soft warning at 3, hard cap at 5");
lines.push(`⚠ ${GOAL_LIST_SOFT_WARNING_THRESHOLD}/${GOAL_LIST_HARD_LIMIT} active goals — soft warning at ${GOAL_LIST_SOFT_WARNING_THRESHOLD}, hard cap at ${GOAL_LIST_HARD_LIMIT}`);
}
lines.push("");
if (goals.length === 0) {
if (goalEntries.length === 0) {
lines.push("No goals found.");
} else {
for (const goal of goals) {
const description = goal.description ? ` — ${goal.description}` : "";
lines.push(`- ${goal.id} [${goal.status}] ${goal.title}${description}`);
}
lines.push(...goalEntries.map(formatGoalListLine));
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { goals, activeCount, softWarning, hardLimit: 5 },
details: { goals: goalEntries, activeCount, softWarning, hardLimit: GOAL_LIST_HARD_LIMIT },
};
},
});