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).
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -154,6 +154,11 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = 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",
|
||||
|
||||
Reference in New Issue
Block a user