fix(agent-tools): hide fn_task_create under Deny and widen the dedupe window

Operator report: with project policy "Ephemeral agent follow-up tasks = Deny",
an executing agent filed ten follow-up tasks — five parallel fn_task_create
calls it reported as timed out, then five sequential retries.

Two defects:

1. Deny was advisory. fn_task_create was registered for every session and only
   refused inside execute(), so the model still saw the tool, planned around it,
   and retried it. The pi extension's isEphemeralCallerAgent also failed OPEN
   whenever the caller id did not resolve to an agent row — which is the normal
   shape of an ephemeral task-worker — so on that lane Deny was a no-op.

2. The deterministic content-fingerprint duplicate window was 60s, which only
   covered concurrent in-flight creates. A retry two minutes later saw nothing
   and filed a second task.

Fixes: isAgentTaskCreateToolAvailable() withholds the tool from ephemeral
sessions under Deny in both engine lanes (outer execution session, per-step
workflow session); isEphemeralCallerAgent fails closed on an unresolvable
caller id; the fingerprint window goes 60s -> 10m (clamp ceiling 5m -> 1h).
upon_validation keeps the tool, and permanent-agent and human/chat callers are
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 23:03:46 -07:00
parent 05b704dc60
commit 13a2b2a9da
8 changed files with 191 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Deny now hides fn_task_create from agents, and retried task creates no longer duplicate.
category: fix
dev: Adds `isAgentTaskCreateToolAvailable(settings, callerIsEphemeral)` in `@fusion/engine` agent-tools; the outer execution session (`executor.ts`) and per-step workflow sessions (`step-session-executor.ts`) omit `fn_task_create` from the tool list when the project policy resolves to `deny`. `isEphemeralCallerAgent` in the pi extension now fails closed: a caller id that is present but unresolvable counts as ephemeral, so the policy still applies. `upon_validation` keeps the tool (it proposes to the mailbox); permanent-agent and human/chat callers are unaffected. Separately, the deterministic content-fingerprint duplicate window in `duplicate-guard.ts` goes 60s -> 10m (clamp ceiling 5m -> 1h) so an agent that retries a create after a tool timeout links the existing task instead of filing a second one.

View File

