From d67996fd25178fdb3be45a69624b6fcb59f59fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 19:00:42 -0400 Subject: [PATCH] Q116: a task without file access cannot write files, and its prompt says to use documents Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-deliverable-tools.ts | 4 ++++ src/main/services/agent-tools.ts | 2 +- src/main/services/task-prompts.ts | 13 ++++++++++--- tests/unit/agent-instructions.test.ts | 3 +++ tests/unit/execution-modes.test.ts | 11 +++++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/main/services/agent-deliverable-tools.ts b/src/main/services/agent-deliverable-tools.ts index 47a766a9..c4f5e4f3 100644 --- a/src/main/services/agent-deliverable-tools.ts +++ b/src/main/services/agent-deliverable-tools.ts @@ -14,6 +14,10 @@ const REVIEW_EXCERPT_CHARS = 4000; export function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables."); + // Writing a file is writing the workspace: only a task the operator approved for file access may, as with run_command. + if (!ctx.workspace.writeAccess) { + throw new Error("This task has read-only workspace access, so it cannot write files. Put the output in a document (create_document), or ask for a task with requires_filesystem, which the operator approves."); + } const path = requireString(args.path, "path"); const content = requireString(args.content, "content"); if (reviewsDeliverables(deps.db, ctx.orgId)) { diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index abfd8b35..0bb124eb 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -112,7 +112,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [ }, { name: "write_deliverable", - description: "Write a deliverable file into the company workspace folder (relative path). Use this to produce real output.", + description: "Write a deliverable file into the company workspace folder (relative path). Only tasks with filesystem access may write files; otherwise use create_document.", parameters: { type: "object", additionalProperties: false, diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 2112369b..eef54414 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -267,7 +267,14 @@ export function buildTaskPrompt(input: { ); return lines.join("\n"); } - if (input.task.requires_filesystem === 1 && input.workspace) { + // Only a task with approved file access can write files; every other task delivers through documents. + const writesFiles = input.task.requires_filesystem === 1 && input.workspace !== null; + const planStep = writesFiles + ? "write the initial operating plan into the workspace (write_deliverable). Then use send_message with /handoff @Name and attachment_file_ids to pass the operating plan to the relevant report." + : "write the initial operating plan as a document (create_document). Then point each relevant report to its doc_id in your message."; + const outputStep = writesFiles ? "write real output with write_deliverable" : "write real output as documents (create_document)"; + const handoffStep = writesFiles ? " Hand files over with /handoff and attachment_file_ids." : " Point teammates to documents by doc_id."; + if (writesFiles) { lines.push( "", "This task carries filesystem access: read the existing code with list_files and read_file before changing anything, write changes with write_deliverable, and prove they work with run_command (install, build, test). Real output is code that builds and tests that pass — a document describing the work does not close an engineering task.", @@ -296,14 +303,14 @@ export function buildTaskPrompt(input: { : `Tell your manager the plan in a direct message. Then ${park}`; lines.push( "", - `Your team is currently empty. First ${firstHiresFor(input.agent.role_level)}, create and assign their first tasks (create_task), and write the initial operating plan into the workspace (write_deliverable). Then use send_message with /handoff @Name and attachment_file_ids to pass the operating plan to the relevant report. Brief EACH report in a direct message (send_message with to="Their Name") about the work you assigned them — note the thread_id each returns so you can follow up in the same conversation. ${announce}`, + `Your team is currently empty. First ${firstHiresFor(input.agent.role_level)}, create and assign their first tasks (create_task), and ${planStep} Brief EACH report in a direct message (send_message with to="Their Name") about the work you assigned them — note the thread_id each returns so you can follow up in the same conversation. ${announce}`, ); } else { const manager = input.managerName ?? "your manager"; const managerReference = input.managerName ? `${manager} (${agentMention(input.managerName)})` : manager; lines.push( "", - `First, read_messages to check if anyone has said something you should respond to — reply to them like a colleague, not a report. Then do the work: write real output with write_deliverable, raise decisions and blockers with ${managerReference} in a direct message (send_message with to=), and keep team-wide topics in channels. Hand files over with /handoff and attachment_file_ids. Keep conversation going — ask questions, share your thinking, push back if something seems wrong. Before you finish: if the work is genuinely done, call update_task_status(task_id="${input.task.id}", status="done", evidence="what you produced and where"); if it needs another session, move it to "in_progress" and say in the thread what remains — an open task will be run again.`, + `First, read_messages to check if anyone has said something you should respond to — reply to them like a colleague, not a report. Then do the work: ${outputStep}, raise decisions and blockers with ${managerReference} in a direct message (send_message with to=), and keep team-wide topics in channels.${handoffStep} Keep conversation going — ask questions, share your thinking, push back if something seems wrong. Before you finish: if the work is genuinely done, call update_task_status(task_id="${input.task.id}", status="done", evidence="what you produced and where"); if it needs another session, move it to "in_progress" and say in the thread what remains — an open task will be run again.`, ); } lines.push("", RECORD_LEARNINGS_INSTRUCTION); diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 6ac1ed3d..92962318 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -226,6 +226,9 @@ describe("agent instruction wiring", () => { expect(capturedPrompt).toContain("Tell your manager the plan in a direct message."); expect(capturedPrompt).not.toContain("#general"); expect(capturedPrompt).not.toContain("Then do the work: write real output"); + // A task without file access plans in a document; it is never told to write files. + expect(capturedPrompt).toContain("write the initial operating plan as a document (create_document)"); + expect(capturedPrompt).not.toContain("(write_deliverable)"); connection.close(); }); }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index c1e98e0c..c8d1ed1f 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -191,6 +191,17 @@ describe("execution modes", () => { expect(db.prepare("select state from approvals where id = ?").get(review)).toEqual({ state: "pending" }); }); + test("a task without file access cannot write files, directly or by holding them for review", () => { + for (const mode of ["ai_ceo", "ai_cofounder"] as const) { + const h = harness(mode); + const readOnly = { ...h.ctx, workspace: { ...h.ctx.workspace!, writeAccess: false } }; + expect(dispatchAgentTool(h.deps, readOnly, call("write_deliverable", { path: "notes.md", content: "x" }))) + .toBe("ERROR: This task has read-only workspace access, so it cannot write files. Put the output in a document (create_document), or ask for a task with requires_filesystem, which the operator approves."); + expect(existsSync(join(h.ctx.workspace!.localPath, "notes.md"))).toBe(false); + expect(h.connection.db.prepare("select count(*) as n from held_deliverables").get()).toEqual({ n: 0 }); + } + }); + test("AI CEO mode ships deliverables without review", () => { const h = harness("ai_ceo"); const wrote = dispatchAgentTool(h.deps, h.ctx, call("write_deliverable", { path: "plan/business-plan.md", content: "# Plan" }));