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:
7
.changeset/agent-file-scope-add-tool.md
Normal file
7
.changeset/agent-file-scope-add-tool.md
Normal 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.
|
||||
104
packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts
Normal file
104
packages/engine/src/__tests__/agent-tools-file-scope-add.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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<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:
|
||||
* 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user