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).
|
||||
|
||||
## 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 {
|
||||
// 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: [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: [] })),
|
||||
|
||||
@@ -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;
|
||||
|
||||
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?: {
|
||||
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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user