diff --git a/.changeset/FN-5977-goal-retrieval-engine-tools.md b/.changeset/FN-5977-goal-retrieval-engine-tools.md new file mode 100644 index 0000000000..3580b219eb --- /dev/null +++ b/.changeset/FN-5977-goal-retrieval-engine-tools.md @@ -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`. diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 5b6ec221be..5006797330 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -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) | diff --git a/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts b/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts index 20d6f402a4..c6bcc09b33 100644 --- a/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts +++ b/packages/cli/src/__tests__/extension-goal-tools-audit.test.ts @@ -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], + }); }); }); diff --git a/packages/cli/src/__tests__/extension-goal-tools.test.ts b/packages/cli/src/__tests__/extension-goal-tools.test.ts index 629b4e73ef..d51f95c9f5 100644 --- a/packages/cli/src/__tests__/extension-goal-tools.test.ts +++ b/packages/cli/src/__tests__/extension-goal-tools.test.ts @@ -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; @@ -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); diff --git a/packages/cli/src/__tests__/goal-store-resolution.test.ts b/packages/cli/src/__tests__/goal-store-resolution.test.ts index 72d07b978f..4595b62895 100644 --- a/packages/cli/src/__tests__/goal-store-resolution.test.ts +++ b/packages/cli/src/__tests__/goal-store-resolution.test.ts @@ -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", }), ]); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 69c3111791..ebc77b7eeb 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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 }, }; }, }); diff --git a/packages/engine/src/__tests__/agent-tools-goal.test.ts b/packages/engine/src/__tests__/agent-tools-goal.test.ts new file mode 100644 index 0000000000..1fd0dafa1c --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-goal.test.ts @@ -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(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 }>(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); +} diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 172b07f28c..ad13f8058e 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -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); }); diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 209f571068..05d1bd7452 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -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 () => { diff --git a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts index 10318e0cb6..9317ec76a5 100644 --- a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts +++ b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts @@ -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 () => { diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 307528d9c2..69657b35c2 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -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) { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 62a4235e21..d433c2f97e 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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 : {}; + 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, _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, _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. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 8d33579a77..ae5675f7c0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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: { diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 872c4b5c82..9e3f7e8fd7 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -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", diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 67da4a1e96..57c7798f69 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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",