FN-8294: expose action-gated Mission hierarchy tools
Expose project-scoped Mission hierarchy operations to engine agents and eligible dashboard chat sessions. - Add Mission, milestone, slice, and feature tool definitions backed by MissionStore - Classify hierarchy mutations for action and permanent-agent approval gates - Wire gated tool access through triage, executor, heartbeat, CLI, and chat lanes - Cover tool availability, mutation gating, and chat integration with tests and documentation Files changed: .changeset/fn-8294-mission-engine-tools.md | 7 ++ docs/missions.md | 8 ++ packages/cli/src/extension.ts | 2 +- .../dashboard/src/__tests__/chat-manager.test.ts | 48 +++++++++ packages/dashboard/src/__tests__/chat.test.ts | 1 + packages/dashboard/src/chat.ts | 113 ++++++++++++++++++++- .../src/__tests__/agent-mission-tools.test.ts | 43 ++++++++ packages/engine/src/__tests__/triage.test.ts | 34 ++++++- packages/engine/src/agent-heartbeat.ts | 4 +- packages/engine/src/agent-tools.ts | 64 ++++++++++++ packages/engine/src/executor.ts | 2 + packages/engine/src/gating-classifications.ts | 11 ++ packages/engine/src/index.ts | 17 ++++ packages/engine/src/triage.ts | 111 ++++++++++++++++++++ 14 files changed, 457 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-8294 Fusion-Task-Lineage: ab0f248b-8a38-40e3-b297-79f9dbe18075 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8294-mission-engine-tools.md
Normal file
7
.changeset/fn-8294-mission-engine-tools.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Expose Mission hierarchy tools to engine agents and dashboard chat.
|
||||||
|
category: feature
|
||||||
|
dev: Uses the project-scoped MissionStore feature-link contract across tool surfaces.
|
||||||
@@ -666,3 +666,11 @@ This lifecycle is validated by integration tests in two dependent tasks:
|
|||||||

|

