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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Make `fn_goal_list` and `fn_goal_show` available in engine agent sessions, including executor, heartbeat, and triage runs.
Also make `fn_goal_list` output concise by truncating descriptions to short single-line snippets while keeping full goal descriptions available through `fn_goal_show`.

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 },
};
},
});

View File

@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore, collectCitedGoalIdsFromAudit, type RunAuditEventInput } from "@fusion/core";
import { createGoalListTool, createGoalShowTool } from "../agent-tools.js";
import { GOAL_RETRIEVAL_INVOKED } from "../goal-anchoring-audit.js";
function makeTmpDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
const first = result.content[0];
return first && first.type === "text" ? (first.text ?? "") : "";
}
function detailsOf<T>(result: { details: unknown }): T {
return result.details as T;
}
const callCtx = [undefined, undefined] as const;
describe("goal retrieval agent tools", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir("kb-engine-goal-tools-");
globalDir = makeTmpDir("kb-engine-goal-tools-global-");
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
vi.restoreAllMocks();
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("lists no goals with stable empty-state output", async () => {
const tool = createGoalListTool(store);
const result = await tool.execute("list-1", {}, ...callCtx, {} as never);
expect(textOf(result)).toBe(["Goals (0) [filter: active]", "Active: 0/5", "", "No goals found."].join("\n"));
expect(detailsOf(result)).toEqual({
goals: [],
activeCount: 0,
softWarning: false,
hardLimit: 5,
});
});
it("lists goals concisely without dumping multiline descriptions", async () => {
const created = store.getGoalStore().createGoal({
title: "Grow plugin ecosystem",
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 output.",
});
const tool = createGoalListTool(store);
const result = await tool.execute("list-2", {}, ...callCtx, {} as never);
const text = textOf(result);
expect(text).toContain(`- ${created.id} [active] Grow plugin ecosystem — First line with extra spaces`);
expect(text).toContain("…");
expect(text).not.toContain("Second line must never appear");
expect(detailsOf<{ goals: Array<{ id: string; title: string; status: string; snippet?: string }> }>(result).goals).toEqual([
{
id: created.id,
title: "Grow plugin ecosystem",
status: "active",
snippet: "First line with extra spaces that should collapse before truncation because it…",
},
]);
});
it("supports archived and all status filters with soft warning output", async () => {
const goalStore = store.getGoalStore();
const archived = goalStore.createGoal({ title: "Archive me", description: "one line" });
goalStore.archiveGoal(archived.id);
goalStore.createGoal({ title: "One" });
goalStore.createGoal({ title: "Two" });
goalStore.createGoal({ title: "Three" });
const tool = createGoalListTool(store);
const archivedResult = await tool.execute("list-archived", { status: "archived" }, ...callCtx, {} as never);
const allResult = await tool.execute("list-all", { status: "all" }, ...callCtx, {} as never);
expect(textOf(archivedResult)).toBe([
"Goals (1) [filter: archived]",
"Active: 3/5",
"⚠ 3/5 active goals — soft warning at 3, hard cap at 5",
"",
`- ${archived.id} [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(resultGoalIds(detailsOf<{ goals: Array<{ id: string }> }>(allResult).goals)).toEqual(expect.arrayContaining([archived.id]));
});
it("shows full goal details including multiline descriptions", async () => {
const created = store.getGoalStore().createGoal({
title: "Stabilize goal citations",
description: "Line one\n- bullet two",
});
const tool = createGoalShowTool(store);
const result = await tool.execute("show-1", { id: created.id }, ...callCtx, {} as never);
const text = textOf(result);
expect((result as { isError?: boolean }).isError).toBeUndefined();
expect(text).toContain(`${created.id}: Stabilize goal citations`);
expect(text).toContain("Status: active");
expect(text).toContain("Description: Line one\n- bullet two");
expect(detailsOf<{ goal: Record<string, unknown> }>(result).goal).toMatchObject({
id: created.id,
title: "Stabilize goal citations",
description: "Line one\n- bullet two",
status: "active",
});
});
it("returns GOAL_NOT_FOUND for missing goals", async () => {
const tool = createGoalShowTool(store);
const result = await tool.execute("show-404", { id: "G-404" }, ...callCtx, {} as never);
expect((result as { isError?: boolean }).isError).toBe(true);
expect(textOf(result)).toBe("Goal G-404 not found");
expect(detailsOf(result)).toEqual({ code: "GOAL_NOT_FOUND", goalId: "G-404" });
});
it("emits retrieval audit events when run context is available", async () => {
const events: RunAuditEventInput[] = [];
const recordSpy = vi.spyOn(store, "recordRunAuditEvent").mockImplementation((event) => {
events.push(event);
return event as never;
});
const created = store.getGoalStore().createGoal({ title: "Reliable engine goal tools" });
const listTool = createGoalListTool(store, { runContext: { runId: "run-1", agentId: "agent-1" }, taskId: "FN-5977" });
const showTool = createGoalShowTool(store, { runContext: { runId: "run-1", agentId: "agent-1" }, taskId: "FN-5977" });
await listTool.execute("list-audit", { status: "active" }, ...callCtx, {} as never);
await showTool.execute("show-audit", { id: created.id }, ...callCtx, {} as never);
await showTool.execute("show-missing", { id: "G-404" }, ...callCtx, {} as never);
const goalEvents = events.filter((event) => event.mutationType === GOAL_RETRIEVAL_INVOKED);
expect(recordSpy).toHaveBeenCalled();
expect(goalEvents).toHaveLength(3);
expect(goalEvents[0]).toMatchObject({
target: "goals",
metadata: expect.objectContaining({ toolName: "fn_goal_list", count: 1, goalIds: [created.id], notFound: false }),
});
expect(goalEvents[1]).toMatchObject({
target: created.id,
metadata: expect.objectContaining({ toolName: "fn_goal_show", count: 1, goalIds: [created.id], notFound: false }),
});
expect(goalEvents[2]).toMatchObject({
target: "G-404",
metadata: expect.objectContaining({ toolName: "fn_goal_show", count: 0, goalIds: [], notFound: true }),
});
const citedGoalEvents = goalEvents.filter((event) => event.metadata?.notFound !== true);
expect(collectCitedGoalIdsFromAudit(citedGoalEvents as any)).toEqual({
injectedGoalIds: [],
retrievedGoalIds: [created.id],
citedGoalIds: [created.id],
});
});
it("silently skips retrieval audit when run context is absent", async () => {
const recordSpy = vi.spyOn(store, "recordRunAuditEvent");
const created = store.getGoalStore().createGoal({ title: "No audit without context" });
const listTool = createGoalListTool(store);
const showTool = createGoalShowTool(store);
await listTool.execute("list-no-audit", {}, ...callCtx, {} as never);
await showTool.execute("show-no-audit", { id: created.id }, ...callCtx, {} as never);
await showTool.execute("show-no-audit-404", { id: "G-404" }, ...callCtx, {} as never);
expect(recordSpy).not.toHaveBeenCalled();
});
it("accepts engine-style ctx metadata for audit emission", async () => {
const events: RunAuditEventInput[] = [];
vi.spyOn(store, "recordRunAuditEvent").mockImplementation((event) => {
events.push(event);
return event as never;
});
const created = store.getGoalStore().createGoal({ title: "Citable goal" });
const tool = createGoalShowTool(store);
await tool.execute(
"show-ctx",
{ id: created.id },
...callCtx,
{ runId: "run-ctx", agentId: "agent-ctx", taskId: "FN-CTX" } as never,
);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
runId: "run-ctx",
agentId: "agent-ctx",
taskId: "FN-CTX",
mutationType: GOAL_RETRIEVAL_INVOKED,
metadata: expect.objectContaining({ goalIds: [created.id] }),
});
});
});
function resultGoalIds(goals: Array<{ id: string }>): string[] {
return goals.map((goal) => goal.id);
}

View File

@@ -69,6 +69,8 @@ describe("gating-classifications parity", () => {
"fn_agent_org_chart",
"fn_agent_show",
"fn_delegate_task",
"fn_goal_list",
"fn_goal_show",
"fn_heartbeat_done",
"fn_list_agents",
"fn_memory_append",
@@ -109,6 +111,8 @@ describe("gating-classifications parity", () => {
it("includes goal retrieval tools on readonly path only", () => {
expect(READONLY_FN_TOOLS.has("fn_goal_list")).toBe(true);
expect(READONLY_FN_TOOLS.has("fn_goal_show")).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_goal_list")).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_goal_show")).toBe(true);
expect(READONLY_FN_TOOLS.has("fn_goal_create")).toBe(false);
expect(READONLY_FN_TOOLS.has("fn_goal_archive")).toBe(false);
});

View File

@@ -2642,9 +2642,9 @@ describe("executeHeartbeat", () => {
expect(callArgs.systemPrompt).toContain("fn_task_log");
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("coding");
// fn_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_read_evaluations, fn_update_identity,
// fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(17);
// fn_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_goal_list, fn_goal_show,
// fn_read_evaluations, fn_update_identity, fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(19);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
@@ -2655,14 +2655,16 @@ describe("executeHeartbeat", () => {
expect(callArgs.customTools![7]!.name).toBe("fn_update_agent_config");
expect(callArgs.customTools![8]!.name).toBe("fn_agent_create");
expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete");
expect(callArgs.customTools![10]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![11]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![12]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![13]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![14]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![15]!.name).toBe("fn_memory_append");
expect(callArgs.customTools![10]!.name).toBe("fn_goal_list");
expect(callArgs.customTools![11]!.name).toBe("fn_goal_show");
expect(callArgs.customTools![12]!.name).toBe("fn_read_evaluations");
expect(callArgs.customTools![13]!.name).toBe("fn_update_identity");
expect(callArgs.customTools![14]!.name).toBe("fn_web_fetch");
expect(callArgs.customTools![15]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![16]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![17]!.name).toBe("fn_memory_append");
// fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![16]!.name).toBe("fn_heartbeat_done");
expect(callArgs.customTools![18]!.name).toBe("fn_heartbeat_done");
});
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {

View File

@@ -170,7 +170,7 @@ describe("createHeartbeatTools", () => {
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
expect(tools).toHaveLength(12);
expect(tools).toHaveLength(14);
expect(tools[0]!.name).toBe("fn_task_create");
expect(tools[1]!.name).toBe("fn_task_log");
expect(tools[2]!.name).toBe("fn_task_document_write");
@@ -181,8 +181,10 @@ describe("createHeartbeatTools", () => {
expect(tools[7]!.name).toBe("fn_update_agent_config");
expect(tools[8]!.name).toBe("fn_agent_create");
expect(tools[9]!.name).toBe("fn_agent_delete");
expect(tools[10]!.name).toBe("fn_read_evaluations");
expect(tools[11]!.name).toBe("fn_update_identity");
expect(tools[10]!.name).toBe("fn_goal_list");
expect(tools[11]!.name).toBe("fn_goal_show");
expect(tools[12]!.name).toBe("fn_read_evaluations");
expect(tools[13]!.name).toBe("fn_update_identity");
});
it("fn_task_create tool creates a task in triage via TaskStore", async () => {

View File

@@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import {
resolveAgentInstructionsWithRatings,
@@ -2281,6 +2281,7 @@ export class HeartbeatMonitor {
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
}
heartbeatTools.push(...createGoalRetrievalTools(taskStore, { runContext }));
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
if (this.reflectionService) {
@@ -3320,6 +3321,7 @@ export class HeartbeatMonitor {
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
}
tools.push(...createGoalRetrievalTools(taskStore, { runContext, taskId }));
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
tools.push(createUpdateIdentityTool(this.store, agentId));
if (this.reflectionService) {

View File

@@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition } from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
@@ -26,6 +26,7 @@ import { fetchWebContent, WebFetchError } from "./web-fetch.js";
import type { RunAuditor } from "./run-audit.js";
import { computeApprovalDedupeKey } from "./agent-action-gate.js";
import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js";
import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js";
import { recordRetry } from "./retry-burned-logger.js";
// ── Tool parameter schemas (canonical definitions) ────────────────────────
@@ -179,6 +180,20 @@ export const updateIdentityParams = Type.Object({
memory: Type.Optional(Type.String({ description: "Updated agent memory text" })),
});
export const goalListParams = Type.Object({
status: Type.Optional(
Type.Union([
Type.Literal("active"),
Type.Literal("archived"),
Type.Literal("all"),
], { description: "Filter by goal status (default: active)" }),
),
});
export const goalShowParams = Type.Object({
id: Type.String({ description: "Goal ID (G-…)" }),
});
export const listAgentsParams = Type.Object({
role: Type.Optional(
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
@@ -1906,6 +1921,169 @@ export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings
return tools;
}
const GOAL_LIST_HARD_LIMIT = 5;
const GOAL_LIST_SOFT_WARNING_THRESHOLD = 3;
const GOAL_SNIPPET_MAX_CHARS = 80;
type GoalAuditContext = {
runId?: string;
agentId?: string;
taskId?: string;
};
type GoalListDetailsEntry = {
id: string;
title: string;
status: GoalStatus;
snippet?: string;
};
function 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()}…`;
}
function buildGoalListDetailsEntry(goal: { id: string; title: string; status: GoalStatus; description?: string }): GoalListDetailsEntry {
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 };
}
function formatGoalListLine(goal: GoalListDetailsEntry): string {
return `- ${goal.id} [${goal.status}] ${goal.title}${goal.snippet ? ` — ${goal.snippet}` : ""}`;
}
function resolveGoalAuditContext(
ctx: unknown,
runContext?: RunMutationContext,
taskId?: string,
): GoalAuditContext {
const candidate = typeof ctx === "object" && ctx !== null ? ctx as Record<string, unknown> : {};
const runId = typeof candidate.runId === "string" ? candidate.runId : runContext?.runId;
const agentId = typeof candidate.agentId === "string" ? candidate.agentId : runContext?.agentId;
const resolvedTaskId = typeof candidate.taskId === "string" ? candidate.taskId : taskId;
return { runId, agentId, taskId: resolvedTaskId };
}
export function createGoalListTool(
store: TaskStore,
options?: { runContext?: RunMutationContext; taskId?: string },
): ToolDefinition {
return {
name: "fn_goal_list",
label: "List Goals",
description: "List goals by status with active-goal warning details.",
parameters: goalListParams,
execute: async (_id: string, params: Static<typeof goalListParams>, _signal, _onUpdate, ctx) => {
const goalStore = store.getGoalStore();
const status = params.status ?? "active";
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
const activeCount = goalStore.listGoals({ status: "active" }).length;
const softWarning = activeCount >= GOAL_LIST_SOFT_WARNING_THRESHOLD;
const goalEntries = goals.map(buildGoalListDetailsEntry);
emitGoalRetrievalAudit(
store,
resolveGoalAuditContext(ctx, options?.runContext, options?.taskId),
{
toolName: "fn_goal_list",
resultCount: goals.length,
goalIds: goals.map((goal) => goal.id),
},
);
const lines: string[] = [
`Goals (${goals.length}) [filter: ${status}]`,
`Active: ${activeCount}/${GOAL_LIST_HARD_LIMIT}`,
];
if (softWarning) {
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 (goalEntries.length === 0) {
lines.push("No goals found.");
} else {
lines.push(...goalEntries.map(formatGoalListLine));
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: {
goals: goalEntries,
activeCount,
softWarning,
hardLimit: GOAL_LIST_HARD_LIMIT,
},
};
},
};
}
export function createGoalShowTool(
store: TaskStore,
options?: { runContext?: RunMutationContext; taskId?: string },
): ToolDefinition {
return {
name: "fn_goal_show",
label: "Show Goal",
description: "Show full details for a single goal by ID.",
parameters: goalShowParams,
execute: async (_id: string, params: Static<typeof goalShowParams>, _signal, _onUpdate, ctx) => {
const goalStore = store.getGoalStore();
const goal = goalStore.getGoal(params.id);
const auditContext = resolveGoalAuditContext(ctx, options?.runContext, options?.taskId);
if (!goal) {
emitGoalRetrievalAudit(store, auditContext, {
toolName: "fn_goal_show",
resultCount: 0,
goalId: params.id,
goalIds: [],
notFound: true,
});
return {
isError: true,
content: [{ type: "text" as const, text: `Goal ${params.id} not found` }],
details: { code: "GOAL_NOT_FOUND", goalId: params.id },
};
}
const lines = [
`${goal.id}: ${goal.title}`,
`Status: ${goal.status}`,
`Created: ${goal.createdAt}`,
`Updated: ${goal.updatedAt}`,
...(goal.description ? [`Description: ${goal.description}`] : []),
];
emitGoalRetrievalAudit(store, auditContext, {
toolName: "fn_goal_show",
resultCount: 1,
goalId: params.id,
goalIds: [params.id],
});
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: { goal },
};
},
};
}
export function createGoalRetrievalTools(
store: TaskStore,
options?: { runContext?: RunMutationContext; taskId?: string },
): ToolDefinition[] {
return [
createGoalListTool(store, options),
createGoalShowTool(store, options),
];
}
/**
* Create a `fn_reflect_on_performance` tool that asks the reflection service to
* analyze recent agent performance and return actionable insights.

View File

@@ -161,6 +161,7 @@ import {
createGetAgentConfigTool,
createListAgentsTool,
createMemoryTools,
createGoalRetrievalTools,
createWebFetchTool,
createReadMessagesTool,
createReflectOnPerformanceTool,
@@ -6639,6 +6640,13 @@ export class TaskExecutor {
getSettings: async () => this.store.getSettings(),
})
: []),
...createGoalRetrievalTools(this.store, {
runContext: {
runId: engineRunContext.runId,
agentId: engineRunContext.agentId,
},
taskId: task.id,
}),
createWebFetchTool(),
...createMemoryTools(this.rootDir, settings, identityAgent ? {
agentMemory: {

View File

@@ -133,6 +133,8 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_heartbeat_done",
"fn_task_create",
"fn_delegate_task",
"fn_goal_list",
"fn_goal_show",
"fn_list_agents",
"fn_agent_show",
"fn_agent_org_chart",

View File

@@ -70,6 +70,7 @@ import {
createDelegateTaskTool,
createListAgentsTool,
createMemoryTools,
createGoalRetrievalTools,
createResearchTools,
createWebFetchTool,
createTaskDocumentReadTool,
@@ -1133,6 +1134,15 @@ export class TriageProcessor {
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
: null;
const triageRunContext = {
runId: generateSyntheticRunId("triage", task.id),
agentId: assignedAgent?.id ?? "triage",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "plan",
source: "triage",
} as const;
const customTools = [
...this.createTriageTools({
parentTaskId: task.id,
@@ -1148,6 +1158,13 @@ export class TriageProcessor {
getSettings: async () => this.store.getSettings(),
})
: []),
...createGoalRetrievalTools(this.store, {
runContext: {
runId: triageRunContext.runId,
agentId: triageRunContext.agentId,
},
taskId: task.id,
}),
...createMemoryTools(this.rootDir, settings, assignedAgent
? {
agentMemory: {
@@ -1216,15 +1233,6 @@ export class TriageProcessor {
planLog.log(`${task.id}: applied plugin prompt contributions for triage surface`);
}
const triageRunContext = {
runId: generateSyntheticRunId("triage", task.id),
agentId: assignedAgent?.id ?? "triage",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "plan",
source: "triage",
} as const;
const runAuditor = createRunAuditor(this.store, triageRunContext);
const triageGoalResolution = await resolveAndEmitGoalContext({
lane: "planning",