FN-7191: allow direct agent questions
Permanent agents can ask structured user questions without approval gating. - Classify fn_ask_question as a read-only coordination tool for permanent-agent decisions. - Exempt fn_ask_question from the action gate under locked-down policies. - Document the user-question exemption and add release notes plus gate coverage. Files changed: .changeset/FN-7191-ask-question-no-gate.md | 7 +++ docs/agents.md | 2 +- .../engine/src/__tests__/agent-action-gate.test.ts | 14 ++++++ .../src/__tests__/gating-classifications.test.ts | 6 +++ .../src/__tests__/permanent-agent-gating.test.ts | 50 ++++++++++++++++++++++ packages/engine/src/gating-classifications.ts | 10 +++++ 6 files changed, 88 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7191 Fusion-Task-Lineage: 349084a8-9098-49f7-ab82-c5a775c0a24c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7191-ask-question-no-gate.md
Normal file
7
.changeset/FN-7191-ask-question-no-gate.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Permanent agents can ask the user a question directly without an approval gate.
|
||||||
|
category: fix
|
||||||
|
dev: Classify fn_ask_question in COORDINATION_EXEMPT_TOOLS and READONLY_FN_TOOLS (gating-classifications.ts) so both the permanent-agent gate and action gate auto-allow it, mirroring fn_send_message.
|
||||||
@@ -164,7 +164,7 @@ Unknown/unclassified tool fallback:
|
|||||||
|
|
||||||
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
|
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
|
||||||
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
- Category `none` only yields `allow` when the tool is positively recognized as read-only.
|
||||||
- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, evaluations, identity reflection, memory bookkeeping, and read-only discovery) are exempt by design and always allowed so permanent-agent heartbeats can complete. `fn_task_create` is governed as `task_agent_mutation` in both action-gate and permanent-agent evaluation because it creates task rows; delegation/import tools remain governed in action-gate evaluation while the permanent-agent classifier still treats them as positively recognized `none` coordination primitives. Task field/status mutation via `fn_task_update` is also governed as `task_agent_mutation`.
|
- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, structured user questions via `fn_ask_question`, evaluations, identity reflection, memory bookkeeping, and read-only discovery) are exempt by design and always allowed so permanent-agent heartbeats can complete. `fn_task_create` is governed as `task_agent_mutation` in both action-gate and permanent-agent evaluation because it creates task rows; delegation/import tools remain governed in action-gate evaluation while the permanent-agent classifier still treats them as positively recognized `none` coordination primitives. Task field/status mutation via `fn_task_update` is also governed as `task_agent_mutation`.
|
||||||
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
|
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
|
||||||
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
|
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const FN_3548_COORDINATION_TOOLS = [
|
|||||||
"fn_list_agents",
|
"fn_list_agents",
|
||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
|
"fn_ask_question",
|
||||||
"fn_send_message",
|
"fn_send_message",
|
||||||
"fn_read_messages",
|
"fn_read_messages",
|
||||||
"fn_memory_search",
|
"fn_memory_search",
|
||||||
@@ -119,6 +120,7 @@ describe("agent-action-gate", () => {
|
|||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_ask_question", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
});
|
});
|
||||||
@@ -241,6 +243,18 @@ describe("agent-action-gate", () => {
|
|||||||
expect(decision.category).toBe("exempt");
|
expect(decision.category).toBe("exempt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows fn_ask_question as an action-gate coordination exemption under locked-down policy", () => {
|
||||||
|
expect(evaluateAgentActionGate({
|
||||||
|
agentId: "a1",
|
||||||
|
toolName: "fn_ask_question",
|
||||||
|
args: {},
|
||||||
|
permissionPolicy: lockedDownPolicy,
|
||||||
|
})).toMatchObject({
|
||||||
|
category: "exempt",
|
||||||
|
disposition: "allow",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps FN-3548 coordination tool list in exempt registry", () => {
|
it("keeps FN-3548 coordination tool list in exempt registry", () => {
|
||||||
expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS]));
|
expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ describe("gating-classifications parity", () => {
|
|||||||
"fn_artifact_list",
|
"fn_artifact_list",
|
||||||
"fn_artifact_register",
|
"fn_artifact_register",
|
||||||
"fn_artifact_view",
|
"fn_artifact_view",
|
||||||
|
"fn_ask_question",
|
||||||
"fn_goal_list",
|
"fn_goal_list",
|
||||||
"fn_goal_show",
|
"fn_goal_show",
|
||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
@@ -151,6 +152,11 @@ describe("gating-classifications parity", () => {
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies fn_ask_question in both gate source sets", () => {
|
||||||
|
expect(READONLY_FN_TOOLS.has("fn_ask_question")).toBe(true);
|
||||||
|
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_ask_question")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("ensures coordination exempt tools are recognized and allowed in permanent gating", () => {
|
it("ensures coordination exempt tools are recognized and allowed in permanent gating", () => {
|
||||||
for (const toolName of COORDINATION_EXEMPT_TOOLS) {
|
for (const toolName of COORDINATION_EXEMPT_TOOLS) {
|
||||||
const classification = classifyPermanentAgentToolCall(toolName);
|
const classification = classifyPermanentAgentToolCall(toolName);
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
|
import type { AgentPermissionPolicy } from "@fusion/core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
classifyPermanentAgentToolCall,
|
classifyPermanentAgentToolCall,
|
||||||
resolvePermanentAgentToolDecision,
|
resolvePermanentAgentToolDecision,
|
||||||
} from "../permanent-agent-gating.js";
|
} from "../permanent-agent-gating.js";
|
||||||
|
|
||||||
|
const unrestrictedPolicy: AgentPermissionPolicy = {
|
||||||
|
presetId: "unrestricted",
|
||||||
|
rules: {
|
||||||
|
git_write: "allow",
|
||||||
|
file_write_delete: "allow",
|
||||||
|
command_execution: "allow",
|
||||||
|
network_api: "allow",
|
||||||
|
task_agent_mutation: "allow",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalRequiredPolicy: AgentPermissionPolicy = {
|
||||||
|
presetId: "approval-required",
|
||||||
|
rules: {
|
||||||
|
git_write: "require-approval",
|
||||||
|
file_write_delete: "require-approval",
|
||||||
|
command_execution: "require-approval",
|
||||||
|
network_api: "require-approval",
|
||||||
|
task_agent_mutation: "require-approval",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockedPolicy: AgentPermissionPolicy = {
|
||||||
|
presetId: "locked-down",
|
||||||
|
rules: {
|
||||||
|
git_write: "block",
|
||||||
|
file_write_delete: "block",
|
||||||
|
command_execution: "block",
|
||||||
|
network_api: "block",
|
||||||
|
task_agent_mutation: "block",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const FN_7111_GOVERNED_TOOLS = [
|
const FN_7111_GOVERNED_TOOLS = [
|
||||||
["fn_workflow_select", "task_agent_mutation"],
|
["fn_workflow_select", "task_agent_mutation"],
|
||||||
["fn_workflow_create", "task_agent_mutation"],
|
["fn_workflow_create", "task_agent_mutation"],
|
||||||
@@ -30,6 +64,7 @@ const FN_3548_COORDINATION_TOOLS = [
|
|||||||
"fn_list_agents",
|
"fn_list_agents",
|
||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
|
"fn_ask_question",
|
||||||
"fn_send_message",
|
"fn_send_message",
|
||||||
"fn_read_messages",
|
"fn_read_messages",
|
||||||
"fn_post_room_message",
|
"fn_post_room_message",
|
||||||
@@ -70,6 +105,7 @@ describe("permanent-agent-gating", () => {
|
|||||||
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
|
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });
|
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });
|
||||||
|
expect(classifyPermanentAgentToolCall("fn_ask_question")).toEqual({ category: "none", recognized: true });
|
||||||
expect(classifyPermanentAgentToolCall("fn_send_message")).toEqual({ category: "none", recognized: true });
|
expect(classifyPermanentAgentToolCall("fn_send_message")).toEqual({ category: "none", recognized: true });
|
||||||
expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true });
|
expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true });
|
||||||
});
|
});
|
||||||
@@ -147,6 +183,20 @@ describe("permanent-agent-gating", () => {
|
|||||||
expect(decision.disposition).toBe("allow");
|
expect(decision.disposition).toBe("allow");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([unrestrictedPolicy, approvalRequiredPolicy, blockedPolicy])(
|
||||||
|
"allows fn_ask_question under %s permanent-agent policy",
|
||||||
|
(permissionPolicy) => {
|
||||||
|
expect(resolvePermanentAgentToolDecision({
|
||||||
|
toolName: "fn_ask_question",
|
||||||
|
gating: { permissionPolicy },
|
||||||
|
})).toMatchObject({
|
||||||
|
category: "none",
|
||||||
|
disposition: "allow",
|
||||||
|
recognized: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(FN_3548_COORDINATION_TOOLS)("classifies FN-3548 coordination tool %s as recognized none", (toolName) => {
|
it.each(FN_3548_COORDINATION_TOOLS)("classifies FN-3548 coordination tool %s as recognized none", (toolName) => {
|
||||||
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
|
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
|
|||||||
"fn_task_done",
|
"fn_task_done",
|
||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
"fn_memory_append",
|
"fn_memory_append",
|
||||||
|
/**
|
||||||
|
* FNXC:ToolGovernance 2026-06-28-00:00:
|
||||||
|
* FN-7191 requires permanent agents to call fn_ask_question directly without an approval gate. The tool only posts a structured question to the user's inbox, so it has the same trust level as fn_send_message and must be positively recognized in both gate paths.
|
||||||
|
*/
|
||||||
|
"fn_ask_question",
|
||||||
"fn_send_message",
|
"fn_send_message",
|
||||||
"fn_read_messages",
|
"fn_read_messages",
|
||||||
"fn_post_room_message",
|
"fn_post_room_message",
|
||||||
@@ -196,6 +201,11 @@ export const COORDINATION_EXEMPT_TOOLS = [
|
|||||||
"fn_agent_show",
|
"fn_agent_show",
|
||||||
"fn_agent_org_chart",
|
"fn_agent_org_chart",
|
||||||
"fn_workflow_list",
|
"fn_workflow_list",
|
||||||
|
/**
|
||||||
|
* FNXC:ToolGovernance 2026-06-28-00:00:
|
||||||
|
* FN-7191 requires fn_ask_question to bypass permanent-agent approval gates like other user-messaging coordination tools; membership here makes the action gate classify it as exempt/allow even under locked-down policies.
|
||||||
|
*/
|
||||||
|
"fn_ask_question",
|
||||||
"fn_send_message",
|
"fn_send_message",
|
||||||
"fn_post_room_message",
|
"fn_post_room_message",
|
||||||
"fn_memory_append",
|
"fn_memory_append",
|
||||||
|
|||||||
Reference in New Issue
Block a user