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:
@@ -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) |
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user