|
||||||
|
|
||||||
See also: [Multi-Project](./multi-project.md) and [Task Management](./task-management.md).
|
See also: [Multi-Project](./multi-project.md) and [Task Management](./task-management.md).
|
||||||
|
|
||||||
|
## Agent and dashboard-chat tools
|
||||||
|
|
||||||
|
Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_delete`, and `fn_feature_link_task`.
|
||||||
|
|
||||||
|
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools.
|
||||||
|
|
||||||
|
For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" })`. Link it to live work with `fn_feature_link_task({ featureId: "F-…", taskId: "FN-…" })`. Linking delegates to `MissionStore.linkFeatureToTask()`: it verifies the task is a live row in the same project, changes the feature to `triaged`, and records the mission/slice linkage on the task. Archived, deleted, missing, and other-project tasks are rejected.
|
||||||
|
|||||||
@@ -4198,8 +4198,8 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// FNXC:MissionToolParity 2026-07-29-12:00: linkFeatureToTask owns both feature and project-scoped task linkage; do not duplicate a route/tool-level slice update.
|
||||||
const updated = await missionStore.linkFeatureToTask(params.featureId, params.taskId);
|
const updated = await missionStore.linkFeatureToTask(params.featureId, params.taskId);
|
||||||
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -833,6 +833,54 @@ describe("ChatManager.sendMessage", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes action and permanent-agent gates to bound Mission chat sessions", async () => {
|
||||||
|
let createOptions: any;
|
||||||
|
__setCreateResolvedAgentSession(async (options: any) => {
|
||||||
|
createOptions = options;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: { messages: [{ role: "assistant", content: "ok" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const taskStore = {
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
defaultAgentPermissionPolicy: { rules: { task_agent_mutation: "block" } },
|
||||||
|
}),
|
||||||
|
getAsyncLayer: vi.fn(() => ({})),
|
||||||
|
getFusionDir: () => "/tmp/test/.fusion",
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatManager = new ChatManager(
|
||||||
|
mockChatStore as any,
|
||||||
|
"/tmp/test",
|
||||||
|
mockAgentStore as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
taskStore as any,
|
||||||
|
);
|
||||||
|
await chatManager.sendMessage("chat-001", "Create a mission");
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatMissionGatingTests 2026-07-29-15:30:
|
||||||
|
Mission mutations in a bound chat session are safe only when both wrappers
|
||||||
|
receive the bound agent policy. The engine gating suites assert block and
|
||||||
|
approval execution; this dashboard seam asserts chat cannot omit either context.
|
||||||
|
*/
|
||||||
|
expect(createOptions.actionGateContext).toMatchObject({
|
||||||
|
agentId: "agent-001",
|
||||||
|
permissionPolicy: { rules: { task_agent_mutation: "block" } },
|
||||||
|
});
|
||||||
|
expect(createOptions.permanentAgentGating).toMatchObject({
|
||||||
|
requester: { actorId: "agent-001" },
|
||||||
|
permissionPolicy: { rules: { task_agent_mutation: "block" } },
|
||||||
|
});
|
||||||
|
expect(createOptions.customTools.map((tool: { name: string }) => tool.name)).toContain("fn_mission_create");
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes fn_task_document_* tools to the chat agent when a task store is present", async () => {
|
it("exposes fn_task_document_* tools to the chat agent when a task store is present", async () => {
|
||||||
let capturedTools: Array<{ name: string; execute?: (...args: any[]) => Promise<any> }> = [];
|
let capturedTools: Array<{ name: string; execute?: (...args: any[]) => Promise<any> }> = [];
|
||||||
__setCreateFnAgent(async (options: any) => {
|
__setCreateFnAgent(async (options: any) => {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
createGetAgentConfigTool: vi.fn(),
|
createGetAgentConfigTool: vi.fn(),
|
||||||
createWebFetchTool: vi.fn(),
|
createWebFetchTool: vi.fn(),
|
||||||
createGoalRetrievalTools: vi.fn(() => []),
|
createGoalRetrievalTools: vi.fn(() => []),
|
||||||
|
createMissionTools: vi.fn(() => []),
|
||||||
createMemoryTools: vi.fn(() => []),
|
createMemoryTools: vi.fn(() => []),
|
||||||
createResearchTools: vi.fn(() => []),
|
createResearchTools: vi.fn(() => []),
|
||||||
resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })),
|
resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })),
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ import type {
|
|||||||
MessageStore,
|
MessageStore,
|
||||||
Settings,
|
Settings,
|
||||||
TaskStore,
|
TaskStore,
|
||||||
|
PermanentAgentGatingContext,
|
||||||
|
} from "@fusion/core";
|
||||||
|
import type { AgentActionGateContext, SkillSelectionContext } from "@fusion/engine";
|
||||||
|
import {
|
||||||
|
ApprovalRequestStore,
|
||||||
|
isEphemeralAgent,
|
||||||
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
|
summarizeTitle,
|
||||||
|
FUSION_RUNTIME_SELF_AWARENESS,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { SkillSelectionContext } from "@fusion/engine";
|
|
||||||
import { summarizeTitle, FUSION_RUNTIME_SELF_AWARENESS } from "@fusion/core";
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join, resolve, relative } from "node:path";
|
import { join, resolve, relative } from "node:path";
|
||||||
@@ -68,6 +75,7 @@ import {
|
|||||||
createGetAgentConfigTool,
|
createGetAgentConfigTool,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createGoalRetrievalTools,
|
createGoalRetrievalTools,
|
||||||
|
createMissionTools,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
createResearchTools,
|
createResearchTools,
|
||||||
resolveMcpServersForStore,
|
resolveMcpServersForStore,
|
||||||
@@ -347,6 +355,91 @@ export interface ChatFusionToolsetOptions {
|
|||||||
agentStore?: AgentStore;
|
agentStore?: AgentStore;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
/** True only when the session has both policy gate contexts for its bound agent. */
|
||||||
|
missionMutationGated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_MISSION_READ_TOOL_NAMES = new Set(["fn_mission_list", "fn_mission_show"]);
|
||||||
|
|
||||||
|
async function createChatMissionGateContexts(
|
||||||
|
taskStore: TaskStore | undefined,
|
||||||
|
agentStore: AgentStore | undefined,
|
||||||
|
agent: Agent | null,
|
||||||
|
): Promise<Pick<ChatFusionToolsetOptions, "missionMutationGated"> & Pick<import("@fusion/engine").AgentRuntimeOptions, "actionGateContext" | "permanentAgentGating">> {
|
||||||
|
if (!taskStore || !agentStore || !agent || isEphemeralAgent(agent)) {
|
||||||
|
return { missionMutationGated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight dashboard/test stores may provide hierarchy reads without the PostgreSQL approval layer.
|
||||||
|
const asyncLayer = typeof taskStore.getAsyncLayer === "function" ? taskStore.getAsyncLayer() : undefined;
|
||||||
|
if (!asyncLayer) {
|
||||||
|
return { missionMutationGated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await taskStore.getSettings();
|
||||||
|
const permissionPolicy = resolveEffectiveAgentPermissionPolicy(
|
||||||
|
agent.permissionPolicy,
|
||||||
|
settings.defaultAgentPermissionPolicy,
|
||||||
|
);
|
||||||
|
const approvalStore = new ApprovalRequestStore(null, { asyncLayer });
|
||||||
|
const requester = { actorId: agent.id, actorType: "agent" as const, actorName: agent.name };
|
||||||
|
const createApprovalRequest: AgentActionGateContext["createApprovalRequest"] = async (decision, args) => await approvalStore.create({
|
||||||
|
requester,
|
||||||
|
targetAction: {
|
||||||
|
category: decision.category === "exempt" ? "command_execution" : decision.category,
|
||||||
|
action: decision.operation,
|
||||||
|
summary: decision.summary,
|
||||||
|
resourceType: decision.resourceType,
|
||||||
|
resourceId: decision.resourceId ?? "",
|
||||||
|
context: { ...decision.metadata, approvalDedupeKey: decision.approvalDedupeKey, toolName: decision.toolName, toolArgs: args },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatMissionGating 2026-07-29-15:30:
|
||||||
|
Bound permanent-agent chat sessions must carry the same action and permanent-agent
|
||||||
|
gate contexts as executor lanes. Unbound or ephemeral chat has no durable principal
|
||||||
|
or approval store, so it exposes only positively read-only Mission tools.
|
||||||
|
*/
|
||||||
|
const actionGateContext: AgentActionGateContext = {
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
isEphemeral: false,
|
||||||
|
permissionPolicy,
|
||||||
|
createApprovalRequest,
|
||||||
|
findApprovalByDedupeKey: async (dedupeKey) => {
|
||||||
|
const latest = await approvalStore.findLatestByDedupeKey({ requesterActorId: agent.id, dedupeKey });
|
||||||
|
return latest ? { id: latest.id, status: latest.status } : null;
|
||||||
|
},
|
||||||
|
pauseForApproval: async () => {
|
||||||
|
await agentStore.updateAgentState(agent.id, "paused");
|
||||||
|
await agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
|
||||||
|
},
|
||||||
|
markApprovalCompleted: async (approvalRequestId) => {
|
||||||
|
await approvalStore.markCompleted(approvalRequestId, { actor: requester, note: "Tool executed after approval" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const permanentAgentGating: PermanentAgentGatingContext = {
|
||||||
|
permissionPolicy,
|
||||||
|
requester,
|
||||||
|
createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => await approvalStore.create({
|
||||||
|
requester,
|
||||||
|
targetAction: {
|
||||||
|
category,
|
||||||
|
action: toolName,
|
||||||
|
summary: `Agent gated action for ${toolName}`,
|
||||||
|
resourceType: "tool",
|
||||||
|
resourceId: toolName,
|
||||||
|
context: { toolName, toolArgs: args, source: "agent-gating", ...(approvalDedupeKey ? { approvalDedupeKey } : {}) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findPendingApprovalRequest: async (dedupeKey) => {
|
||||||
|
const pending = await approvalStore.list({ status: "pending", requesterActorId: agent.id, limit: 100 });
|
||||||
|
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { missionMutationGated: true, actionGateContext, permanentAgentGating };
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -354,11 +447,11 @@ FNXC:ChatAgentTools 2026-07-15-00:00:
|
|||||||
Chat agents, including Grok CLI sessions reached through the plugin MCP bridge,
|
Chat agents, including Grok CLI sessions reached through the plugin MCP bridge,
|
||||||
must receive one safe coordination/productivity toolset in both model-loop and
|
must receive one safe coordination/productivity toolset in both model-loop and
|
||||||
room-responder lanes. Workflow, document, artifact, messaging, and task-planner
|
room-responder lanes. Workflow, document, artifact, messaging, and task-planner
|
||||||
tools stay additive at their call sites; agent lifecycle mutation and memory
|
tools stay additive at their call sites; unbound chat retains only read-only Mission
|
||||||
append remain excluded because chat has no action-gate context.
|
tools because it has no durable action-gate principal.
|
||||||
*/
|
*/
|
||||||
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
|
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
|
||||||
const { taskStore, agentStore, rootDir, agentId } = options;
|
const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false } = options;
|
||||||
const tools: ChatCustomTool[] = [];
|
const tools: ChatCustomTool[] = [];
|
||||||
|
|
||||||
if (taskStore) {
|
if (taskStore) {
|
||||||
@@ -368,6 +461,8 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
|
|||||||
createTaskShowTool(taskStore),
|
createTaskShowTool(taskStore),
|
||||||
createTaskSearchTool(taskStore),
|
createTaskSearchTool(taskStore),
|
||||||
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
|
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
|
||||||
|
/* FNXC:MissionToolParity 2026-07-29-15:30: Dashboard chat uses the engine factory, but only bound permanent-agent sessions with both policy contexts receive hierarchy mutations. */
|
||||||
|
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
|
||||||
...createGoalRetrievalTools(taskStore),
|
...createGoalRetrievalTools(taskStore),
|
||||||
/* FNXC:ChatAgentTools 2026-07-15-00:00: Chat exposes memory retrieval only and respects the workspace memory-enabled setting; prompt-triggered persistent writes stay excluded without an action-gate context. */
|
/* FNXC:ChatAgentTools 2026-07-15-00:00: Chat exposes memory retrieval only and respects the workspace memory-enabled setting; prompt-triggered persistent writes stay excluded without an action-gate context. */
|
||||||
...createMemoryTools(rootDir, settings).filter((tool) => tool.name !== "fn_memory_append"),
|
...createMemoryTools(rootDir, settings).filter((tool) => tool.name !== "fn_memory_append"),
|
||||||
@@ -1860,11 +1955,13 @@ export class ChatManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, input.roomProjectId);
|
const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, input.roomProjectId);
|
||||||
|
const missionGateContexts = await createChatMissionGateContexts(this.taskStore, this.agentStore, input.responder);
|
||||||
const chatFusionTools = await createChatFusionToolset({
|
const chatFusionTools = await createChatFusionToolset({
|
||||||
taskStore: this.taskStore,
|
taskStore: this.taskStore,
|
||||||
agentStore: this.agentStore,
|
agentStore: this.agentStore,
|
||||||
rootDir: this.rootDir,
|
rootDir: this.rootDir,
|
||||||
agentId: input.responder.id,
|
agentId: input.responder.id,
|
||||||
|
missionMutationGated: missionGateContexts.missionMutationGated,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedSession = await createResolvedAgentSession({
|
const resolvedSession = await createResolvedAgentSession({
|
||||||
@@ -1898,6 +1995,8 @@ export class ChatManager {
|
|||||||
fallbackModelId: chatModelSettings.fallbackModelId,
|
fallbackModelId: chatModelSettings.fallbackModelId,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(missionGateContexts.actionGateContext ? { actionGateContext: missionGateContexts.actionGateContext } : {}),
|
||||||
|
...(missionGateContexts.permanentAgentGating ? { permanentAgentGating: missionGateContexts.permanentAgentGating } : {}),
|
||||||
onFallbackModelUsed: (payload: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => {
|
onFallbackModelUsed: (payload: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => {
|
||||||
roomFallbackInfo = payload;
|
roomFallbackInfo = payload;
|
||||||
diagnostics.warn(
|
diagnostics.warn(
|
||||||
@@ -2415,11 +2514,13 @@ export class ChatManager {
|
|||||||
? [createTaskPlannerRefinementTool(this.taskStore, taskPlannerChatTaskId)]
|
? [createTaskPlannerRefinementTool(this.taskStore, taskPlannerChatTaskId)]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const missionGateContexts = await createChatMissionGateContexts(this.taskStore, this.agentStore, agent);
|
||||||
const chatFusionTools = await createChatFusionToolset({
|
const chatFusionTools = await createChatFusionToolset({
|
||||||
taskStore: this.taskStore,
|
taskStore: this.taskStore,
|
||||||
agentStore: this.agentStore,
|
agentStore: this.agentStore,
|
||||||
rootDir: this.rootDir,
|
rootDir: this.rootDir,
|
||||||
agentId: agent?.id,
|
agentId: agent?.id,
|
||||||
|
missionMutationGated: missionGateContexts.missionMutationGated,
|
||||||
});
|
});
|
||||||
const customTools = dedupeChatTools([
|
const customTools = dedupeChatTools([
|
||||||
createAskQuestionTool(),
|
createAskQuestionTool(),
|
||||||
@@ -2453,6 +2554,8 @@ export class ChatManager {
|
|||||||
fallbackModelId: chatModelSettings.fallbackModelId,
|
fallbackModelId: chatModelSettings.fallbackModelId,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(missionGateContexts.actionGateContext ? { actionGateContext: missionGateContexts.actionGateContext } : {}),
|
||||||
|
...(missionGateContexts.permanentAgentGating ? { permanentAgentGating: missionGateContexts.permanentAgentGating } : {}),
|
||||||
onFallbackModelUsed: (payload: {
|
onFallbackModelUsed: (payload: {
|
||||||
primaryModel: string;
|
primaryModel: string;
|
||||||
fallbackModel: string;
|
fallbackModel: string;
|
||||||
|
|||||||
43
packages/engine/src/__tests__/agent-mission-tools.test.ts
Normal file
43
packages/engine/src/__tests__/agent-mission-tools.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createMissionTools } from "../agent-tools.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionToolParity 2026-07-29-12:00:
|
||||||
|
FN-8294 proves the engine surface delegates feature linking to the one MissionStore operation,
|
||||||
|
which owns live project-scoped task validation and bidirectional task linkage.
|
||||||
|
*/
|
||||||
|
describe("createMissionTools", () => {
|
||||||
|
it("exposes the complete hierarchy surface with read and mutation names", () => {
|
||||||
|
const store = { getMissionStore: vi.fn() } as never;
|
||||||
|
expect(createMissionTools(store).map((tool) => tool.name)).toEqual([
|
||||||
|
"fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_delete",
|
||||||
|
"fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate",
|
||||||
|
"fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates feature linkage to MissionStore without a second task update", async () => {
|
||||||
|
const linkFeatureToTask = vi.fn().mockResolvedValue({ id: "F-1", taskId: "FN-1", status: "triaged" });
|
||||||
|
const store = { getMissionStore: () => ({ linkFeatureToTask }) } as never;
|
||||||
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_feature_link_task")!;
|
||||||
|
const result = await tool.execute("call", { featureId: "F-1", taskId: "FN-1" });
|
||||||
|
expect(linkFeatureToTask).toHaveBeenCalledWith("F-1", "FN-1");
|
||||||
|
expect(result.details).toMatchObject({ feature: { taskId: "FN-1", status: "triaged" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a structured error for missing hierarchy records", async () => {
|
||||||
|
const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||||
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;
|
||||||
|
const result = await tool.execute("call", { id: "M-missing" });
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.details).toMatchObject({ code: "MISSION_NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves supplied empty updates so descriptions can be cleared", async () => {
|
||||||
|
const updateMission = vi.fn().mockResolvedValue({ id: "M-1", title: "Mission" });
|
||||||
|
const store = { getMissionStore: () => ({ updateMission }) } as never;
|
||||||
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_update")!;
|
||||||
|
await tool.execute("call", { id: "M-1", description: " " });
|
||||||
|
expect(updateMission).toHaveBeenCalledWith("M-1", { description: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6789,8 +6789,9 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
|
|||||||
async function captureCreateFnAgentArgs(options?: {
|
async function captureCreateFnAgentArgs(options?: {
|
||||||
assignedAgentId?: string;
|
assignedAgentId?: string;
|
||||||
assignedAgentSkills?: string[];
|
assignedAgentSkills?: string[];
|
||||||
|
permissionPolicy?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const { assignedAgentId, assignedAgentSkills } = options || {};
|
const { assignedAgentId, assignedAgentSkills, permissionPolicy } = options || {};
|
||||||
|
|
||||||
const mockAgentStore = {
|
const mockAgentStore = {
|
||||||
getAgent: vi.fn().mockImplementation(async (id: string) => {
|
getAgent: vi.fn().mockImplementation(async (id: string) => {
|
||||||
@@ -6801,6 +6802,7 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
|
|||||||
role: "triage",
|
role: "triage",
|
||||||
state: "idle",
|
state: "idle",
|
||||||
metadata: { skills: assignedAgentSkills },
|
metadata: { skills: assignedAgentSkills },
|
||||||
|
permissionPolicy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -6883,6 +6885,36 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
|
|||||||
expect(args.skillSelection?.sessionPurpose).toBe("triage");
|
expect(args.skillSelection?.sessionPurpose).toBe("triage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["block", "require-approval"] as const)("passes triage Mission mutations through the %s action gate", async (disposition) => {
|
||||||
|
const args = await captureCreateFnAgentArgs({
|
||||||
|
assignedAgentId: "agent-001",
|
||||||
|
assignedAgentSkills: ["triage"],
|
||||||
|
permissionPolicy: {
|
||||||
|
presetId: "custom",
|
||||||
|
rules: {
|
||||||
|
git_write: "allow",
|
||||||
|
file_write_delete: "allow",
|
||||||
|
command_execution: "allow",
|
||||||
|
network_api: "allow",
|
||||||
|
task_agent_mutation: disposition,
|
||||||
|
review_gate_bypass: "allow",
|
||||||
|
file_scope: "allow",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { evaluateAgentActionGate } = await import("../agent-action-gate.js");
|
||||||
|
expect(args.actionGateContext).toBeDefined();
|
||||||
|
expect(args.permanentAgentGating).toBeDefined();
|
||||||
|
expect(evaluateAgentActionGate({
|
||||||
|
agentId: args.actionGateContext.agentId,
|
||||||
|
taskId: args.actionGateContext.taskId,
|
||||||
|
toolName: "fn_mission_create",
|
||||||
|
args: { title: "Gated mission" },
|
||||||
|
permissionPolicy: args.actionGateContext.permissionPolicy,
|
||||||
|
})).toMatchObject({ category: "task_agent_mutation", disposition });
|
||||||
|
});
|
||||||
|
|
||||||
it("skillSelection is undefined when no agentStore provided (role fallback behavior)", async () => {
|
it("skillSelection is undefined when no agentStore provided (role fallback behavior)", async () => {
|
||||||
// When no agentStore is provided, buildSessionSkillContext uses role fallback
|
// When no agentStore is provided, buildSessionSkillContext uses role fallback
|
||||||
// and skillSelection may be undefined or use role fallback skills
|
// and skillSelection may be undefined or use role fallback skills
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
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, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
|
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createMissionTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
|
||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentInstructionsWithRatings,
|
resolveAgentInstructionsWithRatings,
|
||||||
@@ -2511,6 +2511,7 @@ export class HeartbeatMonitor {
|
|||||||
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
heartbeatTools.push(...createMissionTools(taskStore));
|
||||||
heartbeatTools.push(...createGoalRetrievalTools(taskStore, { runContext }));
|
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));
|
||||||
@@ -3773,6 +3774,7 @@ export class HeartbeatMonitor {
|
|||||||
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.push(...createMissionTools(taskStore));
|
||||||
tools.push(...createGoalRetrievalTools(taskStore, { runContext, taskId }));
|
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));
|
||||||
|
|||||||
@@ -3402,6 +3402,70 @@ export function createGoalRetrievalTools(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionToolParity 2026-07-29-12:00:
|
||||||
|
FN-8294 requires every engine-managed lane to use TaskStore's project-scoped MissionStore
|
||||||
|
instead of reproducing route or pi-extension persistence. Mutations deliberately remain plain
|
||||||
|
ToolDefinitions: session action/permanent-agent gates classify their names at the boundary.
|
||||||
|
*/
|
||||||
|
export const missionListParams = Type.Object({});
|
||||||
|
export const missionShowParams = Type.Object({ id: Type.String({ description: "Mission ID (e.g., M-001)" }) });
|
||||||
|
export const missionCreateParams = Type.Object({
|
||||||
|
title: Type.String({ description: "Mission title — brief but descriptive" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Detailed mission objectives and context" })),
|
||||||
|
autoAdvance: Type.Optional(Type.Boolean({ description: "Automatically activate the next pending slice" })),
|
||||||
|
baseBranch: Type.Optional(Type.String({ description: "Optional integration base branch" })),
|
||||||
|
});
|
||||||
|
export const missionUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()) });
|
||||||
|
export const missionDeleteParams = Type.Object({ id: Type.String() });
|
||||||
|
export const milestoneAddParams = Type.Object({ missionId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()) });
|
||||||
|
export const milestoneUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) });
|
||||||
|
export const milestoneDeleteParams = Type.Object({ milestoneId: Type.String(), force: Type.Optional(Type.Boolean()) });
|
||||||
|
export const sliceAddParams = Type.Object({ milestoneId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()) });
|
||||||
|
export const sliceActivateParams = Type.Object({ id: Type.String() });
|
||||||
|
export const sliceDeleteParams = Type.Object({ sliceId: Type.String(), force: Type.Optional(Type.Boolean()) });
|
||||||
|
export const featureAddParams = Type.Object({ sliceId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) });
|
||||||
|
export const featureUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) });
|
||||||
|
export const featureDeleteParams = Type.Object({ featureId: Type.String(), force: Type.Optional(Type.Boolean()) });
|
||||||
|
export const featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() });
|
||||||
|
|
||||||
|
const missionToolResult = (text: string, details: Record<string, unknown>, isError = false) => ({
|
||||||
|
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
|
||||||
|
});
|
||||||
|
const optionalText = (value: string | undefined) => value?.trim() || undefined;
|
||||||
|
/* FNXC:MissionToolParity 2026-07-30-09:56: A supplied empty update value must remain an empty string so MissionStore can clear it, matching the pi-extension contract; only omitted values leave a field unchanged. */
|
||||||
|
const updateFields = (params: Record<string, unknown>, fields: string[]) => Object.fromEntries(
|
||||||
|
fields.filter((field) => params[field] !== undefined).map((field) => [field, (params[field] as string).trim()]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Create the project-scoped Mission hierarchy surface shared by engine lanes and dashboard chat. */
|
||||||
|
export function createMissionTools(store: TaskStore): ToolDefinition[] {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
const tool = (name: string, label: string, description: string, parameters: any, execute: (params: any) => Promise<ReturnType<typeof missionToolResult>>): ToolDefinition => ({
|
||||||
|
name, label, description, parameters,
|
||||||
|
execute: async (_id, params: any) => { try { return await execute(params); } catch (error) { const message = error instanceof Error ? error.message : String(error); return missionToolResult(`ERROR: ${message}`, { error: message }, true); } },
|
||||||
|
});
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
return [
|
||||||
|
tool("fn_mission_list", "List Missions", "List all missions with their current status.", missionListParams, async () => { const missions = await store.getMissionStore().listMissions(); return missionToolResult(missions.length ? `Missions (${missions.length})\n${missions.map((m) => `- ${m.id}: ${m.title} (${m.status})`).join("\n")}` : "No missions yet.", { missions, count: missions.length }); }),
|
||||||
|
tool("fn_mission_show", "Show Mission", "Show a mission with its full milestone, slice, and feature hierarchy.", missionShowParams, async ({ id }) => { const mission = await store.getMissionStore().getMissionWithHierarchy(id); return mission ? missionToolResult(`${mission.id}: ${mission.title}`, { mission }) : missionToolResult(`Mission ${id} not found`, { code: "MISSION_NOT_FOUND", missionId: id }, true); }),
|
||||||
|
tool("fn_mission_create", "Create Mission", "Create a high-level mission.", missionCreateParams, async (p) => { const ms = store.getMissionStore(); const mission = await ms.createMission({ title: p.title.trim(), description: optionalText(p.description), baseBranch: optionalText(p.baseBranch) }); const updated = p.autoAdvance === undefined ? mission : await ms.updateMission(mission.id, { autoAdvance: p.autoAdvance }); return missionToolResult(`Created ${updated.id}: ${updated.title}`, { mission: updated }); }),
|
||||||
|
tool("fn_mission_update", "Update Mission", "Partially update a mission.", missionUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const mission = await store.getMissionStore().updateMission(p.id, updates); return missionToolResult(`Updated ${mission.id}: ${mission.title}`, { mission }); }),
|
||||||
|
tool("fn_mission_delete", "Delete Mission", "Delete a mission and its hierarchy.", missionDeleteParams, async ({ id }) => { await store.getMissionStore().deleteMission(id); return missionToolResult(`Deleted ${id}`, { missionId: id }); }),
|
||||||
|
tool("fn_milestone_add", "Add Milestone", "Add a milestone to a mission.", milestoneAddParams, async (p) => { const milestone = await store.getMissionStore().addMilestone(p.missionId, { title: p.title.trim(), description: optionalText(p.description) }); return missionToolResult(`Added ${milestone.id}`, { milestone }); }),
|
||||||
|
tool("fn_milestone_update", "Update Milestone", "Partially update a milestone.", milestoneUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const milestone = await store.getMissionStore().updateMilestone(p.id, updates); return missionToolResult(`Updated ${milestone.id}`, { milestone }); }),
|
||||||
|
tool("fn_milestone_delete", "Delete Milestone", "Delete a milestone and descendants.", milestoneDeleteParams, async (p) => { await store.getMissionStore().deleteMilestone(p.milestoneId, p.force === true); return missionToolResult(`Deleted ${p.milestoneId}`, { milestoneId: p.milestoneId }); }),
|
||||||
|
tool("fn_slice_add", "Add Slice", "Add a slice to a milestone.", sliceAddParams, async (p) => { const slice = await store.getMissionStore().addSlice(p.milestoneId, { title: p.title.trim(), description: optionalText(p.description) }); return missionToolResult(`Added ${slice.id}`, { slice }); }),
|
||||||
|
tool("fn_slice_activate", "Activate Slice", "Activate a pending slice.", sliceActivateParams, async ({ id }) => { const slice = await store.getMissionStore().activateSlice(id); return missionToolResult(`Activated ${slice.id}`, { slice }); }),
|
||||||
|
tool("fn_slice_delete", "Delete Slice", "Delete a slice and descendants.", sliceDeleteParams, async (p) => { await store.getMissionStore().deleteSlice(p.sliceId, p.force === true); return missionToolResult(`Deleted ${p.sliceId}`, { sliceId: p.sliceId }); }),
|
||||||
|
tool("fn_feature_add", "Add Feature", "Add a feature to a slice.", featureAddParams, async (p) => { const feature = await store.getMissionStore().addFeature(p.sliceId, { title: p.title.trim(), description: optionalText(p.description), acceptanceCriteria: optionalText(p.acceptanceCriteria) }); return missionToolResult(`Added ${feature.id}`, { feature }); }),
|
||||||
|
tool("fn_feature_update", "Update Feature", "Partially update a feature.", featureUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const feature = await store.getMissionStore().updateFeature(p.id, updates); return missionToolResult(`Updated ${feature.id}`, { feature }); }),
|
||||||
|
tool("fn_feature_delete", "Delete Feature", "Delete a feature, respecting linked-task guards.", featureDeleteParams, async (p) => { await store.getMissionStore().deleteFeature(p.featureId, p.force ===true); return missionToolResult(`Deleted ${p.featureId}`, { featureId: p.featureId }); }),
|
||||||
|
tool("fn_feature_link_task", "Link Feature to Task", "Link a feature to a live project-scoped task.", featureLinkTaskParams, async (p) => { const feature = await store.getMissionStore().linkFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Linked ${feature.id} to ${p.taskId}`, { feature }); }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ import {
|
|||||||
createListAgentsTool,
|
createListAgentsTool,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
createGoalRetrievalTools,
|
createGoalRetrievalTools,
|
||||||
|
createMissionTools,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createReadMessagesTool,
|
createReadMessagesTool,
|
||||||
createReflectOnPerformanceTool,
|
createReflectOnPerformanceTool,
|
||||||
@@ -11668,6 +11669,7 @@ export class TaskExecutor {
|
|||||||
getSettings: async () => this.store.getSettings(),
|
getSettings: async () => this.store.getSettings(),
|
||||||
})
|
})
|
||||||
: []),
|
: []),
|
||||||
|
...createMissionTools(this.store),
|
||||||
...createGoalRetrievalTools(this.store, {
|
...createGoalRetrievalTools(this.store, {
|
||||||
runContext: {
|
runContext: {
|
||||||
runId: engineRunContext.runId,
|
runId: engineRunContext.runId,
|
||||||
|
|||||||
@@ -93,9 +93,17 @@ export const TASK_AGENT_MUTATION_TOOLS: ReadonlySet<string> = new Set([
|
|||||||
|
|
||||||
// FN-3953: provisioning tools are gated by dedicated agent_provisioning policy;
|
// FN-3953: provisioning tools are gated by dedicated agent_provisioning policy;
|
||||||
// keep them out of action-gate task_agent_mutation to avoid double approval rows.
|
// keep them out of action-gate task_agent_mutation to avoid double approval rows.
|
||||||
|
/*
|
||||||
|
FNXC:MissionToolGating 2026-07-30-10:31:
|
||||||
|
FN-8294 exposes Mission hierarchy mutations to triage alongside executor and
|
||||||
|
heartbeat. Every permanent-agent task mutation must also be action-gated;
|
||||||
|
otherwise Mission writes fall through the action gate's exempt default even
|
||||||
|
when their permanent-agent classification is restrictive.
|
||||||
|
*/
|
||||||
export const ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
|
export const ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
|
||||||
...ACTION_GATE_SHARED_TASK_AGENT_TOOLS,
|
...ACTION_GATE_SHARED_TASK_AGENT_TOOLS,
|
||||||
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
|
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
|
||||||
|
...PERMANENT_TASK_AGENT_ONLY_TOOLS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet<string> = new Set([
|
export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet<string> = new Set([
|
||||||
@@ -224,6 +232,9 @@ export const COORDINATION_EXEMPT_TOOLS = [
|
|||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
"fn_goal_list",
|
"fn_goal_list",
|
||||||
"fn_goal_show",
|
"fn_goal_show",
|
||||||
|
// FNXC:MissionToolGating 2026-07-30-10:31: Mission reads are safe coordination, but must be registered here as well as READONLY_FN_TOOLS so the action gate recognizes rather than silently defaulting them.
|
||||||
|
"fn_mission_list",
|
||||||
|
"fn_mission_show",
|
||||||
"fn_list_agents",
|
"fn_list_agents",
|
||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
|
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
|
||||||
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
|
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
|
||||||
|
export type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||||
export { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js";
|
export { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js";
|
||||||
export {
|
export {
|
||||||
wrapAuthStorageWithApiKeyProviders,
|
wrapAuthStorageWithApiKeyProviders,
|
||||||
@@ -31,6 +32,7 @@ export {
|
|||||||
createGetAgentConfigTool,
|
createGetAgentConfigTool,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createGoalRetrievalTools,
|
createGoalRetrievalTools,
|
||||||
|
createMissionTools,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
createResearchTools,
|
createResearchTools,
|
||||||
createArtifactListTool,
|
createArtifactListTool,
|
||||||
@@ -92,6 +94,21 @@ export {
|
|||||||
memoryGetParams,
|
memoryGetParams,
|
||||||
goalListParams,
|
goalListParams,
|
||||||
goalShowParams,
|
goalShowParams,
|
||||||
|
missionListParams,
|
||||||
|
missionShowParams,
|
||||||
|
missionCreateParams,
|
||||||
|
missionUpdateParams,
|
||||||
|
missionDeleteParams,
|
||||||
|
milestoneAddParams,
|
||||||
|
milestoneUpdateParams,
|
||||||
|
milestoneDeleteParams,
|
||||||
|
sliceAddParams,
|
||||||
|
sliceActivateParams,
|
||||||
|
sliceDeleteParams,
|
||||||
|
featureAddParams,
|
||||||
|
featureUpdateParams,
|
||||||
|
featureDeleteParams,
|
||||||
|
featureLinkTaskParams,
|
||||||
researchRunParams,
|
researchRunParams,
|
||||||
researchListParams,
|
researchListParams,
|
||||||
researchGetParams,
|
researchGetParams,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import type {
|
|||||||
Settings,
|
Settings,
|
||||||
WorkflowStepResult,
|
WorkflowStepResult,
|
||||||
WorkflowIr,
|
WorkflowIr,
|
||||||
|
Agent,
|
||||||
|
AgentPermissionPolicy,
|
||||||
|
PermanentAgentGatingContext,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
DUPLICATE_OF_METADATA_KEY,
|
DUPLICATE_OF_METADATA_KEY,
|
||||||
@@ -41,6 +44,10 @@ import {
|
|||||||
applyFrontendUxCriteria,
|
applyFrontendUxCriteria,
|
||||||
applyOriginalDescription,
|
applyOriginalDescription,
|
||||||
extractEffectiveWriteScopeFromPrompt,
|
extractEffectiveWriteScopeFromPrompt,
|
||||||
|
ApprovalRequestStore,
|
||||||
|
AWAITING_APPROVAL_PAUSE_REASON,
|
||||||
|
isEphemeralAgent,
|
||||||
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
MAX_TASK_LIST_TEXT_CHARS,
|
MAX_TASK_LIST_TEXT_CHARS,
|
||||||
upsertWorkflowStepResult,
|
upsertWorkflowStepResult,
|
||||||
deriveFallbackTaskTitle,
|
deriveFallbackTaskTitle,
|
||||||
@@ -164,6 +171,7 @@ import {
|
|||||||
createListAgentsTool,
|
createListAgentsTool,
|
||||||
createMemoryTools,
|
createMemoryTools,
|
||||||
createGoalRetrievalTools,
|
createGoalRetrievalTools,
|
||||||
|
createMissionTools,
|
||||||
createResearchTools,
|
createResearchTools,
|
||||||
createWebFetchTool,
|
createWebFetchTool,
|
||||||
createTaskDocumentReadTool,
|
createTaskDocumentReadTool,
|
||||||
@@ -182,6 +190,8 @@ import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
|
|||||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||||
import { reviewStep } from "./reviewer.js";
|
import { reviewStep } from "./reviewer.js";
|
||||||
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
||||||
|
import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||||
|
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
|
||||||
|
|
||||||
|
|
||||||
export interface TriageProcessorOptions {
|
export interface TriageProcessorOptions {
|
||||||
@@ -238,6 +248,7 @@ export class TriageProcessor {
|
|||||||
private stuckAborted = new Set<string>();
|
private stuckAborted = new Set<string>();
|
||||||
private taskDeletedHandler?: (task: Task) => void;
|
private taskDeletedHandler?: (task: Task) => void;
|
||||||
private taskPausedHandler?: (task: Task) => void;
|
private taskPausedHandler?: (task: Task) => void;
|
||||||
|
private _approvalRequestStore?: ApprovalRequestStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param store — Task store instance (also used to listen for `settings:updated` events)
|
* @param store — Task store instance (also used to listen for `settings:updated` events)
|
||||||
@@ -249,6 +260,103 @@ export class TriageProcessor {
|
|||||||
* terminated. When `enginePaused` transitions, only new work dispatch is
|
* terminated. When `enginePaused` transitions, only new work dispatch is
|
||||||
* affected — running sessions continue to completion.
|
* affected — running sessions continue to completion.
|
||||||
*/
|
*/
|
||||||
|
private get approvalRequestStore(): ApprovalRequestStore {
|
||||||
|
if (!this._approvalRequestStore) {
|
||||||
|
const layer = this.store.getAsyncLayer();
|
||||||
|
if (!layer) throw new Error("Triage TaskStore is missing its PostgreSQL AsyncDataLayer");
|
||||||
|
this._approvalRequestStore = new ApprovalRequestStore(null, { asyncLayer: layer });
|
||||||
|
}
|
||||||
|
return this._approvalRequestStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:TriageMissionGating 2026-07-30-10:25:
|
||||||
|
Mission hierarchy mutations are available during triage, but must use the same
|
||||||
|
policy and approval contexts as executor and heartbeat sessions. A planner is
|
||||||
|
not a policy bypass: every non-read Mission tool remains action-gated and
|
||||||
|
permanent-agent gated under the effective assigned-agent or project policy.
|
||||||
|
*/
|
||||||
|
private buildActionGateContext(
|
||||||
|
taskId: string,
|
||||||
|
runId: string,
|
||||||
|
agent: Agent | null,
|
||||||
|
projectDefaultPolicy?: { rules?: Partial<AgentPermissionPolicy["rules"]>; toolRules?: AgentPermissionPolicy["toolRules"] },
|
||||||
|
): AgentActionGateContext {
|
||||||
|
const actorId = agent?.id ?? `triage-${taskId}`;
|
||||||
|
const actorName = agent?.name ?? `Triage planner ${taskId}`;
|
||||||
|
const permissionPolicy = resolveEffectiveAgentPermissionPolicy(agent?.permissionPolicy, projectDefaultPolicy);
|
||||||
|
return {
|
||||||
|
agentId: actorId,
|
||||||
|
agentName: actorName,
|
||||||
|
isEphemeral: !agent || isEphemeralAgent(agent),
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
permissionPolicy,
|
||||||
|
createApprovalRequest: async (decision, args) => await this.approvalRequestStore.create({
|
||||||
|
requester: { actorId, actorType: "agent", actorName },
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
targetAction: {
|
||||||
|
category: decision.category === "exempt" ? "command_execution" : decision.category,
|
||||||
|
action: decision.operation,
|
||||||
|
summary: decision.summary,
|
||||||
|
resourceType: decision.resourceType,
|
||||||
|
resourceId: decision.resourceId ?? "",
|
||||||
|
context: { ...decision.metadata, approvalDedupeKey: decision.approvalDedupeKey, toolName: decision.toolName, toolArgs: args },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findApprovalByDedupeKey: async (dedupeKey) => {
|
||||||
|
const latest = await this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: actorId, taskId, dedupeKey });
|
||||||
|
return latest ? { id: latest.id, status: latest.status } : null;
|
||||||
|
},
|
||||||
|
pauseForApproval: async ({ approvalRequestId, decision }) => {
|
||||||
|
await this.store.pauseTask(taskId, true, { runId, agentId: actorId, source: "triage" }, { pausedByAgentId: actorId, pausedReason: AWAITING_APPROVAL_PAUSE_REASON });
|
||||||
|
await this.store.logEntry(taskId, `Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`);
|
||||||
|
if (agent && this.options.agentStore) {
|
||||||
|
await this.options.agentStore.updateAgentState(agent.id, "paused");
|
||||||
|
await this.options.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
|
||||||
|
}
|
||||||
|
queueMicrotask(() => this.activeSessions.get(taskId)?.dispose());
|
||||||
|
},
|
||||||
|
markApprovalCompleted: async (approvalRequestId) => {
|
||||||
|
await this.approvalRequestStore.markCompleted(approvalRequestId, { actor: { actorId, actorType: "agent", actorName }, note: "Tool executed after approval" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPermanentAgentGatingContext(
|
||||||
|
taskId: string,
|
||||||
|
runId: string,
|
||||||
|
agent: Agent | null,
|
||||||
|
projectDefaultPolicy?: { rules?: Partial<AgentPermissionPolicy["rules"]>; toolRules?: AgentPermissionPolicy["toolRules"] },
|
||||||
|
): PermanentAgentGatingContext {
|
||||||
|
const actorId = agent?.id ?? `triage-${taskId}`;
|
||||||
|
const actorName = agent?.name ?? `Triage planner ${taskId}`;
|
||||||
|
return {
|
||||||
|
permissionPolicy: resolveEffectiveAgentPermissionPolicy(agent?.permissionPolicy, projectDefaultPolicy),
|
||||||
|
requester: { actorId, actorType: "agent", actorName },
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => await this.approvalRequestStore.create({
|
||||||
|
requester: { actorId, actorType: "agent", actorName },
|
||||||
|
taskId,
|
||||||
|
runId,
|
||||||
|
targetAction: {
|
||||||
|
category,
|
||||||
|
action: toolName,
|
||||||
|
summary: buildAgentGatedActionSummary(toolName, args),
|
||||||
|
resourceType: "tool",
|
||||||
|
resourceId: toolName,
|
||||||
|
context: { toolName, toolArgs: args, source: "agent-gating", ...(approvalDedupeKey ? { approvalDedupeKey } : {}) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
findPendingApprovalRequest: async (dedupeKey) => {
|
||||||
|
const pending = await this.approvalRequestStore.list({ status: "pending", requesterActorId: actorId, taskId, limit: 100 });
|
||||||
|
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private store: TaskStore,
|
private store: TaskStore,
|
||||||
private rootDir: string,
|
private rootDir: string,
|
||||||
@@ -1090,6 +1198,7 @@ export class TriageProcessor {
|
|||||||
getSettings: async () => this.store.getSettings(),
|
getSettings: async () => this.store.getSettings(),
|
||||||
})
|
})
|
||||||
: []),
|
: []),
|
||||||
|
...createMissionTools(this.store),
|
||||||
...createGoalRetrievalTools(this.store, {
|
...createGoalRetrievalTools(this.store, {
|
||||||
runContext: {
|
runContext: {
|
||||||
runId: triageRunContext.runId,
|
runId: triageRunContext.runId,
|
||||||
@@ -1302,6 +1411,8 @@ export class TriageProcessor {
|
|||||||
...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}),
|
...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}),
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskTitle: task.title,
|
taskTitle: task.title,
|
||||||
|
actionGateContext: this.buildActionGateContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy),
|
||||||
|
permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy),
|
||||||
onFallbackModelUsed: createFallbackModelObserver({
|
onFallbackModelUsed: createFallbackModelObserver({
|
||||||
agent: "triage",
|
agent: "triage",
|
||||||
label: "triage",
|
label: "triage",
|
||||||
|
|||||||
Reference in New Issue
Block a user