From 7a2137c250cb25c9a73d6f2920b56cf3312d932b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 28 Jun 2026 07:53:44 -0700 Subject: [PATCH] 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) --- .changeset/FN-7191-ask-question-no-gate.md | 7 +++ docs/agents.md | 2 +- .../src/__tests__/agent-action-gate.test.ts | 14 ++++++ .../__tests__/gating-classifications.test.ts | 6 +++ .../__tests__/permanent-agent-gating.test.ts | 50 +++++++++++++++++++ packages/engine/src/gating-classifications.ts | 10 ++++ 6 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .changeset/FN-7191-ask-question-no-gate.md diff --git a/.changeset/FN-7191-ask-question-no-gate.md b/.changeset/FN-7191-ask-question-no-gate.md new file mode 100644 index 0000000000..5a76e3b46b --- /dev/null +++ b/.changeset/FN-7191-ask-question-no-gate.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index c40ae0de58..5f14f8d207 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -164,7 +164,7 @@ Unknown/unclassified tool fallback: - 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. -- 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. - Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths. diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index a1a1669499..123e0c7c30 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -20,6 +20,7 @@ const FN_3548_COORDINATION_TOOLS = [ "fn_list_agents", "fn_agent_show", "fn_agent_org_chart", + "fn_ask_question", "fn_send_message", "fn_read_messages", "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_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_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_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); }); @@ -241,6 +243,18 @@ describe("agent-action-gate", () => { 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", () => { expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS])); }); diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index f759ca3e84..6bd91c5f4e 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -122,6 +122,7 @@ describe("gating-classifications parity", () => { "fn_artifact_list", "fn_artifact_register", "fn_artifact_view", + "fn_ask_question", "fn_goal_list", "fn_goal_show", "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", () => { for (const toolName of COORDINATION_EXEMPT_TOOLS) { const classification = classifyPermanentAgentToolCall(toolName); diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index c1ad8fff24..cea4c28794 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -1,9 +1,43 @@ +import type { AgentPermissionPolicy } from "@fusion/core"; import { describe, expect, it } from "vitest"; import { classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision, } 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 = [ ["fn_workflow_select", "task_agent_mutation"], ["fn_workflow_create", "task_agent_mutation"], @@ -30,6 +64,7 @@ const FN_3548_COORDINATION_TOOLS = [ "fn_list_agents", "fn_agent_show", "fn_agent_org_chart", + "fn_ask_question", "fn_send_message", "fn_read_messages", "fn_post_room_message", @@ -70,6 +105,7 @@ describe("permanent-agent-gating", () => { expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none"); 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_read_messages")).toEqual({ category: "none", recognized: true }); }); @@ -147,6 +183,20 @@ describe("permanent-agent-gating", () => { 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) => { expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true }); }); diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index b75192c21b..4d1604c490 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -154,6 +154,11 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_task_done", "fn_heartbeat_done", "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_read_messages", "fn_post_room_message", @@ -196,6 +201,11 @@ export const COORDINATION_EXEMPT_TOOLS = [ "fn_agent_show", "fn_agent_org_chart", "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_post_room_message", "fn_memory_append",