From 291816aa060386bf79aac7d393b9076934e84acf 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, 9 Jul 2026 19:23:28 -0400 Subject: [PATCH] Give agents hands: workspace read/list/exec tools so engineers build instead of describing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every "engineering" deliverable the company ever produced was a markdown skeleton, because documentation was the ceiling of the toolset: write_deliverable and create_document were the only artifact tools. An engineer could not see the files that exist, read a line of code, or run a single command — and create_task hardcoded requires_filesystem to false, so the entire approval-gated read_write mount plumbing was dead code. New workspace capability (agent-workspace-tools.ts): - list_files / read_file: explore the bound workspace, traversal-guarded by the same resolveWorkspacePath used for writes, size- and count-capped. - run_command: real shell execution with the workspace as cwd, 120s timeout, 16k output cap. Gated on the task's approved filesystem grant — the same requires_filesystem flag that flips the mount to read_write and routes the task through operator approval (auto-approved only in AI-CEO mode, which is that mode's explicit contract). - create_task now takes requires_filesystem, so the CEO and officers can delegate engineering work that is allowed to change code and run tests. - Prompts brief every agent on the tools, and filesystem tasks state the bar directly: real output is code that builds and tests that pass — a document describing the work does not close an engineering task. Tests: tool registry, dependency-folder-skipping listing, traversal refusal, the run_command access gate plus exit-code reporting, and the create_task approval path for filesystem work. --- src/main/services/agent-run-loop.ts | 5 +- src/main/services/agent-tools.ts | 9 +- src/main/services/agent-workspace-tools.ts | 150 ++++++++++++++++++++ src/main/services/task-execution-service.ts | 2 +- src/main/services/task-prompts.ts | 8 +- tests/unit/agent-run-loop.test.ts | 4 +- tests/unit/agent-workspace-tools.test.ts | 113 +++++++++++++++ tests/unit/execution-modes.test.ts | 2 +- 8 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 src/main/services/agent-workspace-tools.ts create mode 100644 tests/unit/agent-workspace-tools.test.ts diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index 9de2c558..035fb32c 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -1,5 +1,6 @@ import type { ProviderService } from "./provider-service"; import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools"; +import { executeWorkspaceTool, isWorkspaceTool, WORKSPACE_TOOLS } from "./agent-workspace-tools"; import { canonicalMentionHandle } from "../../shared/mention-handles"; import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge"; @@ -37,7 +38,7 @@ export interface AgentRunLoopDeps { * iteration cap is hit (which throws rather than silently truncating). */ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise { - const allTools = input.externalTools ? [...AGENT_TOOLS, ...input.externalTools] : AGENT_TOOLS; + const allTools = [...AGENT_TOOLS, ...WORKSPACE_TOOLS, ...(input.externalTools ?? [])]; const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }]; let toolCallCount = 0; let lastRequestId = ""; @@ -70,6 +71,8 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn let output: string; if (isComposioTool(call.name)) { output = await executeComposioTool(deps.tools.db, input.orgId, input.toolContext.agent.id, call); + } else if (isWorkspaceTool(call.name)) { + output = await executeWorkspaceTool(input.toolContext, call); } else { output = dispatchAgentTool(deps.tools, input.toolContext, call, { announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls), diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 5c397553..c35af073 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -22,7 +22,8 @@ export interface AgentToolContext { agent: { id: string; modelProviderId: string | null; modelId: string | null }; taskProjectId: string | null; taskGoalId: string | null; - workspace: { id: string; localPath: string } | null; + /** writeAccess mirrors the task's approved filesystem grant (read_write mount) and gates run_command. */ + workspace: { id: string; localPath: string; writeAccess: boolean } | null; } export interface AgentToolDeps { @@ -71,6 +72,10 @@ export const AGENT_TOOLS: ToolDefinition[] = [ description: { type: "string" }, assignee_agent_name: { type: "string", description: "Name of the agent to assign, if any." }, risk: { type: "string", enum: ["low", "medium", "high"] }, + requires_filesystem: { + type: "boolean", + description: "Set true for engineering work that must change code or run commands (build, test) in the workspace. The assignee then gets run_command; the operator approves such tasks before they execute.", + }, }, required: ["title", "description"], }, @@ -307,7 +312,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord goalId: ctx.taskGoalId, workspaceId: ctx.workspace?.id ?? null, priority: TASK_PRIORITY.NORMAL, - requiresFilesystem: false, + requiresFilesystem: args.requires_filesystem === true, }); const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id ? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee }) diff --git a/src/main/services/agent-workspace-tools.ts b/src/main/services/agent-workspace-tools.ts new file mode 100644 index 00000000..c25c3d31 --- /dev/null +++ b/src/main/services/agent-workspace-tools.ts @@ -0,0 +1,150 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { resolveWorkspacePath } from "./files/workspace-path"; +import { optionalString, requireString } from "./agent-tool-args"; +import type { AgentToolContext } from "./agent-tools"; +import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; + +const READ_FILE_CHAR_LIMIT = 24_000; +const LIST_FILES_LIMIT = 400; +const COMMAND_TIMEOUT_MS = 120_000; +const COMMAND_OUTPUT_CHAR_LIMIT = 16_000; +/** Dependency and VCS internals: huge, machine-managed, never what an agent is looking for. */ +const LIST_SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".pnpm-store", "out", "dist"]); + +/** + * The workspace tools turn agents from document writers into workers: they can + * see what exists (list_files), study it (read_file), and — on tasks the + * operator granted filesystem access — actually build and test (run_command). + * write_deliverable in agent-deliverable-tools remains the write path. + */ +export const WORKSPACE_TOOLS: ToolDefinition[] = [ + { + name: "list_files", + description: "List the files in the company workspace (dependency and VCS folders are skipped). Check what already exists before writing or changing anything.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", description: "Subfolder to list, relative to the workspace root. Omit for the whole workspace." }, + }, + required: [], + }, + }, + { + name: "read_file", + description: "Read a file from the company workspace. Always read existing code or documents before editing them — never guess at their contents.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", description: "File path relative to the workspace root, e.g. 'src/index.ts'." }, + }, + required: ["path"], + }, + }, + { + name: "run_command", + description: "Run a real shell command inside the workspace (install, build, test, git). Available only on tasks that carry filesystem access — for engineering work, real output means the code builds and the tests pass, not a description of them.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + command: { type: "string", description: "The command to run, e.g. 'npm test'. It runs with the workspace as working directory." }, + }, + required: ["command"], + }, + }, +]; + +const WORKSPACE_TOOL_NAMES = new Set(WORKSPACE_TOOLS.map((tool) => tool.name)); + +export function isWorkspaceTool(name: string): boolean { + return WORKSPACE_TOOL_NAMES.has(name); +} + +export async function executeWorkspaceTool(ctx: AgentToolContext, call: ToolCall): Promise { + try { + if (!ctx.workspace) throw new Error("No workspace is bound to this task."); + switch (call.name) { + case "list_files": + return listFiles(ctx.workspace.localPath, optionalString(call.arguments.path) ?? undefined); + case "read_file": + return readFile(ctx.workspace.localPath, requireString(call.arguments.path, "path")); + case "run_command": { + if (!ctx.workspace.writeAccess) { + throw new Error("This task does not carry filesystem access. Ask for a task with requires_filesystem, which the operator approves."); + } + return await runCommand(ctx.workspace.localPath, requireString(call.arguments.command, "command")); + } + default: + throw new Error(`Unknown workspace tool "${call.name}".`); + } + } catch (error) { + return `ERROR: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function listFiles(workspaceRoot: string, subPath: string | undefined): string { + const base = subPath ? resolveWorkspacePath(workspaceRoot, subPath).absolute : workspaceRoot; + const entries: string[] = []; + const walk = (directory: string, prefix: string): void => { + if (entries.length >= LIST_FILES_LIMIT) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entries.length >= LIST_FILES_LIMIT) return; + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (LIST_SKIPPED_DIRECTORIES.has(entry.name)) continue; + walk(join(directory, entry.name), relative); + } else { + entries.push(relative); + } + } + }; + walk(base, ""); + if (entries.length === 0) return "The workspace folder is empty."; + const truncation = entries.length >= LIST_FILES_LIMIT ? `\n(truncated at ${LIST_FILES_LIMIT} files — pass a subfolder path to narrow down)` : ""; + return entries.join("\n") + truncation; +} + +function readFile(workspaceRoot: string, relativePath: string): string { + const { absolute, relativePosix } = resolveWorkspacePath(workspaceRoot, relativePath); + const stats = statSync(absolute); + if (!stats.isFile()) throw new Error(`${relativePosix} is not a file.`); + const content = readFileSync(absolute, "utf8"); + if (content.length <= READ_FILE_CHAR_LIMIT) return content; + return `${content.slice(0, READ_FILE_CHAR_LIMIT)}\n\n[truncated: showing the first ${READ_FILE_CHAR_LIMIT} of ${content.length} characters]`; +} + +function runCommand(workspaceRoot: string, command: string): Promise { + return new Promise((resolvePromise) => { + const child = spawn(command, { shell: true, cwd: workspaceRoot, windowsHide: true }); + let output = ""; + let killedByTimeout = false; + const timer = setTimeout(() => { + killedByTimeout = true; + child.kill(); + }, COMMAND_TIMEOUT_MS); + const collect = (chunk: Buffer): void => { + output += chunk.toString("utf8"); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + child.on("error", (error) => { + clearTimeout(timer); + resolvePromise(`ERROR: ${error.message}`); + }); + child.on("close", (code) => { + clearTimeout(timer); + const trimmed = output.length > COMMAND_OUTPUT_CHAR_LIMIT + ? `${output.slice(0, COMMAND_OUTPUT_CHAR_LIMIT / 2)}\n[... output truncated ...]\n${output.slice(-COMMAND_OUTPUT_CHAR_LIMIT / 2)}` + : output; + if (killedByTimeout) { + resolvePromise(`ERROR: command timed out after ${COMMAND_TIMEOUT_MS / 1000}s and was killed. Partial output:\n${trimmed}`); + return; + } + resolvePromise(`exit code ${code ?? 0}\n${trimmed.trim() || "(no output)"}`); + }); + }); +} diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 94dca5e8..e22a4080 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -138,7 +138,7 @@ export class TaskExecutionService { agent: { id: agent.id, modelProviderId: agent.model_provider_id, modelId: agent.model_id }, taskProjectId: task.project_id, taskGoalId: task.goal_id, - workspace: workspace ? { id: workspace.id, localPath: workspace.local_path } : null, + workspace: workspace ? { id: workspace.id, localPath: workspace.local_path, writeAccess: task.requires_filesystem === 1 } : null, }; const chatContext = this.loadChatContext(orgId, isolation); const inboxContext = chatContext ? null : this.loadInboxContext(orgId, agent.id); diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 72735fb6..8bec86fe 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -94,7 +94,7 @@ export function buildAgentInstructions(input: AgentInstructionContext): string { `You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`, roleVoice(input), "Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.", - `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name, update_task_status tracks it, write_deliverable produces real files, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.`, + `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.`, ...(input.language === "zh" ? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"] : []), @@ -226,6 +226,12 @@ export function buildTaskPrompt(input: { "Respond to anyone above who needs your input before starting the task.", ); } + if (input.task.requires_filesystem === 1 && input.workspace) { + 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.", + ); + } if (input.priorCompletedRuns > 0) { lines.push( "", diff --git a/tests/unit/agent-run-loop.test.ts b/tests/unit/agent-run-loop.test.ts index 892eeca9..afa75e48 100644 --- a/tests/unit/agent-run-loop.test.ts +++ b/tests/unit/agent-run-loop.test.ts @@ -64,7 +64,7 @@ describe("agent run loop (real tool dispatch)", () => { agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" }, taskProjectId: null, taskGoalId: null, - workspace: { id: workspaceId, localPath: workspaceRoot }, + workspace: { id: workspaceId, localPath: workspaceRoot, writeAccess: true }, }; const script: ScriptedTurn[] = [ @@ -136,7 +136,7 @@ describe("agent run loop (real tool dispatch)", () => { }); const ctx: AgentToolContext = { orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" }, - taskProjectId: null, taskGoalId: null, workspace: { id: "wsp", localPath: workspaceRoot }, + taskProjectId: null, taskGoalId: null, workspace: { id: "wsp", localPath: workspaceRoot, writeAccess: true }, }; const script: ScriptedTurn[] = [ { text: "escape", toolCalls: [call("write_deliverable", { path: "../escape.txt", content: "x" })] }, diff --git a/tests/unit/agent-workspace-tools.test.ts b/tests/unit/agent-workspace-tools.test.ts new file mode 100644 index 00000000..7f92e4f0 --- /dev/null +++ b/tests/unit/agent-workspace-tools.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { executeWorkspaceTool, isWorkspaceTool, WORKSPACE_TOOLS } from "../../src/main/services/agent-workspace-tools"; +import type { ToolCall } from "../../src/main/services/provider-tool-turn"; + +const workspaceRoot = join(tmpdir(), `ac-workspace-tools-${randomUUID()}`); +mkdirSync(join(workspaceRoot, "src"), { recursive: true }); +mkdirSync(join(workspaceRoot, "node_modules", "left-pad"), { recursive: true }); +writeFileSync(join(workspaceRoot, "README.md"), "# Demo\n"); +writeFileSync(join(workspaceRoot, "src", "index.ts"), "export const answer = 42;\n"); +writeFileSync(join(workspaceRoot, "node_modules", "left-pad", "index.js"), "module.exports = () => {};\n"); + +afterAll(() => { + rmSync(workspaceRoot, { recursive: true, force: true }); +}); + +function call(name: string, args: Record): ToolCall { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +function ctx(writeAccess: boolean): AgentToolContext { + return { + orgId: "org_test", + agent: { id: "agt_test", modelProviderId: null, modelId: null }, + taskProjectId: null, + taskGoalId: null, + workspace: { id: "wsp_test", localPath: workspaceRoot, writeAccess }, + }; +} + +describe("agent workspace tools", () => { + test("registers the three tools and recognises them by name", () => { + expect(WORKSPACE_TOOLS.map((tool) => tool.name)).toEqual(["list_files", "read_file", "run_command"]); + expect(isWorkspaceTool("run_command")).toBe(true); + expect(isWorkspaceTool("write_deliverable")).toBe(false); + }); + + test("list_files walks the workspace and skips dependency folders", async () => { + const listing = await executeWorkspaceTool(ctx(false), call("list_files", {})); + expect(listing).toContain("README.md"); + expect(listing).toContain("src/index.ts"); + expect(listing).not.toContain("left-pad"); + }); + + test("read_file returns contents and refuses to leave the workspace", async () => { + expect(await executeWorkspaceTool(ctx(false), call("read_file", { path: "src/index.ts" }))).toContain("answer = 42"); + expect(await executeWorkspaceTool(ctx(false), call("read_file", { path: "../outside.txt" }))).toContain("ERROR"); + expect(await executeWorkspaceTool(ctx(false), call("read_file", { path: "C:/Windows/win.ini" }))).toContain("ERROR"); + }); + + test("run_command is gated on the task's approved filesystem access", async () => { + const denied = await executeWorkspaceTool(ctx(false), call("run_command", { command: "echo blocked" })); + expect(denied).toContain("ERROR"); + expect(denied).toContain("filesystem access"); + + const allowed = await executeWorkspaceTool(ctx(true), call("run_command", { command: "echo it works" })); + expect(allowed).toContain("exit code 0"); + expect(allowed).toContain("it works"); + + const failing = await executeWorkspaceTool(ctx(true), call("run_command", { command: 'node -e "process.exit(3)"' })); + expect(failing).toContain("exit code 3"); + }); + + test("tools without a bound workspace explain the situation instead of crashing", async () => { + const noWorkspace: AgentToolContext = { ...ctx(true), workspace: null }; + expect(await executeWorkspaceTool(noWorkspace, call("list_files", {}))).toContain("No workspace"); + }); + + test("create_task can request filesystem access, which lands as an approval-gated task", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-fs-task-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + let providerId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "BuildCorp", employeeName: "Yang", mission: "m", goal: "g", executionMode: "ai_cofounder" }); + providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + connection.db.prepare("update providers set enabled = 1 where id = ?").run(providerId); + ceoId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, + }); + }); + const deps = { db: connection.db, organization, chat: new ChatService(connection.db) }; + const toolCtx: AgentToolContext = { + orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" }, + taskProjectId: null, taskGoalId: null, workspace: null, + }; + + const result = dispatchAgentTool(deps, toolCtx, call("create_task", { + title: "Implement tenant RBAC", + description: "Build and test the tenant RBAC module.", + requires_filesystem: true, + })); + const taskId = /task_id=(\S+?)\)/.exec(result)?.[1] ?? ""; + expect(taskId).toBeTruthy(); + + const task = connection.db.prepare("select requires_filesystem from tasks where id = ?").get(taskId) as { requires_filesystem: number }; + expect(task.requires_filesystem).toBe(1); + const approval = connection.db.prepare( + "select state from approvals where org_id = ? and source_entity_type = 'task' and source_entity_id = ?", + ).get(orgId, taskId) as { state: string } | undefined; + expect(approval).toMatchObject({ state: "pending" }); + connection.close(); + }); +}); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index af6503d8..10f24647 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -54,7 +54,7 @@ function harness(mode: "ai_ceo" | "ai_cofounder"): Harness { agent: { id: ceoId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, - workspace: { id: workspaceId, localPath: workspaceRoot }, + workspace: { id: workspaceId, localPath: workspaceRoot, writeAccess: true }, }, deps: { db: connection.db, organization, chat: new ChatService(connection.db) }, };