fix(dashboard): expose full permission-mapped task toolset in chat sessions (#2376)
## Bug Chat-session tool surface missing task-mutation tools that exist outside of chat, even when the agent's permission record grants them.\n\nRepro: agent-09dcf8b2 (role: custom, CEO) in NextGenEHS has tasks:archive / tasks:delete / tasks:merge / tasks:retry / tasks:update true in its permission record with permissionPolicy.presetId = unrestricted and task_agent_mutation = allow. Calling fn_task_archive / fn_task_delete / fn_task_merge in chat returns: Tool fn_task_* not found.\n\nRoot cause: packages/dashboard/src/chat.ts createChatFusionToolset() built a hardcoded narrow chat-only allowlist while heartbeat registered the complete lifecycle surface unconditionally.\n\nFix:\n- Add exported factories in packages/engine/src/agent-tools.ts for missing lifecycle tools: fn_task_archive, fn_task_unarchive, fn_task_delete, fn_task_retry, fn_task_pause, fn_task_unpause, fn_task_duplicate, fn_task_merge, fn_task_update, fn_task_add_dep, fn_task_promote, fn_trait_list, fn_ask_question, fn_reflect_on_performance, fn_read_evaluations, fn_update_identity, fn_send_message, fn_read_messages.\n- Wire those factories into createChatFusionToolset(). Mission/ideation mutations stay behind missionMutationGated. Agent-scoped tools still require agentId.\n- Re-export from packages/engine/src/index.ts.\n- Regression test: packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts (3/3 passing). Existing chat.test.ts (14/14 passing). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Chat now exposes task lifecycle actions—including archive, retry, pause, duplicate, merge, and dependency updates—when permitted by the agent’s action controls. - Added support for identity updates and evaluation viewing in agent-linked chats. - Existing read-only tools remain available, while restricted actions stay hidden when authorization is unavailable. - **Tests** - Added regression coverage for authorized and unauthorized chat tool surfaces, including preservation of read-only capabilities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/chat-tool-permission-parity.md
Normal file
7
.changeset/chat-tool-permission-parity.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Chat sessions now expose the full permission-mapped task toolset for gated agents.
|
||||||
|
category: feature
|
||||||
|
dev: createChatFusionToolset binds fn_task_archive/unarchive/delete/retry/pause/unpause/duplicate/merge only when an enforceable actionGateContext is present (wrapToolsWithActionGate is a pass-through without a gate, so ungated registration would bypass task_agent_mutation policy). Adds 10 task-lifecycle tool factories to @fusion/engine agent-tools. fn_task_update/add_dep/promote are intentionally not bound in project-scoped chat (no ambient task id). fn_read_evaluations degrades to ratings-only (no ReflectionStore in chat); fn_reflect_on_performance is omitted (no AgentReflectionService). Regression tests assert the gated surface, the withheld surface without a gate, and absence of ambient-task tools.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { TaskStore, AgentStore, MessageStore, Settings } from "@fusion/core";
|
||||||
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { createChatFusionToolset } from "../chat.js";
|
||||||
|
|
||||||
|
function makeTool(name: string): ToolDefinition {
|
||||||
|
return { name, label: name, description: "", parameters: { type: "object", properties: {} }, execute: async () => ({ content: [], details: {} }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTaskStore = () => ({
|
||||||
|
getSettings: vi.fn(async () => ({})),
|
||||||
|
} as unknown as TaskStore);
|
||||||
|
|
||||||
|
const baseAgentStore = {} as unknown as AgentStore;
|
||||||
|
|
||||||
|
const baseMessageStore = {} as unknown as MessageStore;
|
||||||
|
|
||||||
|
describe("createChatFusionToolset — permission-parity regression", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task-lifecycle mutation tools that require an enforceable action-gate context.
|
||||||
|
// These are only exposed when actionGateContext is present, because
|
||||||
|
// wrapToolsWithActionGate is a pass-through without a gate (pi.ts) — advertising
|
||||||
|
// them ungated would let archive/delete/retry/etc. run with no policy enforcement.
|
||||||
|
const gatedMutationTools = [
|
||||||
|
"fn_task_archive",
|
||||||
|
"fn_task_unarchive",
|
||||||
|
"fn_task_delete",
|
||||||
|
"fn_task_retry",
|
||||||
|
"fn_task_pause",
|
||||||
|
"fn_task_unpause",
|
||||||
|
"fn_task_duplicate",
|
||||||
|
"fn_task_merge",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tools that target the factory's ambient current-task id. Project-scoped chat has
|
||||||
|
// no ambient task, so binding "" would make them operate on no task — they are
|
||||||
|
// intentionally NOT part of the chat surface (executor/heartbeat bind them with a
|
||||||
|
// concrete task id instead).
|
||||||
|
const ambientTaskTools = ["fn_task_update", "fn_task_add_dep", "fn_task_promote"];
|
||||||
|
|
||||||
|
it("exposes gated task-mutation surface when an action-gate context is present", async () => {
|
||||||
|
const taskStore = baseTaskStore();
|
||||||
|
const tools = await createChatFusionToolset({
|
||||||
|
taskStore,
|
||||||
|
agentStore: baseAgentStore,
|
||||||
|
rootDir: "/project",
|
||||||
|
agentId: "agent-abc",
|
||||||
|
missionMutationGated: true,
|
||||||
|
actionGateContext: {} as any,
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((t) => t.name));
|
||||||
|
for (const name of gatedMutationTools) {
|
||||||
|
expect(names.has(name), `missing gated mutation tool: ${name}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds task-mutation tools when there is no enforceable action-gate context", async () => {
|
||||||
|
const taskStore = baseTaskStore();
|
||||||
|
const tools = await createChatFusionToolset({
|
||||||
|
taskStore,
|
||||||
|
agentStore: baseAgentStore,
|
||||||
|
rootDir: "/project",
|
||||||
|
agentId: "agent-abc",
|
||||||
|
missionMutationGated: false,
|
||||||
|
// no actionGateContext
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((t) => t.name));
|
||||||
|
for (const name of gatedMutationTools) {
|
||||||
|
expect(names.has(name), `mutation tool leaked without gate: ${name}`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never binds ambient-task tools in project-scoped chat (no ambient task id)", async () => {
|
||||||
|
const taskStore = baseTaskStore();
|
||||||
|
for (const gate of [undefined, {} as any]) {
|
||||||
|
const tools = await createChatFusionToolset({
|
||||||
|
taskStore,
|
||||||
|
agentStore: baseAgentStore,
|
||||||
|
rootDir: "/project",
|
||||||
|
agentId: "agent-abc",
|
||||||
|
missionMutationGated: gate ? true : false,
|
||||||
|
...(gate ? { actionGateContext: gate } : {}),
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((t) => t.name));
|
||||||
|
for (const name of ambientTaskTools) {
|
||||||
|
expect(names.has(name), `ambient-task tool must not be bound: ${name}`).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bind fn_reflect_on_performance in chat (no reflection service available)", async () => {
|
||||||
|
const taskStore = baseTaskStore();
|
||||||
|
const tools = await createChatFusionToolset({
|
||||||
|
taskStore,
|
||||||
|
agentStore: baseAgentStore,
|
||||||
|
rootDir: "/project",
|
||||||
|
agentId: "agent-abc",
|
||||||
|
actionGateContext: {} as any,
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((t) => t.name));
|
||||||
|
expect(names.has("fn_reflect_on_performance")).toBe(false);
|
||||||
|
// read-only evaluations tool still present (degrades to ratings-only without a store)
|
||||||
|
expect(names.has("fn_read_evaluations")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not regress existing read-only tools", async () => {
|
||||||
|
const taskStore = baseTaskStore();
|
||||||
|
const tools = await createChatFusionToolset({
|
||||||
|
taskStore,
|
||||||
|
agentStore: baseAgentStore,
|
||||||
|
rootDir: "/project",
|
||||||
|
agentId: "agent-abc",
|
||||||
|
});
|
||||||
|
const names = new Set(tools.map((t) => t.name));
|
||||||
|
expect(names.has("fn_task_list")).toBe(true);
|
||||||
|
expect(names.has("fn_task_show")).toBe(true);
|
||||||
|
expect(names.has("fn_task_search")).toBe(true);
|
||||||
|
expect(names.has("fn_task_create")).toBe(true);
|
||||||
|
expect(names.has("fn_task_assign")).toBe(true);
|
||||||
|
expect(names.has("fn_list_agents")).toBe(true);
|
||||||
|
expect(names.has("fn_web_fetch")).toBe(true);
|
||||||
|
expect(names.has("fn_trait_list")).toBe(true);
|
||||||
|
expect(names.has("fn_ask_question")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -109,6 +109,22 @@ vi.mock("@fusion/engine", () => ({
|
|||||||
createIdeationTools: vi.fn(() => []),
|
createIdeationTools: vi.fn(() => []),
|
||||||
createMemoryTools: vi.fn(() => []),
|
createMemoryTools: vi.fn(() => []),
|
||||||
createResearchTools: vi.fn(() => []),
|
createResearchTools: vi.fn(() => []),
|
||||||
|
/*
|
||||||
|
FNXC:ChatToolset 2026-07-26-12:00:
|
||||||
|
#2376 chat permission-parity imported task-lifecycle / identity / evaluation factories into chat.ts.
|
||||||
|
Keep this hardcoded @fusion/engine mock complete so check-mock-completeness stays green (Gate).
|
||||||
|
*/
|
||||||
|
createTaskArchiveTool: vi.fn(() => ({})),
|
||||||
|
createTaskUnarchiveTool: vi.fn(() => ({})),
|
||||||
|
createTaskDeleteTool: vi.fn(() => ({})),
|
||||||
|
createTaskRetryTool: vi.fn(() => ({})),
|
||||||
|
createTaskPauseTool: vi.fn(() => ({})),
|
||||||
|
createTaskUnpauseTool: vi.fn(() => ({})),
|
||||||
|
createTaskDuplicateTool: vi.fn(() => ({})),
|
||||||
|
createTaskMergeTool: vi.fn(() => ({})),
|
||||||
|
createTraitListTool: vi.fn(() => ({})),
|
||||||
|
createReadEvaluationsTool: vi.fn(() => ({})),
|
||||||
|
createUpdateIdentityTool: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("resolveFileReferences", () => {
|
describe("resolveFileReferences", () => {
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ import {
|
|||||||
resolveMcpServersForStore,
|
resolveMcpServersForStore,
|
||||||
resolveExecutorThinkingLevel,
|
resolveExecutorThinkingLevel,
|
||||||
wrapToolsWithActionGate,
|
wrapToolsWithActionGate,
|
||||||
|
createTaskArchiveTool,
|
||||||
|
createTaskUnarchiveTool,
|
||||||
|
createTaskDeleteTool,
|
||||||
|
createTaskRetryTool,
|
||||||
|
createTaskPauseTool,
|
||||||
|
createTaskUnpauseTool,
|
||||||
|
createTaskDuplicateTool,
|
||||||
|
createTaskMergeTool,
|
||||||
|
createTraitListTool,
|
||||||
|
createReadEvaluationsTool,
|
||||||
|
createUpdateIdentityTool,
|
||||||
} from "@fusion/engine";
|
} from "@fusion/engine";
|
||||||
import * as engineModule from "@fusion/engine";
|
import * as engineModule from "@fusion/engine";
|
||||||
|
|
||||||
@@ -562,7 +573,7 @@ function createTaskVerificationTools(taskStore: TaskStore, actionGateContext?: A
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
|
export async function createChatFusionToolset(options: ChatFusionToolsetOptions): Promise<ChatCustomTool[]> {
|
||||||
const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false } = options;
|
const { taskStore, agentStore, rootDir, agentId, missionMutationGated = false, actionGateContext } = options;
|
||||||
const tools: ChatCustomTool[] = [];
|
const tools: ChatCustomTool[] = [];
|
||||||
|
|
||||||
if (taskStore) {
|
if (taskStore) {
|
||||||
@@ -573,7 +584,35 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
|
|||||||
createTaskSearchTool(taskStore),
|
createTaskSearchTool(taskStore),
|
||||||
...createTaskVerificationTools(taskStore, options.actionGateContext),
|
...createTaskVerificationTools(taskStore, options.actionGateContext),
|
||||||
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
|
createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir }),
|
||||||
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Promotion is a mission mutation because it creates canonical roadmap work; dashboard chat exposes it only through the same permanent-agent action gate as all hierarchy writes. */
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatTaskMutationGate 2026-07-22-00:00:
|
||||||
|
Task-lifecycle mutations are only exposed when an enforceable action-gate context is
|
||||||
|
present for the bound agent. wrapToolsWithActionGate is a pass-through when the gate
|
||||||
|
context is absent or ephemeral (see pi.ts), so registering these tools without a gate
|
||||||
|
would leave archive/delete/retry/etc. callable with NO task_agent_mutation policy
|
||||||
|
enforcement. Withhold them from the surface instead of advertising unenforceable
|
||||||
|
mutations. fn_task_update, fn_task_add_dep, and fn_task_promote are intentionally NOT
|
||||||
|
bound in project-scoped chat: they target the factory's ambient task id, which project
|
||||||
|
chat does not have (binding "" makes them operate on no task). The executor/heartbeat
|
||||||
|
lanes bind those with a concrete current-task id.
|
||||||
|
*/
|
||||||
|
if (actionGateContext) {
|
||||||
|
tools.push(
|
||||||
|
createTaskArchiveTool(taskStore),
|
||||||
|
createTaskUnarchiveTool(taskStore),
|
||||||
|
createTaskDeleteTool(taskStore),
|
||||||
|
createTaskRetryTool(taskStore),
|
||||||
|
createTaskPauseTool(taskStore),
|
||||||
|
createTaskUnpauseTool(taskStore),
|
||||||
|
createTaskDuplicateTool(taskStore),
|
||||||
|
createTaskMergeTool(taskStore, ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push(
|
||||||
|
/* FNXC:ResearchMissionBridge 2026-07-18-12:00: Mission writes are exposed only through the same permanent-agent action gate as all hierarchy writes. */
|
||||||
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
|
...createMissionTools(taskStore).filter((tool) => missionMutationGated || CHAT_MISSION_READ_TOOL_NAMES.has(tool.name)),
|
||||||
/* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */
|
/* FNXC:Ideation 2026-07-30-15:30: Unbound or ephemeral chat exposes only positive ideation reads; mutations require the same durable gate context as Mission writes. */
|
||||||
...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)),
|
...createIdeationTools(taskStore).filter((tool) => missionMutationGated || CHAT_IDEATION_READ_TOOL_NAMES.has(tool.name)),
|
||||||
@@ -592,10 +631,21 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions)
|
|||||||
}
|
}
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
tools.push(createGetAgentConfigTool(agentStore, agentId));
|
tools.push(createGetAgentConfigTool(agentStore, agentId));
|
||||||
|
tools.push(createUpdateIdentityTool(agentStore, agentId));
|
||||||
|
/*
|
||||||
|
FNXC:ChatEvaluations 2026-07-22-00:00:
|
||||||
|
Chat has no ReflectionStore/AgentReflectionService, so pass undefined for the
|
||||||
|
reflection store (fn_read_evaluations degrades to ratings-only) and omit
|
||||||
|
fn_reflect_on_performance entirely — matching the heartbeat/executor guard that
|
||||||
|
only binds the reflect tool when a reflection service is available.
|
||||||
|
*/
|
||||||
|
tools.push(createReadEvaluationsTool(agentStore, undefined, agentId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tools.push(createWebFetchTool());
|
tools.push(createWebFetchTool());
|
||||||
|
tools.push(createTraitListTool());
|
||||||
|
tools.push(createAskQuestionTool());
|
||||||
return dedupeChatTools(tools);
|
return dedupeChatTools(tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,40 @@ describe("agent-action-gate", () => {
|
|||||||
expect(blockedDecision.disposition).toBe("block");
|
expect(blockedDecision.disposition).toBe("block");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:AgentGating 2026-07-26-12:00:
|
||||||
|
#2376 greptile P1: project chat has no ambient gate taskId. Approvals for fn_task_delete/archive must key off the invocation target so approving task A cannot authorize task B.
|
||||||
|
*/
|
||||||
|
it("scopes task_agent_mutation approval dedupe keys to the param-supplied target task", () => {
|
||||||
|
const deleteA = evaluateAgentActionGate({
|
||||||
|
agentId: "agent-chat",
|
||||||
|
toolName: "fn_task_delete",
|
||||||
|
args: { id: "FN-A" },
|
||||||
|
permissionPolicy: approvalPolicy,
|
||||||
|
});
|
||||||
|
const deleteB = evaluateAgentActionGate({
|
||||||
|
agentId: "agent-chat",
|
||||||
|
toolName: "fn_task_delete",
|
||||||
|
args: { id: "FN-B" },
|
||||||
|
permissionPolicy: approvalPolicy,
|
||||||
|
});
|
||||||
|
const mergeC = evaluateAgentActionGate({
|
||||||
|
agentId: "agent-chat",
|
||||||
|
toolName: "fn_task_merge",
|
||||||
|
args: { task_id: "FN-C" },
|
||||||
|
permissionPolicy: approvalPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteA.resourceType).toBe("task");
|
||||||
|
expect(deleteA.resourceId).toBe("FN-A");
|
||||||
|
expect(deleteB.resourceId).toBe("FN-B");
|
||||||
|
expect(mergeC.resourceId).toBe("FN-C");
|
||||||
|
expect(deleteA.approvalDedupeKey).not.toBe(deleteB.approvalDedupeKey);
|
||||||
|
expect(deleteA.approvalDedupeKey).toContain("FN-A");
|
||||||
|
expect(deleteB.approvalDedupeKey).toContain("FN-B");
|
||||||
|
expect(mergeC.approvalDedupeKey).toContain("FN-C");
|
||||||
|
});
|
||||||
|
|
||||||
// FN-7728: fn_task_bypass_review must classify as its own review_gate_bypass category,
|
// FN-7728: fn_task_bypass_review must classify as its own review_gate_bypass category,
|
||||||
// not task_agent_mutation, and must never fall through to the unrecognized-tool exempt fallback.
|
// not task_agent_mutation, and must never fall through to the unrecognized-tool exempt fallback.
|
||||||
it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation and exempt", () => {
|
it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation and exempt", () => {
|
||||||
|
|||||||
@@ -198,9 +198,20 @@ export function evaluateAgentActionGate(params: {
|
|||||||
operation = params.toolName;
|
operation = params.toolName;
|
||||||
resourceType = "file";
|
resourceType = "file";
|
||||||
} else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
|
} else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
|
||||||
|
/*
|
||||||
|
FNXC:AgentGating 2026-07-26-12:00:
|
||||||
|
PR #2376 review (greptile): chat sessions pass an empty ambient gateContext.taskId for project-scoped tools, so approval dedupe previously collapsed every fn_task_delete/archive/… call by the same agent into one empty-task key — approving task A let task B execute under that approval.
|
||||||
|
Prefer the invocation's target id (`id` or `task_id`) as resourceId (and as the effective taskId when ambient is empty) so each target gets its own approval identity.
|
||||||
|
*/
|
||||||
category = "task_agent_mutation";
|
category = "task_agent_mutation";
|
||||||
operation = params.toolName;
|
operation = params.toolName;
|
||||||
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
|
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
|
||||||
|
if (resourceType === "task") {
|
||||||
|
const fromId = typeof args.id === "string" ? args.id.trim() : "";
|
||||||
|
const fromTaskId = typeof args.task_id === "string" ? args.task_id.trim() : "";
|
||||||
|
const targetTaskId = fromId || fromTaskId || undefined;
|
||||||
|
if (targetTaskId) resourceId = targetTaskId;
|
||||||
|
}
|
||||||
} else if (COMMAND_EXECUTION_TOOLS.has(params.toolName)) {
|
} else if (COMMAND_EXECUTION_TOOLS.has(params.toolName)) {
|
||||||
category = "command_execution";
|
category = "command_execution";
|
||||||
operation = params.toolName;
|
operation = params.toolName;
|
||||||
@@ -283,9 +294,11 @@ export function evaluateAgentActionGate(params: {
|
|||||||
? "allow"
|
? "allow"
|
||||||
: exactDisposition ?? params.permissionPolicy.rules[category];
|
: exactDisposition ?? params.permissionPolicy.rules[category];
|
||||||
|
|
||||||
|
// Prefer ambient task scope; fall back to arg-derived resourceId so chat tools (empty ambient taskId) still isolate approvals per target.
|
||||||
|
const effectiveTaskId = params.taskId?.trim() || resourceId;
|
||||||
const dedupeKey = computeApprovalDedupeKey({
|
const dedupeKey = computeApprovalDedupeKey({
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
taskId: params.taskId,
|
taskId: effectiveTaskId,
|
||||||
toolName: params.toolName,
|
toolName: params.toolName,
|
||||||
category,
|
category,
|
||||||
resourceType,
|
resourceType,
|
||||||
|
|||||||
@@ -273,6 +273,66 @@ export const taskPromoteParams = Type.Object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const taskArchiveParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID to archive from any live column (e.g. FN-001)." }),
|
||||||
|
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references (child sourceParentTaskId) before archiving, so a task still referenced as a lineage parent can be archived." })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskDeleteParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID to delete (e.g. FN-001)" }),
|
||||||
|
allowResurrection: Type.Optional(Type.Boolean({ description: "When true, mark this tombstone as explicitly reusable for future recreation." })),
|
||||||
|
removeLineageReferences: Type.Optional(Type.Boolean({ description: "When true, clear incoming lineage-parent references before deleting." })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskUnarchiveParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID to unarchive (e.g. FN-001). Must be in 'archived' column." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskRetryParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID to retry (e.g. FN-001)." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskPauseParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskUnpauseParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Task ID (e.g. FN-001)" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskDuplicateParams = Type.Object({
|
||||||
|
id: Type.String({ description: "Source task ID to duplicate (e.g. FN-001)" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskMergeParams = Type.Object({
|
||||||
|
task_id: Type.String({ description: "The task ID to merge into the current task." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskAddDepParams = Type.Object({
|
||||||
|
task_id: Type.String({ description: "The ID of the task to depend on (e.g. \"KB-001\")" }),
|
||||||
|
confirm: Type.Optional(Type.Boolean({ description: "Set to true to confirm adding the dependency. Required because adding a dependency to an in-progress task will stop execution and discard current work." })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const STEP_STATUSES = ["pending", "in-progress", "done", "skipped"] as const;
|
||||||
|
|
||||||
|
export const taskUpdateParams = Type.Object({
|
||||||
|
step: Type.Optional(Type.Number({ description: "Step number (0-indexed; matches the `### Step N:` numbers in PROMPT.md — Step 0 is Preflight). Omit when updating only custom_fields/dependencies." })),
|
||||||
|
status: Type.Optional(Type.Union(
|
||||||
|
STEP_STATUSES.map((s) => Type.Literal(s)),
|
||||||
|
{ description: "New status: pending, in-progress, done, or skipped. Required when step is set." },
|
||||||
|
)),
|
||||||
|
dependencies: Type.Optional(Type.Array(Type.String(), {
|
||||||
|
description: "Optional task dependency array. Replaces existing dependencies. Pass ['FN-001', 'FN-002'] to set dependencies. Pass [] to clear all dependencies. Omit parameter to preserve existing dependencies.",
|
||||||
|
})),
|
||||||
|
custom_fields: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
||||||
|
description:
|
||||||
|
"Optional patch of workflow-defined custom field values, keyed by field id. " +
|
||||||
|
"Values are validated against the task's workflow field schema (type/enum membership); " +
|
||||||
|
"pass null for a field to clear it. Rejected writes return the offending field id and reason. " +
|
||||||
|
"Only fields declared by the task's workflow may be written.",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
export const workflowCreateParams = Type.Object({
|
export const workflowCreateParams = Type.Object({
|
||||||
name: Type.String({ description: "Workflow name (required, non-empty)." }),
|
name: Type.String({ description: "Workflow name (required, non-empty)." }),
|
||||||
description: Type.Optional(Type.String({ description: "Optional human-readable description." })),
|
description: Type.Optional(Type.String({ description: "Optional human-readable description." })),
|
||||||
@@ -3053,6 +3113,281 @@ export function createTaskPromoteTool(store: TaskStore, currentTaskId: string):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatTaskMutationTools 2026-07-26-12:00:
|
||||||
|
Chat permission-parity (#2376) adds these lifecycle tools so permanent-agent chat can archive/delete/retry/etc under the same task_agent_mutation gate as heartbeat/executor.
|
||||||
|
Keep catch blocks typed as unknown (no-explicit-any) and surface err.message via instanceof — the PR lint gate fails bare `any` here even though older factories still use the disable-comment pattern.
|
||||||
|
*/
|
||||||
|
function toolErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskArchiveTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_archive",
|
||||||
|
label: "Archive Task",
|
||||||
|
description:
|
||||||
|
"Archive a task from any live column (move to archived). " +
|
||||||
|
"Archived tasks are preserved for historical reference but moved out of the main board view. " +
|
||||||
|
"If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed.",
|
||||||
|
parameters: taskArchiveParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskArchiveParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.archiveTask(params.id, {
|
||||||
|
removeLineageReferences: params.removeLineageReferences === true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Archived ${task.id} → ${task.column}` }],
|
||||||
|
details: { taskId: task.id, column: task.column },
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to archive task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskUnarchiveTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_unarchive",
|
||||||
|
label: "Unarchive Task",
|
||||||
|
description:
|
||||||
|
"Unarchive an archived task (move from archived → its restore column). " +
|
||||||
|
"Restores to the pre-archive column when available, with active execution columns downgraded to todo.",
|
||||||
|
parameters: taskUnarchiveParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskUnarchiveParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.unarchiveTask(params.id);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Unarchived ${task.id} → ${task.column}` }],
|
||||||
|
details: { taskId: task.id, column: task.column },
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to unarchive task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskDeleteTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_delete",
|
||||||
|
label: "Delete Task",
|
||||||
|
description:
|
||||||
|
"Soft-delete a task from active Fusion board views. " +
|
||||||
|
"The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. " +
|
||||||
|
"If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.",
|
||||||
|
parameters: taskDeleteParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskDeleteParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.deleteTask(params.id, {
|
||||||
|
allowResurrection: params.allowResurrection === true,
|
||||||
|
removeLineageReferences: params.removeLineageReferences === true,
|
||||||
|
auditContext: { agentId: "chat", runId: `chat-delete-${params.id}-${Date.now()}`, taskId: params.id },
|
||||||
|
});
|
||||||
|
return { content: [{ type: "text" as const, text: `Deleted ${task.id}` }], details: { taskId: task.id } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to delete task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskRetryTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_retry",
|
||||||
|
label: "Retry Task",
|
||||||
|
description: "Retry a failed task. Clears failure state and re-queues the task for execution.",
|
||||||
|
parameters: taskRetryParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskRetryParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.getTask(params.id);
|
||||||
|
if (task.status !== "failed" && task.status !== "stuck-killed") {
|
||||||
|
return { content: [{ type: "text" as const, text: `Task ${params.id} is not in a retryable state (status: ${task.status || "none"})` }], details: { taskId: params.id, currentStatus: task.status }, isError: true };
|
||||||
|
}
|
||||||
|
await store.updateTask(params.id, { status: null, error: null });
|
||||||
|
await store.moveTask(params.id, "todo");
|
||||||
|
await store.logEntry(params.id, "Retry requested via chat tool", "Task reset to todo for retry");
|
||||||
|
return { content: [{ type: "text" as const, text: `Retried ${params.id} → todo` }], details: { taskId: params.id, newColumn: "todo" } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to retry task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskPauseTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_pause",
|
||||||
|
label: "Pause Task",
|
||||||
|
description: "Pause an active task so the executor will not pick it up on the next heartbeat.",
|
||||||
|
parameters: taskPauseParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskPauseParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.pauseTask(params.id, true);
|
||||||
|
return { content: [{ type: "text" as const, text: `Paused ${task.id}` }], details: { taskId: task.id, column: task.column } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to pause task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskUnpauseTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_unpause",
|
||||||
|
label: "Unpause Task",
|
||||||
|
description: "Resume a previously paused task so the executor can pick it up again.",
|
||||||
|
parameters: taskUnpauseParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskUnpauseParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.pauseTask(params.id, false);
|
||||||
|
return { content: [{ type: "text" as const, text: `Unpaused ${task.id}` }], details: { taskId: task.id, column: task.column } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to unpause task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskDuplicateTool(store: TaskStore): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_duplicate",
|
||||||
|
label: "Duplicate Task",
|
||||||
|
description: "Duplicate an existing task, preserving its description, workflow, and dependencies.",
|
||||||
|
parameters: taskDuplicateParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskDuplicateParams>) => {
|
||||||
|
try {
|
||||||
|
const task = await store.duplicateTask(params.id);
|
||||||
|
return { content: [{ type: "text" as const, text: `Duplicated to ${task.id}` }], details: { taskId: task.id } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to duplicate task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatTaskMutationTools 2026-07-26-12:00:
|
||||||
|
fn_task_merge targets params.task_id only; the ambient currentTaskId arg is retained for call-site parity with other task tools but is intentionally unused (project chat passes "").
|
||||||
|
Prefix with underscore so the lint gate does not fail on the unused parameter.
|
||||||
|
*/
|
||||||
|
export function createTaskMergeTool(store: TaskStore, _currentTaskId: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_merge",
|
||||||
|
label: "Merge Task",
|
||||||
|
description:
|
||||||
|
"Merge a task into the current/parent task. The target task is closed and its work is rolled into the parent.",
|
||||||
|
parameters: taskMergeParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskMergeParams>) => {
|
||||||
|
const targetId = params.task_id?.trim();
|
||||||
|
if (!targetId) return { content: [{ type: "text" as const, text: "ERROR: task_id is required." }], details: {}, isError: true };
|
||||||
|
try {
|
||||||
|
const result = await store.mergeTask(targetId);
|
||||||
|
const mergedInto = result?.task?.id ?? targetId;
|
||||||
|
return { content: [{ type: "text" as const, text: `Merged ${targetId} into ${mergedInto}` }], details: { targetId, mergedInto } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to merge task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskUpdateTool(store: TaskStore, taskId: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_update",
|
||||||
|
label: "Update Step / Custom Fields / Dependencies",
|
||||||
|
description:
|
||||||
|
"Update a task step status, dependencies, or workflow custom fields without leaving chat. " +
|
||||||
|
"Use step+status to report progress, dependencies to rewire blockers, or custom_fields to set workflow-defined fields.",
|
||||||
|
parameters: taskUpdateParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskUpdateParams>) => {
|
||||||
|
try {
|
||||||
|
if (params.custom_fields !== undefined) {
|
||||||
|
const res = await store.updateTaskCustomFields(taskId, params.custom_fields);
|
||||||
|
if (!res.ok) {
|
||||||
|
const r = res.rejection;
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `ERROR: custom field '${r.fieldId}' rejected (${r.code}): ${r.detail}` }],
|
||||||
|
details: { fieldId: r.fieldId, code: r.code, detail: r.detail },
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.dependencies !== undefined) {
|
||||||
|
if (params.dependencies.includes(taskId)) {
|
||||||
|
return { content: [{ type: "text" as const, text: "ERROR: self-dependency not allowed." }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
const invalidIds: string[] = [];
|
||||||
|
for (const depId of params.dependencies) {
|
||||||
|
try { await store.getTask(depId); } catch { invalidIds.push(depId); }
|
||||||
|
}
|
||||||
|
if (invalidIds.length) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Unknown dependency task(s): ${invalidIds.join(", ")}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
await store.updateTask(taskId, { dependencies: params.dependencies });
|
||||||
|
}
|
||||||
|
if (params.step !== undefined && params.status !== undefined) {
|
||||||
|
const task = await store.updateStep(taskId, params.step, params.status);
|
||||||
|
return { content: [{ type: "text" as const, text: `Updated ${taskId}: step ${params.step} → ${params.status}` }], details: { taskId: task.id, step: params.step, status: params.status } };
|
||||||
|
}
|
||||||
|
if (params.custom_fields !== undefined || params.dependencies !== undefined) {
|
||||||
|
return { content: [{ type: "text" as const, text: "Updated." }], details: {} };
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text" as const, text: "No-op: provide step+status, dependencies, or custom_fields." }], details: {} };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTaskAddDepTool(store: TaskStore, taskId: string): ToolDefinition {
|
||||||
|
return {
|
||||||
|
name: "fn_task_add_dep",
|
||||||
|
label: "Add Task Dependency",
|
||||||
|
description:
|
||||||
|
"Add a dependency to the current task. Adding a dependency to an in-progress task will stop execution " +
|
||||||
|
"and discard current work. Confirm is required for in-progress tasks.",
|
||||||
|
parameters: taskAddDepParams,
|
||||||
|
execute: async (_id: string, params: Static<typeof taskAddDepParams>) => {
|
||||||
|
try {
|
||||||
|
const depId = params.task_id?.trim();
|
||||||
|
if (!depId) return { content: [{ type: "text" as const, text: "ERROR: task_id is required." }], details: {}, isError: true };
|
||||||
|
const task = await store.getTask(taskId);
|
||||||
|
/*
|
||||||
|
FNXC:WorkflowResolvedColumns 2026-07-30-00:30:
|
||||||
|
The board's WIP lanes, not the literal. This guard is the only thing standing between an
|
||||||
|
operator and losing in-flight work: adding a dependency to a running task stops execution and
|
||||||
|
discards it, so the confirmation must fire on whatever lane that board calls "in progress".
|
||||||
|
Keyed on the literal it was silently skipped on every renamed board — the work is destroyed
|
||||||
|
with no prompt, which is the failure you cannot undo.
|
||||||
|
|
||||||
|
Same shape as the terminal-column resolution earlier in this file: seed the legacy id as the
|
||||||
|
floor, union the resolved trait columns, and degrade to the legacy id alone if the workflow
|
||||||
|
cannot be read.
|
||||||
|
*/
|
||||||
|
const wipColumns = new Set<string>(["in-progress"]);
|
||||||
|
try {
|
||||||
|
const wipIr = await fusionCore.resolveWorkflowIrForTask(store, taskId);
|
||||||
|
if (wipIr) for (const id of fusionCore.columnsWithFlag(wipIr, "countsTowardWip")) wipColumns.add(id);
|
||||||
|
} catch { /* degraded: legacy id only */ }
|
||||||
|
if (wipColumns.has(task.column) && params.confirm !== true) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: "WARNING: adding a dependency to an in-progress task will stop execution and discard current work. Pass confirm:true to proceed." }],
|
||||||
|
details: { requiresConfirm: true, taskId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (depId === taskId) return { content: [{ type: "text" as const, text: "ERROR: cannot add self-dependency." }], details: {}, isError: true };
|
||||||
|
await store.updateTask(taskId, { dependencies: [...(task.dependencies || []), depId] });
|
||||||
|
return { content: [{ type: "text" as const, text: `Added dependency ${depId} to ${taskId}` }], details: { taskId, dependency: depId } };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { content: [{ type: "text" as const, text: `ERROR: Failed to add dependency: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13).
|
* Shared write-time column-agent gate for the `fn_workflow_*` tools (R11/R13).
|
||||||
* Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so
|
* Runs the SAME `validateColumnAgentBindings` check the dashboard route runs, so
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
|||||||
"fn_task_archive",
|
"fn_task_archive",
|
||||||
"fn_task_unarchive",
|
"fn_task_unarchive",
|
||||||
"fn_task_delete",
|
"fn_task_delete",
|
||||||
|
// FNXC:AgentGating 2026-07-26-12:00: #2376 chat permission-parity exposes fn_task_merge; classify it so the action gate cannot fall through to exempt.
|
||||||
|
"fn_task_merge",
|
||||||
"fn_task_plan",
|
"fn_task_plan",
|
||||||
"fn_mission_create",
|
"fn_mission_create",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ export {
|
|||||||
createWorkflowDeleteTool,
|
createWorkflowDeleteTool,
|
||||||
createWorkflowSettingsTool,
|
createWorkflowSettingsTool,
|
||||||
createTraitListTool,
|
createTraitListTool,
|
||||||
|
createTaskArchiveTool,
|
||||||
|
createTaskUnarchiveTool,
|
||||||
|
createTaskDeleteTool,
|
||||||
|
createTaskRetryTool,
|
||||||
|
createTaskPauseTool,
|
||||||
|
createTaskUnpauseTool,
|
||||||
|
createTaskDuplicateTool,
|
||||||
|
createTaskMergeTool,
|
||||||
|
createTaskUpdateTool,
|
||||||
|
createTaskAddDepTool,
|
||||||
|
createReadEvaluationsTool,
|
||||||
|
createUpdateIdentityTool,
|
||||||
createWorkflowAuthoringTools,
|
createWorkflowAuthoringTools,
|
||||||
createAgentTask,
|
createAgentTask,
|
||||||
taskCreateParams,
|
taskCreateParams,
|
||||||
|
|||||||
Reference in New Issue
Block a user