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:
7
.changeset/FN-5977-goal-retrieval-engine-tools.md
Normal file
7
.changeset/FN-5977-goal-retrieval-engine-tools.md
Normal 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`.
|
||||||
@@ -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_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_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_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_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_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) |
|
| `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 { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { TaskStore } from "@fusion/core";
|
import { TaskStore, collectCitedGoalIdsFromAudit } from "@fusion/core";
|
||||||
import kbExtension from "../extension.js";
|
import kbExtension from "../extension.js";
|
||||||
import { GOAL_RETRIEVAL_INVOKED } from "@fusion/engine";
|
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[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[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 }) });
|
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;
|
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", () => {
|
describe("extension goal retrieval tools", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let api: ReturnType<typeof createMockAPI>;
|
let api: ReturnType<typeof createMockAPI>;
|
||||||
@@ -101,11 +106,11 @@ describe("extension goal retrieval tools", () => {
|
|||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
});
|
});
|
||||||
expect(result.content[0].text).toContain(goalId);
|
expect(textOf(result)).toContain(goalId);
|
||||||
expect(result.content[0].text).toContain("Improve reliability");
|
expect(textOf(result)).toContain("Improve reliability");
|
||||||
expect(result.content[0].text).toContain("Status: active");
|
expect(textOf(result)).toContain("Status: active");
|
||||||
expect(result.content[0].text).toContain("Created:");
|
expect(textOf(result)).toContain("Created:");
|
||||||
expect(result.content[0].text).toContain("Updated:");
|
expect(textOf(result)).toContain("Updated:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns GOAL_NOT_FOUND error for unknown id", async () => {
|
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));
|
const result = await tool!.execute("goal-show-404", { id: "G-404" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
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" });
|
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 () => {
|
it("supports list to show retrieval with stable json shape", async () => {
|
||||||
const createTool = api.tools.get("fn_goal_create");
|
const createTool = api.tools.get("fn_goal_create");
|
||||||
const listTool = api.tools.get("fn_goal_list");
|
const listTool = api.tools.get("fn_goal_list");
|
||||||
@@ -127,7 +231,7 @@ describe("extension goal retrieval tools", () => {
|
|||||||
expect(listTool).toBeDefined();
|
expect(listTool).toBeDefined();
|
||||||
expect(showTool).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));
|
const listResult = await listTool!.execute("goal-list-1", { status: "active" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
expect(Array.isArray(listResult.details.goals)).toBe(true);
|
expect(Array.isArray(listResult.details.goals)).toBe(true);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("extension goal tools store resolution", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: goal.id,
|
id: goal.id,
|
||||||
title: "Canonical goal",
|
title: "Canonical goal",
|
||||||
description: "Created in the project root store",
|
snippet: "Created in the project root store",
|
||||||
status: "active",
|
status: "active",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2398,6 +2398,28 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
// ── Goal Tools ───────────────────────────────────────────────
|
// ── Goal Tools ───────────────────────────────────────────────
|
||||||
// Author-facing goal management
|
// 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({
|
pi.registerTool({
|
||||||
name: "fn_goal_list",
|
name: "fn_goal_list",
|
||||||
label: "fn: List Goals",
|
label: "fn: List Goals",
|
||||||
@@ -2429,7 +2451,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
const status = params.status ?? "active";
|
const status = params.status ?? "active";
|
||||||
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
|
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
|
||||||
const activeCount = goalStore.listGoals({ status: "active" }).length;
|
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, {
|
emitGoalRetrievalAudit(store, fnCtx, {
|
||||||
toolName: "fn_goal_list",
|
toolName: "fn_goal_list",
|
||||||
@@ -2439,23 +2462,20 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`Goals (${goals.length}) [filter: ${status}]`);
|
lines.push(`Goals (${goals.length}) [filter: ${status}]`);
|
||||||
lines.push(`Active: ${activeCount}/5`);
|
lines.push(`Active: ${activeCount}/${GOAL_LIST_HARD_LIMIT}`);
|
||||||
if (softWarning) {
|
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("");
|
lines.push("");
|
||||||
if (goals.length === 0) {
|
if (goalEntries.length === 0) {
|
||||||
lines.push("No goals found.");
|
lines.push("No goals found.");
|
||||||
} else {
|
} else {
|
||||||
for (const goal of goals) {
|
lines.push(...goalEntries.map(formatGoalListLine));
|
||||||
const description = goal.description ? ` — ${goal.description}` : "";
|
|
||||||
lines.push(`- ${goal.id} [${goal.status}] ${goal.title}${description}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
details: { goals, activeCount, softWarning, hardLimit: 5 },
|
details: { goals: goalEntries, activeCount, softWarning, hardLimit: GOAL_LIST_HARD_LIMIT },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
213
packages/engine/src/__tests__/agent-tools-goal.test.ts
Normal file
213
packages/engine/src/__tests__/agent-tools-goal.test.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ describe("gating-classifications parity", () => {
|
|||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_delegate_task",
|
"fn_delegate_task",
|
||||||
|
"fn_goal_list",
|
||||||
|
"fn_goal_show",
|
||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
"fn_list_agents",
|
"fn_list_agents",
|
||||||
"fn_memory_append",
|
"fn_memory_append",
|
||||||
@@ -109,6 +111,8 @@ describe("gating-classifications parity", () => {
|
|||||||
it("includes goal retrieval tools on readonly path only", () => {
|
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_list")).toBe(true);
|
||||||
expect(READONLY_FN_TOOLS.has("fn_goal_show")).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_create")).toBe(false);
|
||||||
expect(READONLY_FN_TOOLS.has("fn_goal_archive")).toBe(false);
|
expect(READONLY_FN_TOOLS.has("fn_goal_archive")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2642,9 +2642,9 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(callArgs.systemPrompt).toContain("fn_task_log");
|
expect(callArgs.systemPrompt).toContain("fn_task_log");
|
||||||
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
|
expect(callArgs.systemPrompt).toContain("fn_task_document_write");
|
||||||
expect(callArgs.tools).toBe("coding");
|
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_get_agent_config, fn_update_agent_config, fn_agent_create, fn_agent_delete, fn_goal_list, fn_goal_show,
|
||||||
// fn_web_fetch, fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
|
// 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);
|
expect(callArgs.customTools).toHaveLength(19);
|
||||||
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
|
expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
|
||||||
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
|
expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
|
||||||
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
|
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![7]!.name).toBe("fn_update_agent_config");
|
||||||
expect(callArgs.customTools![8]!.name).toBe("fn_agent_create");
|
expect(callArgs.customTools![8]!.name).toBe("fn_agent_create");
|
||||||
expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete");
|
expect(callArgs.customTools![9]!.name).toBe("fn_agent_delete");
|
||||||
expect(callArgs.customTools![10]!.name).toBe("fn_read_evaluations");
|
expect(callArgs.customTools![10]!.name).toBe("fn_goal_list");
|
||||||
expect(callArgs.customTools![11]!.name).toBe("fn_update_identity");
|
expect(callArgs.customTools![11]!.name).toBe("fn_goal_show");
|
||||||
expect(callArgs.customTools![12]!.name).toBe("fn_web_fetch");
|
expect(callArgs.customTools![12]!.name).toBe("fn_read_evaluations");
|
||||||
expect(callArgs.customTools![13]!.name).toBe("fn_memory_search");
|
expect(callArgs.customTools![13]!.name).toBe("fn_update_identity");
|
||||||
expect(callArgs.customTools![14]!.name).toBe("fn_memory_get");
|
expect(callArgs.customTools![14]!.name).toBe("fn_web_fetch");
|
||||||
expect(callArgs.customTools![15]!.name).toBe("fn_memory_append");
|
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)
|
// 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 () => {
|
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe("createHeartbeatTools", () => {
|
|||||||
|
|
||||||
const tools = monitor.createHeartbeatTools("agent-001", mockTaskStore, "FN-001");
|
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[0]!.name).toBe("fn_task_create");
|
||||||
expect(tools[1]!.name).toBe("fn_task_log");
|
expect(tools[1]!.name).toBe("fn_task_log");
|
||||||
expect(tools[2]!.name).toBe("fn_task_document_write");
|
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[7]!.name).toBe("fn_update_agent_config");
|
||||||
expect(tools[8]!.name).toBe("fn_agent_create");
|
expect(tools[8]!.name).toBe("fn_agent_create");
|
||||||
expect(tools[9]!.name).toBe("fn_agent_delete");
|
expect(tools[9]!.name).toBe("fn_agent_delete");
|
||||||
expect(tools[10]!.name).toBe("fn_read_evaluations");
|
expect(tools[10]!.name).toBe("fn_goal_list");
|
||||||
expect(tools[11]!.name).toBe("fn_update_identity");
|
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 () => {
|
it("fn_task_create tool creates a task in triage via TaskStore", async () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
|
|||||||
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||||
import { Type, type Static } from "@earendil-works/pi-ai";
|
import { Type, type Static } from "@earendil-works/pi-ai";
|
||||||
import { createHash } from "node:crypto";
|
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 { AgentLogger } from "./agent-logger.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentInstructionsWithRatings,
|
resolveAgentInstructionsWithRatings,
|
||||||
@@ -2281,6 +2281,7 @@ export class HeartbeatMonitor {
|
|||||||
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
heartbeatTools.push(...createGoalRetrievalTools(taskStore, { runContext }));
|
||||||
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||||
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
|
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
|
||||||
if (this.reflectionService) {
|
if (this.reflectionService) {
|
||||||
@@ -3320,6 +3321,7 @@ export class HeartbeatMonitor {
|
|||||||
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.push(...createGoalRetrievalTools(taskStore, { runContext, taskId }));
|
||||||
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
|
||||||
tools.push(createUpdateIdentityTool(this.store, agentId));
|
tools.push(createUpdateIdentityTool(this.store, agentId));
|
||||||
if (this.reflectionService) {
|
if (this.reflectionService) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { join, relative, resolve } from "node:path";
|
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 { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS } from "@fusion/core";
|
||||||
import { promoteHeldTask } from "./hold-release.js";
|
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";
|
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 type { RunAuditor } from "./run-audit.js";
|
||||||
import { computeApprovalDedupeKey } from "./agent-action-gate.js";
|
import { computeApprovalDedupeKey } from "./agent-action-gate.js";
|
||||||
import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js";
|
import { MessageDeliveryAutoRecoveryHandler } from "./auto-recovery-handlers/message-delivery.js";
|
||||||
|
import { emitGoalRetrievalAudit } from "./goal-anchoring-audit.js";
|
||||||
import { recordRetry } from "./retry-burned-logger.js";
|
import { recordRetry } from "./retry-burned-logger.js";
|
||||||
|
|
||||||
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
||||||
@@ -179,6 +180,20 @@ export const updateIdentityParams = Type.Object({
|
|||||||
memory: Type.Optional(Type.String({ description: "Updated agent memory text" })),
|
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({
|
export const listAgentsParams = Type.Object({
|
||||||
role: Type.Optional(
|
role: Type.Optional(
|
||||||
Type.String({ description: "Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa')" }),
|
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;
|
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
|
* Create a `fn_reflect_on_performance` tool that asks the reflection service to
|
||||||
* analyze recent agent performance and return actionable insights.
|
* analyze recent agent performance and return actionable insights.
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ import {
|
|||||||
createGetAgentConfigTool,
|
createGetAgentConfigTool,
|
||||||
createListAgentsTool,
|
createListAgentsTool,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
|
createGoalRetrievalTools,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createReadMessagesTool,
|
createReadMessagesTool,
|
||||||
createReflectOnPerformanceTool,
|
createReflectOnPerformanceTool,
|
||||||
@@ -6639,6 +6640,13 @@ export class TaskExecutor {
|
|||||||
getSettings: async () => this.store.getSettings(),
|
getSettings: async () => this.store.getSettings(),
|
||||||
})
|
})
|
||||||
: []),
|
: []),
|
||||||
|
...createGoalRetrievalTools(this.store, {
|
||||||
|
runContext: {
|
||||||
|
runId: engineRunContext.runId,
|
||||||
|
agentId: engineRunContext.agentId,
|
||||||
|
},
|
||||||
|
taskId: task.id,
|
||||||
|
}),
|
||||||
createWebFetchTool(),
|
createWebFetchTool(),
|
||||||
...createMemoryTools(this.rootDir, settings, identityAgent ? {
|
...createMemoryTools(this.rootDir, settings, identityAgent ? {
|
||||||
agentMemory: {
|
agentMemory: {
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export const COORDINATION_EXEMPT_TOOLS = [
|
|||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
"fn_task_create",
|
"fn_task_create",
|
||||||
"fn_delegate_task",
|
"fn_delegate_task",
|
||||||
|
"fn_goal_list",
|
||||||
|
"fn_goal_show",
|
||||||
"fn_list_agents",
|
"fn_list_agents",
|
||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
createDelegateTaskTool,
|
createDelegateTaskTool,
|
||||||
createListAgentsTool,
|
createListAgentsTool,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
|
createGoalRetrievalTools,
|
||||||
createResearchTools,
|
createResearchTools,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createTaskDocumentReadTool,
|
createTaskDocumentReadTool,
|
||||||
@@ -1133,6 +1134,15 @@ export class TriageProcessor {
|
|||||||
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
|
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
|
||||||
: 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 = [
|
const customTools = [
|
||||||
...this.createTriageTools({
|
...this.createTriageTools({
|
||||||
parentTaskId: task.id,
|
parentTaskId: task.id,
|
||||||
@@ -1148,6 +1158,13 @@ export class TriageProcessor {
|
|||||||
getSettings: async () => this.store.getSettings(),
|
getSettings: async () => this.store.getSettings(),
|
||||||
})
|
})
|
||||||
: []),
|
: []),
|
||||||
|
...createGoalRetrievalTools(this.store, {
|
||||||
|
runContext: {
|
||||||
|
runId: triageRunContext.runId,
|
||||||
|
agentId: triageRunContext.agentId,
|
||||||
|
},
|
||||||
|
taskId: task.id,
|
||||||
|
}),
|
||||||
...createMemoryTools(this.rootDir, settings, assignedAgent
|
...createMemoryTools(this.rootDir, settings, assignedAgent
|
||||||
? {
|
? {
|
||||||
agentMemory: {
|
agentMemory: {
|
||||||
@@ -1216,15 +1233,6 @@ export class TriageProcessor {
|
|||||||
planLog.log(`${task.id}: applied plugin prompt contributions for triage surface`);
|
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 runAuditor = createRunAuditor(this.store, triageRunContext);
|
||||||
const triageGoalResolution = await resolveAndEmitGoalContext({
|
const triageGoalResolution = await resolveAndEmitGoalContext({
|
||||||
lane: "planning",
|
lane: "planning",
|
||||||
|
|||||||
Reference in New Issue
Block a user