@@ -729,18 +729,30 @@ FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00:
fn_task_create runs inside whatever agent loaded the pi extension. When the caller is an ephemeral/runtime task-worker (executor-FN-XXXX and friends), the project setting `ephemeralAgentsCanCreateTasks` decides whether it may open new tasks.
Human/dashboard/CLI callers have no `ctx.agentId`, so they are never gated here — the setting only constrains runtime-managed agents.
Resolution is fail-open on lookup errors: a missing/unresolvable caller is treated as non-ephemeral so a store hiccup never blocks legitimate task creation.
FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20:
Fail-open was too generous for the identity signal itself. A runtime task-worker session always
carries a caller id; only a human/dashboard/CLI caller has none. So an id that is PRESENT but does
not resolve to an agent row (deleted ephemeral row, cross-project store, transient read failure) is
a runtime caller with an unknown identity, and is now classified ephemeral so the project policy
still applies. An absent id keeps the old human pass-through, and a resolved permanent agent is
still never gated.
Incident: with the project policy on Deny, an executing agent still filed ten follow-up tasks —
an execute-time gate that answers "not ephemeral" whenever identity resolution comes up empty is
indistinguishable from no gate at all on exactly the sessions the setting exists to constrain.
*/
async function isEphemeralCallerAgent(cwd: string, callerAgentId: string | undefined): Promise<boolean> {
if (!callerAgentId) return false;
try {
const agentStore = await getAgentStore(cwd);
await agentStore.init();
const agent = await agentStore.resolveAgent(callerAgentId);
if (!agent) return false;
if (!agent) return true;
return isEphemeralAgent(agent);
} catch {
return false;
return true;
}
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Column, Task } from "../types.js";
import type { TaskStore } from "../store.js";
import { computeContentFingerprint } from "../duplicate-detection.js";
import {
__getDeterministicGuardMutexSize,
reconcileDeterministicDuplicate,
@@ -31,7 +32,8 @@ function makeStore(seed: Task[] = []): { tasks: Task[]; store: TaskStore } {
const tasks = [...seed];
const store = {
findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fp: string, options?: { windowMs?: number; includeArchived?: boolean }) => {
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000)));
// Mirrors clampWindowMs in duplicate-guard.ts (default 10m, ceiling 1h).
const windowMs = Math.max(1, Math.min(3_600_000, Math.trunc(options?.windowMs ?? 600_000)));
const cutoff = Date.now() - windowMs;
return tasks
.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fp)
@@ -211,6 +213,23 @@ describe("runDeterministicDuplicateGuard", () => {
result.releaseLock();
});
/*
FNXC:TaskCreationDeduplication 2026-07-26-06:45:
Regression for the ten-duplicate incident: an agent's parallel fn_task_create calls appeared to
time out, it retried them minutes later, and the retries fell outside the old 60s window. The
default window must cover a full timeout-and-retry cycle on every entry point — the guard's
pre-check AND the post-create reconciliation.
*/
it("catches a retry of the same content minutes after the original committed", async () => {
const originalTs = new Date(Date.now() - 150_000).toISOString();
const original = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: originalTs, updatedAt: originalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: computeContentFingerprint(INPUT)! } } });
const { store } = makeStore([original]);
const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" });
expect(result.action).toBe("duplicate");
expect(result.existing?.id).toBe("FN-1");
result.releaseLock();
});
it("returns null fingerprint for empty description", async () => {
const { store } = makeStore();
const result = await runDeterministicDuplicateGuard(store, { title: "x", description: "..." }, { lockScope: "p-1" });

View File

@@ -2,8 +2,24 @@ import type { Task } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeContentFingerprint } from "./duplicate-detection.js";
const DEFAULT_WINDOW_MS = 60_000;
const MAX_WINDOW_MS = 300_000;
/*
FNXC:TaskCreationDeduplication 2026-07-26-06:45:
The window must outlive one agent timeout-and-retry cycle, not one request.
Incident: an agent fired five parallel fn_task_create calls, reported them as timed out, and
retried them sequentially about two minutes later. The originals had committed, but the retries
landed outside the old 60s window, so the exact-content guard saw nothing and the board took ten
tasks instead of five. 60s only covered concurrent in-flight creates; a model that pauses to
explain itself and then retries always beat it.
Ten minutes is chosen to span a stalled tool call plus the model's retry turn. False positives stay
cheap and rare: this is an EXACT normalized title+description hash, and a legitimate repeat of
byte-identical content inside ten minutes is a double-submit, not distinct work. Near-duplicate
(paraphrase) matching is unaffected and keeps its own thresholds/windows. The clamp ceiling rises
with it so an explicit caller-supplied window is not silently cut back to five minutes.
*/
const DEFAULT_WINDOW_MS = 600_000;
const MAX_WINDOW_MS = 3_600_000;
export const deterministicGuardLocks = new Map<string, Promise<void>>();
// Test-only compatibility hook used by dashboard deterministic-dedup route tests.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { TaskStore } from "@fusion/core";
import { createTaskCreateTool, isAgentTaskCreateToolAvailable } from "../agent-tools.js";
/*
FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20:
Operator report: with project policy "Ephemeral agent follow-up tasks = Deny", an executing agent
still filed ten follow-up tasks (five parallel fn_task_create calls it reported as timed out, then
five sequential retries). Deny must be structural — the tool is not registered for an ephemeral
session at all — not merely an execute-time refusal the model can keep retrying.
These tests assert the invariant on every surface that can hand fn_task_create to an ephemeral
worker: the shared registration predicate, the execute-time gate inside the factory (defense in
depth), and the two engine lanes that register the tool (outer execution session, per-step session).
*/
function settings(policy?: "allow" | "upon_validation" | "deny", legacy?: boolean) {
return {
...(policy ? { ephemeralAgentTaskCreationPolicy: policy } : {}),
...(legacy === undefined ? {} : { ephemeralAgentsCanCreateTasks: legacy }),
} as Parameters<typeof isAgentTaskCreateToolAvailable>[0];
}
function readEngineSource(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(`../${relativePath}`, import.meta.url)), "utf8");
}
describe("isAgentTaskCreateToolAvailable", () => {
it("withholds the tool from an ephemeral caller when the policy denies creation", () => {
expect(isAgentTaskCreateToolAvailable(settings("deny"), true)).toBe(false);
});
it("honors the legacy boolean when no explicit policy is persisted", () => {
expect(isAgentTaskCreateToolAvailable(settings(undefined, false), true)).toBe(false);
expect(isAgentTaskCreateToolAvailable(settings(undefined, true), true)).toBe(true);
});
it("keeps the tool for allow and upon_validation (a proposal is a supported action)", () => {
expect(isAgentTaskCreateToolAvailable(settings("allow"), true)).toBe(true);
expect(isAgentTaskCreateToolAvailable(settings("upon_validation"), true)).toBe(true);
});
it("never gates a non-ephemeral caller, even under deny", () => {
expect(isAgentTaskCreateToolAvailable(settings("deny"), false)).toBe(true);
expect(isAgentTaskCreateToolAvailable(settings("deny"), undefined)).toBe(true);
});
it("defaults to available when settings are unreadable", () => {
expect(isAgentTaskCreateToolAvailable(undefined, true)).toBe(true);
expect(isAgentTaskCreateToolAvailable(settings(), true)).toBe(true);
});
});
describe("fn_task_create execute-time gate (defense in depth)", () => {
it("refuses an ephemeral caller under deny without touching the store", async () => {
let createCalls = 0;
const store = {
getSettings: async () => settings("deny"),
createTask: async () => { createCalls += 1; throw new Error("createTask must not run under deny"); },
} as unknown as TaskStore;
const tool = createTaskCreateTool(store, { sourceType: "api" }, { callerIsEphemeral: true });
const result = await (tool.execute as unknown as (
id: string,
params: unknown,
) => Promise<{ isError?: boolean; details?: unknown }>)(
"call-1",
{ description: "Follow-up work discovered mid-task" },
);
expect(result.isError).toBe(true);
expect((result.details as { rule?: string }).rule).toBe("ephemeral-agents-cannot-create-tasks");
expect(createCalls).toBe(0);
});
});
describe("engine lanes that register fn_task_create", () => {
it("guards the outer execution session registration with the availability predicate", () => {
const source = readEngineSource("executor.ts");
expect(source).toContain("isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral)");
});
it("guards the per-step workflow session registration with the availability predicate", () => {
const source = readEngineSource("step-session-executor.ts");
expect(source).toContain("isAgentTaskCreateToolAvailable(settings, this.options.callerIsEphemeral)");
});
});

