From 8d1efb73c4fc6efd7c0b822613e7546e553887e2 Mon Sep 17 00:00:00 2001 From: Ivan Marshall Widjaja Date: Sun, 11 Jan 2026 23:40:48 +1100 Subject: [PATCH] feat(sisyphus-task): add workdir parameter to constrain spawned agents Add optional `workdir` parameter that injects strict directory constraints into spawned agents' system content. Validates absolute paths, existence, and directory type. Enables orchestrators to delegate work to specific git worktrees or project subdirectories. - Validation: absolute path, exists, is directory - Injection: system content (sync/background) or prompt prepend (resume) - Documentation: updated tool description and schema - Tests: validation, injection, and combination scenarios --- src/tools/sisyphus-task/constants.ts | 1 + src/tools/sisyphus-task/tools.test.ts | 344 ++++++++++++++++++++++++++ src/tools/sisyphus-task/tools.ts | 76 +++++- src/tools/sisyphus-task/types.ts | 1 + 4 files changed, 411 insertions(+), 11 deletions(-) diff --git a/src/tools/sisyphus-task/constants.ts b/src/tools/sisyphus-task/constants.ts index 4919b6556..dc3ec2c5b 100644 --- a/src/tools/sisyphus-task/constants.ts +++ b/src/tools/sisyphus-task/constants.ts @@ -245,6 +245,7 @@ MUTUALLY EXCLUSIVE: Provide EITHER category OR agent, not both (unless resuming) - background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries. - resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity. - skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Skills will be resolved and their content prepended with a separator. Empty array = no prepending. +- workdir: Absolute directory path for the spawned agent. If provided, we inject a strict workdir constraint block into the agent's system/prompt to keep it working only within this directory. The directory must exist and be a valid directory path. Useful for git worktrees or when delegating tasks to specific project subdirectories. **WHEN TO USE resume:** - Task failed/incomplete → resume with "fix: [specific issue]" diff --git a/src/tools/sisyphus-task/tools.test.ts b/src/tools/sisyphus-task/tools.test.ts index d26db75d1..a3d077559 100644 --- a/src/tools/sisyphus-task/tools.test.ts +++ b/src/tools/sisyphus-task/tools.test.ts @@ -427,5 +427,349 @@ describe("buildSystemContent", () => { expect(result).toContain(categoryPromptAppend) expect(result).toContain("\n\n") }) + + test("includes workdir context when workdir is provided", () => { + // #given + const { buildSystemContent } = require("./tools") + const workdir = "/path/to/worktree" + + // #when + const result = buildSystemContent({ workdir }) + + // #then + expect(result).toContain("") + expect(result).toContain(workdir) + expect(result).toContain("WORKING DIRECTORY:") + expect(result).toContain("CRITICAL CONSTRAINTS") + }) + + test("combines workdir with skill content and category promptAppend", () => { + // #given + const { buildSystemContent } = require("./tools") + const skillContent = "You are a playwright expert" + const categoryPromptAppend = "Focus on visual design" + const workdir = "/path/to/worktree" + + // #when + const result = buildSystemContent({ skillContent, categoryPromptAppend, workdir }) + + // #then + expect(result).toContain(skillContent) + expect(result).toContain(categoryPromptAppend) + expect(result).toContain("") + expect(result).toContain(workdir) + // Should have separators between all parts + const parts = result.split("\n\n") + expect(parts.length).toBeGreaterThanOrEqual(3) + }) + }) + + describe("workdir validation", () => { + test("returns error when workdir does not exist", async () => { + // #given + const { createSisyphusTask } = require("./tools") + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: "/nonexistent/path/that/does/not/exist", + }, + toolContext + ) + + // #then + expect(result).toContain("does not exist") + expect(result).toContain("workdir") + }) + + test("returns error when workdir is not an absolute path", async () => { + // #given + const { createSisyphusTask } = require("./tools") + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: "relative/path", + }, + toolContext + ) + + // #then + expect(result).toContain("must be an absolute path") + expect(result).toContain("workdir") + }) + + test("returns error when workdir is not a directory", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const path = require("node:path") + const os = require("node:os") + + // Create a temporary file (not a directory) + const tmpFile = path.join(os.tmpdir(), `test-file-${Date.now()}`) + fs.writeFileSync(tmpFile, "test") + + try { + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Test task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir: tmpFile, + }, + toolContext + ) + + // #then + expect(result).toContain("not a directory") + expect(result).toContain("workdir") + } finally { + // Cleanup + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile) + } + } + }) + }) + + describe("workdir injection in background launch", () => { + test("background launch includes workdir in skillContent", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const os = require("node:os") + const path = require("node:path") + + const workdir = path.join(os.tmpdir(), `test-workdir-${Date.now()}`) + fs.mkdirSync(workdir, { recursive: true }) + + try { + let launchInput: any + + const mockManager = { + launch: async (input: any) => { + launchInput = input + return { + id: "task-workdir", + sessionID: "session-workdir", + description: "Workdir task", + agent: "Sisyphus-Junior", + status: "running", + } + }, + } + + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + await tool.execute( + { + description: "Workdir task", + prompt: "Do something", + category: "general", + run_in_background: true, + skills: [], + workdir, + }, + toolContext + ) + + // #then + expect(launchInput.skillContent).toContain("") + expect(launchInput.skillContent).toContain(workdir) + } finally { + // Cleanup + if (fs.existsSync(workdir)) { + fs.rmSync(workdir, { recursive: true, force: true }) + } + } + }) + }) + + describe("workdir injection in sync execution", () => { + test("sync execution includes workdir in system content", async () => { + // #given + const { createSisyphusTask } = require("./tools") + const fs = require("node:fs") + const os = require("node:os") + const path = require("node:path") + + const workdir = path.join(os.tmpdir(), `test-workdir-sync-${Date.now()}`) + fs.mkdirSync(workdir, { recursive: true }) + + try { + let promptInput: any + const sessionId = "test-session-sync" + let pollCount = 0 + + const mockManager = { launch: async () => ({}) } + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: sessionId } }), + prompt: async (input: any) => { + promptInput = input + return { data: {} } + }, + messages: async () => { + // Return consistent message count to allow stability detection + return { + data: [ + { + info: { role: "assistant", time: { created: Date.now() } }, + parts: [{ type: "text", text: "Task completed" }], + }, + ], + } + }, + status: async () => { + // After initial polls, return idle to allow completion + pollCount++ + return { + data: { + [sessionId]: { + type: pollCount > 5 ? "idle" : "running", + }, + }, + } + }, + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + const result = await tool.execute( + { + description: "Sync workdir task", + prompt: "Do something", + category: "general", + run_in_background: false, + skills: [], + workdir, + }, + toolContext + ) + + // #then + expect(promptInput.body.system).toContain("") + expect(promptInput.body.system).toContain(workdir) + expect(result).toBeDefined() + } finally { + // Cleanup + if (fs.existsSync(workdir)) { + fs.rmSync(workdir, { recursive: true, force: true }) + } + } + }, { timeout: 15000 }) }) }) diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 42113ca52..b7acab90d 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -1,6 +1,6 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" -import { existsSync, readdirSync } from "node:fs" -import { join } from "node:path" +import { existsSync, readdirSync, statSync } from "node:fs" +import { isAbsolute, join } from "node:path" import type { BackgroundManager } from "../../features/background-agent" import type { SisyphusTaskArgs } from "./types" import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema" @@ -95,20 +95,53 @@ export interface SisyphusTaskToolOptions { export interface BuildSystemContentInput { skillContent?: string categoryPromptAppend?: string + workdir?: string +} + +function buildWorkdirContext(workdir: string): string { + return ` +WORKING DIRECTORY: ${workdir} + +**CRITICAL CONSTRAINTS:** +- You MUST treat "${workdir}" as your repository root and working directory +- All file read/write operations MUST be relative to "${workdir}" or use absolute paths under this directory +- When using terminal/shell tools, ALWAYS change to "${workdir}" first (e.g., \`cd "${workdir}" && \`) +- Do NOT operate on files outside of "${workdir}" +- All paths you reference should either be absolute (starting with "${workdir}") or relative to "${workdir}" + +This directory is your workspace boundary - stay within it. +` +} + +function buildPromptWithWorkdir(prompt: string, workdir?: string): string { + if (!workdir) { + return prompt + } + return `${buildWorkdirContext(workdir)}\n\n${prompt}` } export function buildSystemContent(input: BuildSystemContentInput): string | undefined { - const { skillContent, categoryPromptAppend } = input + const { skillContent, categoryPromptAppend, workdir } = input - if (!skillContent && !categoryPromptAppend) { - return undefined + const parts: string[] = [] + + if (skillContent) { + parts.push(skillContent) + } + + if (categoryPromptAppend) { + parts.push(categoryPromptAppend) + } + + if (workdir) { + parts.push(buildWorkdirContext(workdir)) } - if (skillContent && categoryPromptAppend) { - return `${skillContent}\n\n${categoryPromptAppend}` + if (parts.length === 0) { + return undefined } - return skillContent || categoryPromptAppend + return parts.join("\n\n") } export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefinition { @@ -124,6 +157,7 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."), resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"), skills: tool.schema.array(tool.schema.string()).describe("Array of skill names to prepend to the prompt. Use [] if no skills needed."), + workdir: tool.schema.string().optional().describe("Working directory boundary for the spawned agent. If provided, instructions are injected into the agent's system/prompt to constrain it to this directory. Must be an absolute path to an existing directory."), }, async execute(args: SisyphusTaskArgs, toolContext) { const ctx = toolContext as ToolContextWithMetadata @@ -133,6 +167,26 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini if (args.skills === undefined) { return `❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed.` } + + // Validate workdir if provided + if (args.workdir) { + if (!isAbsolute(args.workdir)) { + return `❌ Invalid workdir: "${args.workdir}" must be an absolute path.` + } + if (!existsSync(args.workdir)) { + return `❌ Invalid workdir: "${args.workdir}" does not exist.` + } + try { + const stats = statSync(args.workdir) + if (!stats.isDirectory()) { + return `❌ Invalid workdir: "${args.workdir}" is not a directory.` + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `❌ Error validating workdir: ${message}` + } + } + const runInBackground = args.run_in_background === true let skillContent: string | undefined @@ -157,7 +211,7 @@ export function createSisyphusTask(options: SisyphusTaskToolOptions): ToolDefini try { const task = await manager.resume({ sessionId: args.resume, - prompt: args.prompt, + prompt: buildPromptWithWorkdir(args.prompt, args.workdir), parentSessionID: ctx.sessionID, parentMessageID: ctx.messageID, parentModel, @@ -211,7 +265,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` task: false, sisyphus_task: false, }, - parts: [{ type: "text", text: args.prompt }], + parts: [{ type: "text", text: buildPromptWithWorkdir(args.prompt, args.workdir) }], }, }) } catch (promptError) { @@ -345,7 +399,7 @@ ${textContent || "(No text output)"}` } } - const systemContent = buildSystemContent({ skillContent, categoryPromptAppend }) + const systemContent = buildSystemContent({ skillContent, categoryPromptAppend, workdir: args.workdir }) if (runInBackground) { try { diff --git a/src/tools/sisyphus-task/types.ts b/src/tools/sisyphus-task/types.ts index f60bbeced..9cd6d19d5 100644 --- a/src/tools/sisyphus-task/types.ts +++ b/src/tools/sisyphus-task/types.ts @@ -6,4 +6,5 @@ export interface SisyphusTaskArgs { run_in_background: boolean resume?: string skills: string[] + workdir?: string }