From 8d1620ea23e77824a6c68d93d2d4c6290de015ee Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 10:31:59 -0700 Subject: [PATCH] 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) --- .changeset/fn-8294-mission-engine-tools.md | 7 ++ docs/missions.md | 8 ++ packages/cli/src/extension.ts | 2 +- .../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(-) create mode 100644 .changeset/fn-8294-mission-engine-tools.md create mode 100644 packages/engine/src/__tests__/agent-mission-tools.test.ts diff --git a/.changeset/fn-8294-mission-engine-tools.md b/.changeset/fn-8294-mission-engine-tools.md new file mode 100644 index 0000000000..2f75564e25 --- /dev/null +++ b/.changeset/fn-8294-mission-engine-tools.md @@ -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. diff --git a/docs/missions.md b/docs/missions.md index b64e329d35..12bcd4f360 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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. diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 03a2965bff..9e2c4315b9 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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: [ diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 079869ef45..6aac4e7492 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -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 }> = []; __setCreateFnAgent(async (options: any) => { diff --git a/packages/dashboard/src/__tests__/chat.test.ts b/packages/dashboard/src/__tests__/chat.test.ts index 6ddfeda9c1..fd4c77f40e 100644 --- a/packages/dashboard/src/__tests__/chat.test.ts +++ b/packages/dashboard/src/__tests__/chat.test.ts @@ -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: [] })), diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index c142865d9b..2ea971ddff 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -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> { + 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 { - 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; diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts new file mode 100644 index 0000000000..09f6aefc71 --- /dev/null +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -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: "" }); + }); +}); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index b562d3854d..4f70e658a6 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -6789,8 +6789,9 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => { async function captureCreateFnAgentArgs(options?: { assignedAgentId?: string; assignedAgentSkills?: string[]; + permissionPolicy?: Record; }) { - 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 diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 8e025a47bc..0945913209 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -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)); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index ab2b8c5e3e..ab531b9895 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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, 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, 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>): 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. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 4ce2fb5972..6e6a2b87d2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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, diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index c7b99e99ea..80797f9665 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -93,9 +93,17 @@ export const TASK_AGENT_MUTATION_TOOLS: ReadonlySet = 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 = 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 = 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", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 7f817fbbd5..be5476b1ad 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 1a13f120d5..3513b601ae 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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(); 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; 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; 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",