feat: add fn_task_file_scope_add tool so agents can widen their File Scope

Agents that must edit files beyond a task's declared ## File Scope had no
way to keep the scope in sync, so those edits were stranded at merge (the
squash merge is scoped to ## File Scope, and cross-task overlap blocking +
the merge file-scope invariant both read it).

New executor tool fn_task_file_scope_add validates repo-relative
paths/globs with isValidFileScopeEntry, de-dupes against existing scope,
appends them to the ## File Scope section of PROMPT.md, and persists via
store.updateTask({ prompt }) (same validation + task.json/PROMPT.md sync as
fn_task_prompt_write). Registered in the main coding-agent tool list; the
base executor prompt now instructs the agent to call it when editing beyond
the declared scope. Merge-time peer-claim refusal is unchanged and remains
the cross-task backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 23:33:21 -07:00
parent 7f2e34f5b3
commit f10c39fa0b
4 changed files with 205 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Agents can now add files to a task's File Scope while working, so out-of-scope edits aren't stranded at merge.
category: feature
dev: New `fn_task_file_scope_add` executor tool (packages/engine/src/agent-tools.ts, wired in executor.ts) appends validated repo-relative paths/globs to the `## File Scope` section of PROMPT.md and persists via `store.updateTask({ prompt })` (same validation + task.json/PROMPT.md sync as `fn_task_prompt_write`). Entries are validated with `isValidFileScopeEntry` and de-duplicated; the base executor prompt now instructs the agent to call it when editing beyond the declared scope. Does not re-run the merge-time peer-claim refusal — the squash file-scope invariant remains the cross-task backstop.

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createTaskFileScopeAddTool } from "../agent-tools.js";
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
return createEngineCoreMock(() => importOriginal<typeof import("@fusion/core")>());
});
const TASK_ID = "FN-4242";
const PROMPT_WITH_SCOPE = `## Mission
Do the thing.
## File Scope
- \`packages/engine/src/existing.ts\`
## Steps
1. Go.
`;
function createMockStore(prompt: string) {
const getTask = vi.fn<TaskStore["getTask"]>().mockResolvedValue({ id: TASK_ID, prompt } as any);
const updateTask = vi.fn<TaskStore["updateTask"]>().mockResolvedValue(undefined as any);
const appendAgentLog = vi.fn<TaskStore["appendAgentLog"]>().mockResolvedValue(undefined);
const store = { getTask, updateTask, appendAgentLog } as unknown as TaskStore;
return { store, getTask, updateTask, appendAgentLog };
}
async function runTool(tool: { execute: (...args: any[]) => Promise<any> }, params: Record<string, unknown>) {
return tool.execute("call-1", params, undefined as any, undefined as any, undefined as any);
}
function getText(result: any): string {
const first = result?.content?.[0];
return first?.type === "text" ? first.text : "";
}
describe("fn_task_file_scope_add", () => {
beforeEach(() => vi.clearAllMocks());
it("appends new valid files to the ## File Scope section and persists via updateTask", async () => {
const { store, updateTask, appendAgentLog } = createMockStore(PROMPT_WITH_SCOPE);
const tool = createTaskFileScopeAddTool(store, TASK_ID);
const result = await runTool(tool, { files: ["packages/engine/src/foo.ts"], reason: "needed for the fix" });
expect(updateTask).toHaveBeenCalledTimes(1);
const [, updates] = updateTask.mock.calls[0];
const newPrompt = (updates as { prompt: string }).prompt;
// Both the original and the new entry are present, and the new one is inside File Scope.
expect(newPrompt).toContain("`packages/engine/src/existing.ts`");
expect(newPrompt).toContain("`packages/engine/src/foo.ts`");
// Appended into the File Scope section, not after ## Steps.
const scopeIdx = newPrompt.indexOf("## File Scope");
const stepsIdx = newPrompt.indexOf("## Steps");
expect(newPrompt.indexOf("`packages/engine/src/foo.ts`")).toBeGreaterThan(scopeIdx);
expect(newPrompt.indexOf("`packages/engine/src/foo.ts`")).toBeLessThan(stepsIdx);
expect(appendAgentLog).toHaveBeenCalledTimes(1);
expect(getText(result)).toMatch(/Added to File Scope/);
});
it("does not duplicate an entry already in scope and does not call updateTask when nothing new", async () => {
const { store, updateTask } = createMockStore(PROMPT_WITH_SCOPE);
const tool = createTaskFileScopeAddTool(store, TASK_ID);
const result = await runTool(tool, { files: ["packages/engine/src/existing.ts"] });
expect(updateTask).not.toHaveBeenCalled();
expect(getText(result)).toMatch(/Already present/);
});
it("rejects invalid entries (path traversal / leading slash) and reports them", async () => {
const { store, updateTask } = createMockStore(PROMPT_WITH_SCOPE);
const tool = createTaskFileScopeAddTool(store, TASK_ID);
const result = await runTool(tool, { files: ["../secrets.txt", "/etc/passwd"] });
expect(updateTask).not.toHaveBeenCalled();
expect(getText(result)).toMatch(/Rejected/);
});
it("adds valid files while rejecting invalid ones in the same call", async () => {
const { store, updateTask } = createMockStore(PROMPT_WITH_SCOPE);
const tool = createTaskFileScopeAddTool(store, TASK_ID);
const result = await runTool(tool, { files: ["packages/engine/src/foo.ts", "../bad"] });
expect(updateTask).toHaveBeenCalledTimes(1);
const text = getText(result);
expect(text).toMatch(/Added to File Scope: packages\/engine\/src\/foo\.ts/);
expect(text).toMatch(/Rejected.*\.\.\/bad/);
});
it("errors without mutating when PROMPT.md has no ## File Scope section", async () => {
const { store, updateTask } = createMockStore("## Mission\nDo it.\n\n## Steps\n1. Go.\n");
const tool = createTaskFileScopeAddTool(store, TASK_ID);
const result = await runTool(tool, { files: ["packages/engine/src/foo.ts"] });
expect(updateTask).not.toHaveBeenCalled();
expect(getText(result)).toMatch(/no "## File Scope" section/);
});
});