View File

@@ -977,6 +977,30 @@ async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryCon
// ── Tool factory functions ────────────────────────────────────────────────
/**
* FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20:
* When the project policy is `deny`, an ephemeral/runtime task-worker must not merely
* be REFUSED at execute time — `fn_task_create` must not be registered for that session
* at all, so the model never sees the tool in its tool list.
*
* Incident: an executing agent under a `deny` project fired five parallel `fn_task_create`
* calls, reported them as timed out, retried them sequentially, and left ten tasks on a
* board whose operator had switched follow-up creation off. An execute-time-only refusal
* still invites the model to plan around the tool, burn turns retrying it, and — on any
* lane where `callerIsEphemeral` fails to reach the factory — create the tasks anyway.
* Suppressing registration makes the operator's Deny structural instead of advisory.
*
* `upon_validation` keeps the tool registered: that policy routes a proposal to the
* operator mailbox and is a supported agent action, not a prohibition.
*/
export function isAgentTaskCreateToolAvailable(
settings: Pick<Settings, "ephemeralAgentTaskCreationPolicy" | "ephemeralAgentsCanCreateTasks"> | undefined | null,
callerIsEphemeral: boolean | undefined,
): boolean {
if (!callerIsEphemeral) return true;
return fusionCore.resolveEphemeralTaskCreationPolicy(settings ?? {}) !== "deny";
}
type AgentTaskCreationOptions = {
rootDir?: string;
bypassDuplicateCheck?: boolean;

View File

@@ -256,6 +256,7 @@ import {
createArtifactRegisterTool as sharedCreateArtifactRegisterTool,
createArtifactViewTool as sharedCreateArtifactViewTool,
createTaskCreateTool as sharedCreateTaskCreateTool,
isAgentTaskCreateToolAvailable,
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool,
createTaskPromptWriteTool as sharedCreateTaskPromptWriteTool,
@@ -12642,11 +12643,19 @@ export class TaskExecutor {
};
await runPendingTaskVerification();
/*
FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20:
A `deny` project policy removes fn_task_create from the session's tool list instead of
registering a tool that only refuses at execute time; see isAgentTaskCreateToolAvailable.
*/
const executionCallerIsEphemeral = !identityAgent || isEphemeralAgent(identityAgent);
const customTools = [
this.createTaskUpdateTool(task.id, codeReviewVerdicts, sessionRef, stuckDetector),
this.createTaskLogTool(task.id),
this.createTaskLogsReadTool(task.id),
this.createTaskCreateTool(!identityAgent || isEphemeralAgent(identityAgent), task.id, identityAgent?.id),
...(isAgentTaskCreateToolAvailable(settings, executionCallerIsEphemeral)
? [this.createTaskCreateTool(executionCallerIsEphemeral, task.id, identityAgent?.id)]
: []),
this.createTaskAddDepTool(task.id),
this.createTaskDoneTool(task.id, worktreePath, detail.prompt ?? "", codeReviewVerdicts, () => { taskDone = true; }, audit),
createRunVerificationTool({

View File

@@ -51,6 +51,7 @@ import {
createReadMessagesTool,
createSendMessageTool,
createTaskCreateTool,
isAgentTaskCreateToolAvailable,
createTaskDocumentReadTool,
createTaskDocumentWriteTool,
createTaskLogTool,
@@ -1329,7 +1330,13 @@ export class StepSessionExecutor {
createTaskLogsReadTool(this.options.store, taskDetail.id),
]
: [];
const taskCreateTool = this.options.store
/*
FNXC:EphemeralAgentTaskCreation 2026-07-26-06:20:
Per-step workflow sessions honor the same registration-time Deny as the outer
execution session: an ephemeral step worker under `deny` is never handed
fn_task_create, rather than being handed a tool that only refuses on call.
*/
const taskCreateTool = this.options.store && isAgentTaskCreateToolAvailable(settings, this.options.callerIsEphemeral)
? [createTaskCreateTool(this.options.store, undefined, { rootDir: this.options.rootDir, callerIsEphemeral: this.options.callerIsEphemeral, sourceTaskId: this.options.sourceTaskId ?? taskDetail.id, sourceAgentId: this.options.sourceAgentId ?? taskDetail.assignedAgentId, messageStore: this.options.messageStore })]
: [];