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:
gsxdsm
2026-07-18 10:31:59 -07:00
parent a6f1ed07e2
commit 8d1620ea23
14 changed files with 457 additions and 8 deletions

View 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.

View File

@@ -666,3 +666,11 @@ This lifecycle is validated by integration tests in two dependent tasks:
![Mission manager](./screenshots/mission-manager.png)
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.

View File

@@ -4198,8 +4198,8 @@ export default function kbExtension(pi: ExtensionAPI) {
}
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);
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
return {
content: [

View File

@@ -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 () => {
let capturedTools: Array<{ name: string; execute?: (...args: any[]) => Promise<any> }> = [];
__setCreateFnAgent(async (options: any) => {

View File

@@ -71,6 +71,7 @@ vi.mock("@fusion/engine", () => ({
createGetAgentConfigTool: vi.fn(),
createWebFetchTool: vi.fn(),
createGoalRetrievalTools: vi.fn(() => []),
createMissionTools: vi.fn(() => []),
createMemoryTools: vi.fn(() => []),
createResearchTools: vi.fn(() => []),
resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })),

View File

@@ -27,9 +27,16 @@ import type {
MessageStore,
Settings,
TaskStore,
PermanentAgentGatingContext,
} from "@fusion/core";
import type { AgentActionGateContext, SkillSelectionContext } from "@fusion/engine";
import {
ApprovalRequestStore,
isEphemeralAgent,
resolveEffectiveAgentPermissionPolicy,
summarizeTitle,
FUSION_RUNTIME_SELF_AWARENESS,
} from "@fusion/core";
import type { SkillSelectionContext } from "@fusion/engine";
import { summarizeTitle, FUSION_RUNTIME_SELF_AWARENESS } from "@fusion/core";
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { join, resolve, relative } from "node:path";
@@ -68,6 +75,7 @@ import {
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
createMissionTools,
createMemoryTools,
createResearchTools,
resolveMcpServersForStore,
@@ -347,6 +355,91 @@ export interface ChatFusionToolsetOptions {
agentStore?: AgentStore;
rootDir: 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,
must receive one safe coordination/productivity toolset in both model-loop and
room-responder lanes. Workflow, document, artifact, messaging, and task-planner
tools stay additive at their call sites; agent lifecycle mutation and memory
append remain excluded because chat has no action-gate context.
tools stay additive at their call sites; unbound chat retains only read-only Mission
tools because it has no durable action-gate principal.
*/
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[] = [];
if (taskStore) {
@@ -368,6 +461,8 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
createTaskShowTool(taskStore),
createTaskSearchTool(taskStore),
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),
/* 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"),
@@ -1860,11 +1955,13 @@ export class ChatManager {
);
const workflowTools = createChatWorkflowAuthoringTools(this.taskStore, input.roomProjectId);
const missionGateContexts = await createChatMissionGateContexts(this.taskStore, this.agentStore, input.responder);
const chatFusionTools = await createChatFusionToolset({
taskStore: this.taskStore,
agentStore: this.agentStore,
rootDir: this.rootDir,
agentId: input.responder.id,
missionMutationGated: missionGateContexts.missionMutationGated,
});
const resolvedSession = await createResolvedAgentSession({
@@ -1898,6 +1995,8 @@ export class ChatManager {
fallbackModelId: chatModelSettings.fallbackModelId,
}
: {}),
...(missionGateContexts.actionGateContext ? { actionGateContext: missionGateContexts.actionGateContext } : {}),
...(missionGateContexts.permanentAgentGating ? { permanentAgentGating: missionGateContexts.permanentAgentGating } : {}),
onFallbackModelUsed: (payload: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => {
roomFallbackInfo = payload;
diagnostics.warn(
@@ -2415,11 +2514,13 @@ export class ChatManager {
? [createTaskPlannerRefinementTool(this.taskStore, taskPlannerChatTaskId)]
: [];
const missionGateContexts = await createChatMissionGateContexts(this.taskStore, this.agentStore, agent);
const chatFusionTools = await createChatFusionToolset({
taskStore: this.taskStore,
agentStore: this.agentStore,
rootDir: this.rootDir,
agentId: agent?.id,
missionMutationGated: missionGateContexts.missionMutationGated,
});
const customTools = dedupeChatTools([
createAskQuestionTool(),
@@ -2453,6 +2554,8 @@ export class ChatManager {
fallbackModelId: chatModelSettings.fallbackModelId,
}
: {}),
...(missionGateContexts.actionGateContext ? { actionGateContext: missionGateContexts.actionGateContext } : {}),
...(missionGateContexts.permanentAgentGating ? { permanentAgentGating: missionGateContexts.permanentAgentGating } : {}),
onFallbackModelUsed: (payload: {
primaryModel: string;
fallbackModel: string;

View 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: "" });
});
});

View File

@@ -6789,8 +6789,9 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
async function captureCreateFnAgentArgs(options?: {
assignedAgentId?: string;
assignedAgentSkills?: string[];
permissionPolicy?: Record<string, unknown>;
}) {
const { assignedAgentId, assignedAgentSkills } = options || {};
const { assignedAgentId, assignedAgentSkills, permissionPolicy } = options || {};
const mockAgentStore = {
getAgent: vi.fn().mockImplementation(async (id: string) => {
@@ -6801,6 +6802,7 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
role: "triage",
state: "idle",
metadata: { skills: assignedAgentSkills },
permissionPolicy,
};
}
return null;
@@ -6883,6 +6885,36 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => {
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 () => {
// When no agentStore is provided, buildSessionSkillContext uses role fallback
// and skillSelection may be undefined or use role fallback skills

View File

@@ -38,7 +38,7 @@ import {
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, 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 {
resolveAgentInstructionsWithRatings,
@@ -2511,6 +2511,7 @@ export class HeartbeatMonitor {
heartbeatTools.push(createPostRoomMessageTool(this.chatStore, agentId));
}
heartbeatTools.push(...createMissionTools(taskStore));
heartbeatTools.push(...createGoalRetrievalTools(taskStore, { runContext }));
heartbeatTools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
heartbeatTools.push(createUpdateIdentityTool(this.store, agentId));
@@ -3773,6 +3774,7 @@ export class HeartbeatMonitor {
tools.push(createPostRoomMessageTool(this.chatStore, agentId));
}
tools.push(...createMissionTools(taskStore));
tools.push(...createGoalRetrievalTools(taskStore, { runContext, taskId }));
tools.push(createReadEvaluationsTool(this.store, this.reflectionStore, agentId));
tools.push(createUpdateIdentityTool(this.store, agentId));

View File

@@ -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
* analyze recent agent performance and return actionable insights.

View File

@@ -232,6 +232,7 @@ import {
createListAgentsTool,
createMemoryTools,
createGoalRetrievalTools,
createMissionTools,
createWebFetchTool,
createReadMessagesTool,
createReflectOnPerformanceTool,
@@ -11668,6 +11669,7 @@ export class TaskExecutor {
getSettings: async () => this.store.getSettings(),
})
: []),
...createMissionTools(this.store),
...createGoalRetrievalTools(this.store, {
runContext: {
runId: engineRunContext.runId,

View File

@@ -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;
// 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([
...ACTION_GATE_SHARED_TASK_AGENT_TOOLS,
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
...PERMANENT_TASK_AGENT_ONLY_TOOLS,
]);
export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet<string> = new Set([
@@ -224,6 +232,9 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_heartbeat_done",
"fn_goal_list",
"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_agent_show",
"fn_agent_org_chart",

View File

@@ -1,5 +1,6 @@
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.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 {
wrapAuthStorageWithApiKeyProviders,
@@ -31,6 +32,7 @@ export {
createGetAgentConfigTool,
createWebFetchTool,
createGoalRetrievalTools,
createMissionTools,
createMemoryTools,
createResearchTools,
createArtifactListTool,
@@ -92,6 +94,21 @@ export {
memoryGetParams,
goalListParams,
goalShowParams,
missionListParams,
missionShowParams,
missionCreateParams,
missionUpdateParams,
missionDeleteParams,
milestoneAddParams,
milestoneUpdateParams,
milestoneDeleteParams,
sliceAddParams,
sliceActivateParams,
sliceDeleteParams,
featureAddParams,
featureUpdateParams,
featureDeleteParams,
featureLinkTaskParams,
researchRunParams,
researchListParams,
researchGetParams,

View File

@@ -8,6 +8,9 @@ import type {
Settings,
WorkflowStepResult,
WorkflowIr,
Agent,
AgentPermissionPolicy,
PermanentAgentGatingContext,
} from "@fusion/core";
import {
DUPLICATE_OF_METADATA_KEY,
@@ -41,6 +44,10 @@ import {
applyFrontendUxCriteria,
applyOriginalDescription,
extractEffectiveWriteScopeFromPrompt,
ApprovalRequestStore,
AWAITING_APPROVAL_PAUSE_REASON,
isEphemeralAgent,
resolveEffectiveAgentPermissionPolicy,
MAX_TASK_LIST_TEXT_CHARS,
upsertWorkflowStepResult,
deriveFallbackTaskTitle,
@@ -164,6 +171,7 @@ import {
createListAgentsTool,
createMemoryTools,
createGoalRetrievalTools,
createMissionTools,
createResearchTools,
createWebFetchTool,
createTaskDocumentReadTool,
@@ -182,6 +190,8 @@ import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { reviewStep } from "./reviewer.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 {
@@ -238,6 +248,7 @@ export class TriageProcessor {
private stuckAborted = new Set<string>();
private taskDeletedHandler?: (task: Task) => void;
private taskPausedHandler?: (task: Task) => void;
private _approvalRequestStore?: ApprovalRequestStore;
/**
* @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
* 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(
private store: TaskStore,
private rootDir: string,
@@ -1090,6 +1198,7 @@ export class TriageProcessor {
getSettings: async () => this.store.getSettings(),
})
: []),
...createMissionTools(this.store),
...createGoalRetrievalTools(this.store, {
runContext: {
runId: triageRunContext.runId,
@@ -1302,6 +1411,8 @@ export class TriageProcessor {
...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}),
taskId: task.id,
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({
agent: "triage",
label: "triage",