View File

@@ -99,6 +99,19 @@ export const taskPromptWriteParams = Type.Object({
content: Type.String({ description: "Complete replacement content for this task's PROMPT.md." }), content: Type.String({ description: "Complete replacement content for this task's PROMPT.md." }),
}); });
export const taskFileScopeAddParams = Type.Object({
files: Type.Array(
Type.String({
description:
"A repo-relative file path or glob to add to the task's File Scope, e.g. `packages/engine/src/foo.ts` or `packages/dashboard/app/**`. No leading slash, no `..`, no URLs or git refs.",
}),
{ minItems: 1, description: "One or more files/globs to add to this task's ## File Scope." },
),
reason: Type.Optional(
Type.String({ description: "Short reason these files must be edited (recorded in the agent log)." }),
),
});
export const chatTaskDocumentWriteParams = Type.Object({ export const chatTaskDocumentWriteParams = Type.Object({
task_id: Type.String({ description: "Task ID to write the document to (e.g. 'FN-001')." }), task_id: Type.String({ description: "Task ID to write the document to (e.g. 'FN-001')." }),
key: Type.String({ key: Type.String({
@@ -1364,6 +1377,79 @@ export function createTaskPromptWriteTool(store: TaskStore, taskId: string, runC
}; };
} }
/*
FNXC:FileScope 2026-07-08-22:40:
Requirement: when an executing agent must edit files beyond the task's declared `## File Scope`, it should extend the declared scope itself rather than silently editing out-of-scope (which strands those edits — the merger's squash is scoped to `## File Scope`, and cross-task overlap blocking + the merge file-scope invariant both read it). This tool appends validated entries to the `## File Scope` section of PROMPT.md and persists via `store.updateTask({ prompt })`, so the same validation (`validateFileScopeInPromptContent`) and task.json/PROMPT.md sync path as `fn_task_prompt_write` applies, and `parseFileScopeFromPrompt` picks the additions up immediately.
Entries are validated with `isValidFileScopeEntry` and de-duplicated against existing scope. Marker-free plain `- \`path\`` lines are used (not the merger's `scopeAutoWiden` HTML-comment marker) so these read as first-class declared scope. Caveat: unlike the merge-time auto-widen, this does NOT re-run the peer-claim refusal (files owned by another active task's scope) — the merge-time invariant remains the backstop for genuine cross-task conflicts.
*/
export function createTaskFileScopeAddTool(store: TaskStore, taskId: string, runContext?: RunMutationContext): ToolDefinition {
return {
name: "fn_task_file_scope_add",
label: "Add to File Scope",
description:
"Add one or more files/globs to this task's declared ## File Scope when you need to edit beyond the initial scope. " +
"Use this instead of silently editing out-of-scope files so your changes are not stranded at merge. " +
"Paths are repo-relative (no leading slash, no `..`).",
parameters: taskFileScopeAddParams,
execute: async (_id: string, params: Static<typeof taskFileScopeAddParams>) => {
const errorContent = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
try {
const requested = params.files.map((f) => f.trim()).filter((f) => f.length > 0);
const rejected = requested.filter((f) => !fusionCore.isValidFileScopeEntry(f));
const valid = requested.filter((f) => fusionCore.isValidFileScopeEntry(f));
const task = await store.getTask(taskId);
const prompt = task.prompt ?? "";
const headingMatch = prompt.match(/^##\s+File Scope\s*$/m);
if (!headingMatch) {
return errorContent(
`ERROR: ${taskId}'s PROMPT.md has no "## File Scope" section to extend. Use fn_task_prompt_write if the spec needs a scope section.`,
);
}
const sectionStart = headingMatch.index! + headingMatch[0].length;
const rest = prompt.slice(sectionStart);
const nextHeadingIdx = rest.search(/^##\s/m);
const sectionEnd = nextHeadingIdx === -1 ? prompt.length : sectionStart + nextHeadingIdx;
const section = prompt.slice(sectionStart, sectionEnd);
const existing = new Set((section.match(/`([^`]+)`/g) ?? []).map((t) => t.slice(1, -1)));
const alreadyPresent = valid.filter((f) => existing.has(f));
const toAdd = valid.filter((f) => !existing.has(f));
if (toAdd.length === 0) {
const parts = ["No files added to File Scope."];
if (alreadyPresent.length > 0) parts.push(`Already present: ${alreadyPresent.join(", ")}.`);
if (rejected.length > 0) parts.push(`Rejected (invalid path/glob): ${rejected.join(", ")}.`);
return errorContent(parts.join(" "));
}
const insertion = toAdd.map((f) => `- \`${f}\``).join("\n");
const sectionTrimmed = section.replace(/\s+$/, "");
const newSection = sectionTrimmed.length === 0 ? `\n\n${insertion}\n` : `${sectionTrimmed}\n${insertion}\n`;
const newPrompt = prompt.slice(0, sectionStart) + newSection + prompt.slice(sectionEnd);
await store.updateTask(taskId, { prompt: newPrompt }, runContext);
await store
.appendAgentLog(
taskId,
`Added to File Scope: ${toAdd.join(", ")}${params.reason ? ` — ${params.reason}` : ""}`,
"text",
)
.catch(() => {});
const parts = [`Added to File Scope: ${toAdd.join(", ")}.`];
if (alreadyPresent.length > 0) parts.push(`Already present: ${alreadyPresent.join(", ")}.`);
if (rejected.length > 0) parts.push(`Rejected (invalid path/glob): ${rejected.join(", ")}.`);
return { content: [{ type: "text" as const, text: parts.join(" ") }], details: { added: toAdd } };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
return errorContent(`ERROR: Failed to update File Scope for ${taskId}: ${err.message}`);
}
},
};
}
/** /**
* FNXC:ChatAgentTools 2026-06-18-06:51: * FNXC:ChatAgentTools 2026-06-18-06:51:
* Chat sessions do not have an ambient task, but users expect the same `fn_task_document_write` and `fn_task_document_read` names that task-bound lanes expose. * Chat sessions do not have an ambient task, but users expect the same `fn_task_document_write` and `fn_task_document_read` names that task-bound lanes expose.

View File

@@ -210,6 +210,7 @@ import {
createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool, createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool,
createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool, createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool,
createTaskPromptWriteTool as sharedCreateTaskPromptWriteTool, createTaskPromptWriteTool as sharedCreateTaskPromptWriteTool,
createTaskFileScopeAddTool as sharedCreateTaskFileScopeAddTool,
createTaskLogTool as sharedCreateTaskLogTool, createTaskLogTool as sharedCreateTaskLogTool,
createWorkflowListTool as sharedCreateWorkflowListTool, createWorkflowListTool as sharedCreateWorkflowListTool,
createWorkflowGetTool as sharedCreateWorkflowGetTool, createWorkflowGetTool as sharedCreateWorkflowGetTool,
@@ -1482,6 +1483,7 @@ Executors must not move the workflow of the task they are executing unless the u
- Read "Context to Read First" files before starting - Read "Context to Read First" files before starting
- Follow the "Do NOT" section strictly — these are hard constraints, not suggestions - Follow the "Do NOT" section strictly — these are hard constraints, not suggestions
- If tests, lint, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green - If tests, lint, build, or typecheck fail and the fix requires touching code outside the declared File Scope, fix those failures directly and keep the repo green
- When you must edit files beyond the declared File Scope to complete this task, call \`fn_task_file_scope_add\` to add them to the File Scope as you go — keep the declared scope in sync with what you actually change so your edits are not stranded by the scope-aware squash merge
- Use \`fn_task_create\` for genuinely separate follow-up work, not for mandatory fixes required to make this task land cleanly - Use \`fn_task_create\` for genuinely separate follow-up work, not for mandatory fixes required to make this task land cleanly
- Update documentation listed in "Must Update" and check "Check If Affected" - Update documentation listed in "Must Update" and check "Check If Affected"
- NEVER delete, remove, or gut modules, interfaces, settings, exports, or test files outside your File Scope - NEVER delete, remove, or gut modules, interfaces, settings, exports, or test files outside your File Scope
@@ -10308,6 +10310,8 @@ export class TaskExecutor {
this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv), this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv),
this.createTaskDocumentWriteTool(task.id), this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id), this.createTaskDocumentReadTool(task.id),
// FNXC:FileScope 2026-07-08-22:40: let the coding agent extend its own declared ## File Scope at runtime (fn_task_file_scope_add) so edits beyond the initial scope are not stranded by the scope-aware squash merge.
this.createTaskFileScopeAddTool(task.id),
// FNXC:ArtifactRegistry 2026-06-21-07:04: Artifact list/view are read-only discovery tools and must remain available even when the task has no assigned agent identity; only registration requires an authorId for persisted attribution and best-effort inbox notification. // FNXC:ArtifactRegistry 2026-06-21-07:04: Artifact list/view are read-only discovery tools and must remain available even when the task has no assigned agent identity; only registration requires an authorId for persisted attribution and best-effort inbox notification.
this.createArtifactListTool(), this.createArtifactListTool(),
this.createArtifactViewTool(), this.createArtifactViewTool(),
@@ -12314,6 +12318,10 @@ export class TaskExecutor {
return sharedCreateTaskPromptWriteTool(this.store, taskId, this.getRunContextFor(taskId)); return sharedCreateTaskPromptWriteTool(this.store, taskId, this.getRunContextFor(taskId));
} }
private createTaskFileScopeAddTool(taskId: string): ToolDefinition {
return sharedCreateTaskFileScopeAddTool(this.store, taskId, this.getRunContextFor(taskId));
}
private createArtifactRegisterTool(authorId: string): ToolDefinition { private createArtifactRegisterTool(authorId: string): ToolDefinition {
return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore); return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore);
} }