From f10c39fa0bfe344fc8f2b1fc03759649e01ea7ab Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 23:33:21 -0700 Subject: [PATCH] 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) --- .changeset/agent-file-scope-add-tool.md | 7 ++ .../agent-tools-file-scope-add.test.ts | 104 ++++++++++++++++++ packages/engine/src/agent-tools.ts | 86 +++++++++++++++ packages/engine/src/executor.ts | 8 ++ 4 files changed, 205 insertions(+) create mode 100644 .changeset/agent-file-scope-add-tool.md create mode 100644 packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts diff --git a/.changeset/agent-file-scope-add-tool.md b/.changeset/agent-file-scope-add-tool.md new file mode 100644 index 0000000000..665dec1f40 --- /dev/null +++ b/.changeset/agent-file-scope-add-tool.md @@ -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. diff --git a/packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts b/packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts new file mode 100644 index 0000000000..fa92a7ff3f --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts @@ -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()); +}); + +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().mockResolvedValue({ id: TASK_ID, prompt } as any); + const updateTask = vi.fn().mockResolvedValue(undefined as any); + const appendAgentLog = vi.fn().mockResolvedValue(undefined); + const store = { getTask, updateTask, appendAgentLog } as unknown as TaskStore; + return { store, getTask, updateTask, appendAgentLog }; +} + +async function runTool(tool: { execute: (...args: any[]) => Promise }, params: Record) { + 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/); + }); +}); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 4c58a7332c..3798177b0f 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -99,6 +99,19 @@ export const taskPromptWriteParams = Type.Object({ 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({ task_id: Type.String({ description: "Task ID to write the document to (e.g. 'FN-001')." }), 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) => { + 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: * 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. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 30a8a284d4..167bfcdd8f 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -210,6 +210,7 @@ import { createTaskDocumentReadTool as sharedCreateTaskDocumentReadTool, createTaskDocumentWriteTool as sharedCreateTaskDocumentWriteTool, createTaskPromptWriteTool as sharedCreateTaskPromptWriteTool, + createTaskFileScopeAddTool as sharedCreateTaskFileScopeAddTool, createTaskLogTool as sharedCreateTaskLogTool, createWorkflowListTool as sharedCreateWorkflowListTool, 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 - 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 +- 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 - 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 @@ -10308,6 +10310,8 @@ export class TaskExecutor { this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv), this.createTaskDocumentWriteTool(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. this.createArtifactListTool(), this.createArtifactViewTool(), @@ -12314,6 +12318,10 @@ export class TaskExecutor { 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 { return sharedCreateArtifactRegisterTool(this.store, authorId, this.options.messageStore); }