From ed2dceab7334c66560c5fe5744ce86399c55d442 Mon Sep 17 00:00:00 2001 From: Gautam Tayal <148259092+gautamtayal1@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:44:39 +0530 Subject: [PATCH] feat (core): inject worker prompt as instructions file (#1302) * fix: codex worker session prompt file * fix: clean up buildPrompt * fix: revert system prompt change * fix: cleanup user prompt * feat: add opencode agents.md file write for worker agent * feat: update writer function to write dynamic prompt * fix: restore function to pass right agent role * fix: lintfix * fix: revert opencode agents.md file creation * feat: add support for dynamic OpenCode configuration * fix: update OpenCode section identifiers snd docs * chore: update tests and changeset * chore: lintfix * chore: fix test * fix(prompt-builder): include issue ID in task prompt when user prompt is not provided --- .changeset/worker-system-prompt-split.md | 5 + docs/DEVELOPMENT.md | 14 +- docs/opencode-workflows-spec.md | 8 +- packages/core/README.md | 13 +- .../core/src/__tests__/prompt-builder.test.ts | 187 +++++++++++------- .../__tests__/session-manager/restore.test.ts | 74 +++++++ .../__tests__/session-manager/spawn.test.ts | 156 ++++++++++++++- packages/core/src/index.ts | 3 + packages/core/src/opencode-config.ts | 27 +++ packages/core/src/prompt-builder.ts | 36 ++-- packages/core/src/session-manager.ts | 132 ++++++++----- 11 files changed, 508 insertions(+), 147 deletions(-) create mode 100644 .changeset/worker-system-prompt-split.md create mode 100644 packages/core/src/opencode-config.ts diff --git a/.changeset/worker-system-prompt-split.md b/.changeset/worker-system-prompt-split.md new file mode 100644 index 000000000..034c20359 --- /dev/null +++ b/.changeset/worker-system-prompt-split.md @@ -0,0 +1,5 @@ +--- +"@aoagents/ao-core": patch +--- + +Split worker session prompts into persistent system instructions and task-only input, materialize OpenCode worker/orchestrator instructions into session-scoped `AGENTS.md`, and keep restore behavior aligned with the updated AO prompt markers. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 833035b20..54a2a8d60 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -65,7 +65,7 @@ Activity states (orthogonal to lifecycle): `active`, `ready`, `idle`, `waiting_i | ---------------------------------------- | ----------------------------------------------- | | `packages/core/src/session-manager.ts` | Session CRUD: spawn, list, kill, send, restore | | `packages/core/src/lifecycle-manager.ts` | State machine, polling loop, reactions engine | -| `packages/core/src/prompt-builder.ts` | 3-layer prompt assembly (base + config + rules) | +| `packages/core/src/prompt-builder.ts` | Layered worker prompt assembly (system + task) | | `packages/core/src/config.ts` | Config loading and Zod validation | | `packages/core/src/plugin-registry.ts` | Plugin discovery, loading, resolution | | `packages/core/src/agent-selection.ts` | Resolves worker vs orchestrator agent roles | @@ -299,8 +299,10 @@ spawn(config) ├─ Determine branch name ├─ Create workspace (Workspace.create) ├─ Generate issue prompt (Tracker.generatePrompt) + ├─ Assemble layered prompt (prompt-builder.ts) → {systemPrompt, taskPrompt} + ├─ Persist worker system prompt file + ├─ For OpenCode workers: write OPENCODE_CONFIG pointing at that file ├─ Build agent launch command (Agent.getLaunchCommand) - ├─ Assemble full prompt (prompt-builder.ts) ├─ Create runtime session (Runtime.create) ├─ Post-launch setup (Agent.postLaunchSetup, optional) └─ Write metadata file → return Session @@ -312,11 +314,13 @@ If issue validation fails, nothing is created — fail before allocating resourc ## Prompt Assembly -Prompts are built in three layers (`packages/core/src/prompt-builder.ts`): +Worker prompts are built in three persistent layers (`packages/core/src/prompt-builder.ts`): 1. **Base agent guidance** — standard instructions for all sessions (git workflow, PR conventions, lifecycle hooks) -2. **Config context** — project-specific info (repo, branch, issue details, agent rules from `agentRules` / `agentRulesFile`) -3. **User rules** — inlined last, highest priority +2. **Config context** — project-specific info (repo, branch, tracker, issue details, automated reactions) +3. **Project rules** — content from `agentRules` / `agentRulesFile` + +The explicit user request is returned separately as `taskPrompt`. This lets session manager persist stable system instructions to disk while still sending only task-specific text to agents that need post-launch prompt delivery. Orchestrator sessions use a separate prompt from `packages/core/src/orchestrator-prompt.ts`. diff --git a/docs/opencode-workflows-spec.md b/docs/opencode-workflows-spec.md index 5d53e650a..30becbcb3 100644 --- a/docs/opencode-workflows-spec.md +++ b/docs/opencode-workflows-spec.md @@ -44,10 +44,14 @@ This document defines intended behavior for Agent Orchestrator when `agent: open ## 3) `ao spawn` - Uses selected agent (default/project/override) and launches OpenCode command from plugin launch config. +- Worker sessions write persistent system instructions to `worker-prompt-.md`. - OpenCode launch behavior: - no mapped id: run with `--title AO:` and then continue via discovered session id. - mapped id (`agentConfig.opencodeSessionId`): launch directly with `--session `. -- Model/subagent/system prompt inputs are forwarded into OpenCode launch command. +- For OpenCode workers, core writes `OPENCODE_CONFIG` with an `instructions` array pointing at the worker prompt file instead of mutating workspace `AGENTS.md`. +- The explicit user request remains separate task text and is forwarded as `prompt` only when present. +- OpenCode orchestrators still use workspace `AGENTS.md` for persisted system prompt context. +- Model/subagent/task prompt inputs are forwarded into OpenCode launch command. ## 4) `ao send` @@ -76,6 +80,8 @@ This document defines intended behavior for Agent Orchestrator when `agent: open - attempt title discovery using longer interactive timeout. - if still missing, fail with non-restorable error (`OpenCode session mapping is missing`). - Restore must recreate runtime with preserved metadata/session fields and keep mapping persisted. +- For restored OpenCode workers, core recreates `OPENCODE_CONFIG` from the saved worker prompt file when it exists. +- For restored OpenCode orchestrators, core rewrites workspace `AGENTS.md` from the saved orchestrator prompt file. ## 8) `ao session remap` diff --git a/packages/core/README.md b/packages/core/README.md index 61db60c95..2d88761af 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -47,13 +47,16 @@ Handles session lifecycle: 4. Determine branch name 5. Create workspace via `Workspace.create()` 6. Generate prompt via `Tracker.generatePrompt()` -7. Build launch command via `Agent.getLaunchCommand()` -8. Create runtime session via `Runtime.create()` -9. Run `Agent.postLaunchSetup()` (optional) -10. Write metadata file -11. Return Session object +7. Build layered worker prompt via `buildPrompt()` into `systemPrompt` + `taskPrompt` +8. Persist `systemPromptFile` for the session and, for OpenCode workers, write `OPENCODE_CONFIG` +9. Build launch command via `Agent.getLaunchCommand()` +10. Create runtime session via `Runtime.create()` +11. Run `Agent.postLaunchSetup()` (optional) +12. Write metadata file +13. Return Session object **Note:** If issue validation fails (not found, auth error), spawn fails before creating any resources (no workspace, no runtime, no session ID). This prevents spawning sessions with broken issue references. +Worker sessions keep persistent instructions in the prompt file. OpenCode workers consume that file through `OPENCODE_CONFIG`, while OpenCode orchestrators continue to project their system prompt into workspace `AGENTS.md`. ### `src/services/lifecycle-manager.ts` — State Machine + Reactions diff --git a/packages/core/src/__tests__/prompt-builder.test.ts b/packages/core/src/__tests__/prompt-builder.test.ts index f0e68efe2..71e33a1a4 100644 --- a/packages/core/src/__tests__/prompt-builder.test.ts +++ b/packages/core/src/__tests__/prompt-builder.test.ts @@ -3,7 +3,11 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { buildPrompt, BASE_AGENT_PROMPT, BASE_AGENT_PROMPT_NO_REPO } from "../prompt-builder.js"; +import { + buildPrompt, + BASE_AGENT_PROMPT, + BASE_AGENT_PROMPT_NO_REPO, +} from "../prompt-builder.js"; import type { ProjectConfig } from "../types.js"; let tmpDir: string; @@ -26,76 +30,125 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +function combinePrompt({ + systemPrompt, + taskPrompt, +}: { + systemPrompt: string; + taskPrompt?: string; +}): string { + return taskPrompt ? `${systemPrompt}\n\n${taskPrompt}` : systemPrompt; +} + +describe("buildPrompt split output", () => { + it("splits persistent instructions from task-specific text", () => { + project.agentRules = "Always run pnpm test before pushing."; + + const { systemPrompt, taskPrompt } = buildPrompt({ + project, + projectId: "test-app", + issueId: "INT-1343", + issueContext: "## Linear Issue INT-1343\nTitle: Layered Prompt System", + userPrompt: "Focus on the API layer only.", + }); + + expect(systemPrompt).toContain(BASE_AGENT_PROMPT); + expect(systemPrompt).toContain("## Project Context"); + expect(systemPrompt).toContain("## Project Rules"); + expect(systemPrompt).toContain("## Task"); + expect(systemPrompt).toContain("## Issue Details"); + expect(systemPrompt).not.toContain("## Additional Instructions"); + + expect(taskPrompt).toContain("Focus on the API layer only."); + expect(taskPrompt).not.toContain("Work on issue: INT-1343"); + expect(taskPrompt).not.toContain("Layered Prompt System"); + }); + + it("omits taskPrompt for bare spawns", () => { + const { taskPrompt } = buildPrompt({ + project, + projectId: "test-app", + }); + + expect(taskPrompt).toBeUndefined(); + }); +}); + describe("buildPrompt", () => { it("includes base prompt on bare spawns", () => { - const result = buildPrompt({ project, projectId: "test-app" }); - expect(result).toContain(BASE_AGENT_PROMPT); - expect(result).toContain("## Project Context"); - expect(result).toContain("Project: Test App"); + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app" }); + expect(systemPrompt).toContain(BASE_AGENT_PROMPT); + expect(systemPrompt).toContain("## Project Context"); + expect(systemPrompt).toContain("Project: Test App"); + expect(taskPrompt).toBeUndefined(); }); it("includes base prompt when issue is provided", () => { - const result = buildPrompt({ + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).not.toBeNull(); - expect(result).toContain(BASE_AGENT_PROMPT); + expect(systemPrompt).toContain(BASE_AGENT_PROMPT); + expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(taskPrompt).toBe("Work on issue: INT-1343"); }); it("includes project context", () => { - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).toContain("Test App"); - expect(result).toContain("org/test-app"); - expect(result).toContain("main"); + expect(systemPrompt).toContain("Test App"); + expect(systemPrompt).toContain("org/test-app"); + expect(systemPrompt).toContain("main"); }); it("uses trimmed base prompt when repo is not configured", () => { const noRepoProject = { ...project, repo: undefined }; - const result = buildPrompt({ project: noRepoProject, projectId: "test-app" }); - expect(result).toContain(BASE_AGENT_PROMPT_NO_REPO); - expect(result).not.toContain(BASE_AGENT_PROMPT); - expect(result).not.toContain("create a PR"); - expect(result).not.toContain("PR Best Practices"); - expect(result).not.toContain("Repository:"); + const { systemPrompt } = buildPrompt({ project: noRepoProject, projectId: "test-app" }); + expect(systemPrompt).toContain(BASE_AGENT_PROMPT_NO_REPO); + expect(systemPrompt).not.toContain(BASE_AGENT_PROMPT); + expect(systemPrompt).not.toContain("create a PR"); + expect(systemPrompt).not.toContain("PR Best Practices"); + expect(systemPrompt).not.toContain("Repository:"); }); it("includes issue ID in task section", () => { - const result = buildPrompt({ + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).toContain("Work on issue: INT-1343"); - expect(result).toContain("feat/INT-1343"); + expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).toContain("feat/INT-1343"); + expect(taskPrompt).toBe("Work on issue: INT-1343"); }); it("includes issue context when provided", () => { - const result = buildPrompt({ + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", issueContext: "## Linear Issue INT-1343\nTitle: Layered Prompt System\nPriority: High", }); - expect(result).toContain("## Issue Details"); - expect(result).toContain("Layered Prompt System"); - expect(result).toContain("Priority: High"); + expect(systemPrompt).toContain("## Issue Details"); + expect(systemPrompt).toContain("Layered Prompt System"); + expect(systemPrompt).toContain("Priority: High"); + expect(taskPrompt).toBe("Work on issue: INT-1343"); + expect(taskPrompt).not.toContain("Layered Prompt System"); }); it("includes inline agentRules", () => { project.agentRules = "Always run pnpm test before pushing."; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).toContain("## Project Rules"); - expect(result).toContain("Always run pnpm test before pushing."); + expect(systemPrompt).toContain("## Project Rules"); + expect(systemPrompt).toContain("Always run pnpm test before pushing."); }); it("reads agentRulesFile content", () => { @@ -103,13 +156,13 @@ describe("buildPrompt", () => { writeFileSync(rulesPath, "Use conventional commits.\nNo force pushes."); project.agentRulesFile = "agent-rules.md"; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).toContain("Use conventional commits."); - expect(result).toContain("No force pushes."); + expect(systemPrompt).toContain("Use conventional commits."); + expect(systemPrompt).toContain("No force pushes."); }); it("includes both agentRules and agentRulesFile", () => { @@ -118,85 +171,81 @@ describe("buildPrompt", () => { writeFileSync(rulesPath, "File rule."); project.agentRulesFile = "rules.txt"; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - expect(result).toContain("Inline rule."); - expect(result).toContain("File rule."); + expect(systemPrompt).toContain("Inline rule."); + expect(systemPrompt).toContain("File rule."); }); it("handles missing agentRulesFile gracefully", () => { project.agentRulesFile = "nonexistent-rules.md"; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-1343", }); - // Should not throw, should still build prompt without rules - expect(result).not.toBeNull(); - expect(result).not.toContain("## Project Rules"); + expect(systemPrompt).not.toContain("## Project Rules"); }); it("appends userPrompt last", () => { project.agentRules = "Project rule."; - const result = buildPrompt({ - project, - projectId: "test-app", - issueId: "INT-1343", - userPrompt: "Focus on the API layer only.", - }); + const prompt = combinePrompt( + buildPrompt({ + project, + projectId: "test-app", + issueId: "INT-1343", + userPrompt: "Focus on the API layer only.", + }), + ); - expect(result).not.toBeNull(); - const promptStr = result!; - - // User prompt should come after project rules - const rulesIdx = promptStr.indexOf("Project rule."); - const userIdx = promptStr.indexOf("Focus on the API layer only."); + const rulesIdx = prompt.indexOf("Project rule."); + const userIdx = prompt.indexOf("Focus on the API layer only."); expect(rulesIdx).toBeLessThan(userIdx); - expect(promptStr).toContain("## Additional Instructions"); }); it("builds prompt from rules alone (no issue)", () => { project.agentRules = "Always lint before committing."; - const result = buildPrompt({ - project, - projectId: "test-app", - }); - expect(result).not.toBeNull(); - expect(result).toContain(BASE_AGENT_PROMPT); - expect(result).toContain("Always lint before committing."); + const prompt = combinePrompt( + buildPrompt({ + project, + projectId: "test-app", + }), + ); + expect(prompt).toContain(BASE_AGENT_PROMPT); + expect(prompt).toContain("Always lint before committing."); }); it("builds prompt from userPrompt alone (no issue, no rules)", () => { - const result = buildPrompt({ + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: "test-app", - userPrompt: "Just explore the codebase.", + userPrompt: "Focus on the API layer only.", }); - expect(result).not.toBeNull(); - expect(result).toContain("Just explore the codebase."); + expect(systemPrompt).toContain(BASE_AGENT_PROMPT); + expect(taskPrompt).toContain("Focus on the API layer only."); }); it("includes tracker info in context", () => { project.tracker = { plugin: "linear" }; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-100", }); - expect(result).toContain("Tracker: linear"); + expect(systemPrompt).toContain("Tracker: linear"); }); it("uses project name in context", () => { - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "my-project", issueId: "INT-100", }); - expect(result).toContain("Project: Test App"); + expect(systemPrompt).toContain("Project: Test App"); }); it("includes reaction hints for auto send-to-agent reactions", () => { @@ -204,13 +253,13 @@ describe("buildPrompt", () => { "ci-failed": { auto: true, action: "send-to-agent" }, "approved-and-green": { auto: false, action: "notify" }, }; - const result = buildPrompt({ + const { systemPrompt } = buildPrompt({ project, projectId: "test-app", issueId: "INT-100", }); - expect(result).toContain("ci-failed"); - expect(result).not.toContain("approved-and-green"); + expect(systemPrompt).toContain("ci-failed"); + expect(systemPrompt).not.toContain("approved-and-green"); }); }); diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 0481bc1e1..7c8433bb3 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -712,6 +712,80 @@ describe("restore", () => { expect(written).toContain(""); }); + it("injects OPENCODE_CONFIG for restored OpenCode workers", async () => { + const wsPath = join(tmpDir, "ws-app-worker-opencode-agentsmd"); + mkdirSync(wsPath, { recursive: true }); + + const baseDir = getProjectBaseDir(ctx.config.projects["my-app"]!.storageKey); + mkdirSync(baseDir, { recursive: true }); + const promptFile = join(baseDir, "worker-prompt-app-1.md"); + const promptContent = "Work on issue: TEST-1\nFix the failing tests."; + writeFileSync(promptFile, promptContent, "utf-8"); + + const mockOpenCodeAgent: Agent = { + ...mockAgent, + name: "opencode", + getRestoreCommand: vi.fn().mockResolvedValue("opencode --session 'ses_restore'"), + }; + + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockOpenCodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + agent: "opencode", + opencodeSessionId: "ses_restore", + runtimeHandle: JSON.stringify(makeHandle("rt-old")), + }); + + const configWithOpenCode: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + worker: { + agent: "opencode", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithOpenCode, + registry: registryWithOpenCode, + }); + await sm.restore("app-1"); + + expect(mockRuntime.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + OPENCODE_CONFIG: expect.stringContaining("opencode-config-app-1.json"), + }), + }), + ); + const runtimeCreateCall = vi.mocked(mockRuntime.create).mock.calls[0][0]; + const opencodeConfigPath = runtimeCreateCall.environment.OPENCODE_CONFIG; + expect(opencodeConfigPath).toBeTruthy(); + expect(existsSync(opencodeConfigPath)).toBe(true); + const opencodeConfig = JSON.parse(readFileSync(opencodeConfigPath, "utf-8")) as { + instructions: string[]; + }; + expect(opencodeConfig.instructions).toEqual([promptFile]); + expect(existsSync(getWorkspaceAgentsMdPath(wsPath))).toBe(false); + }); + it("preserves original createdAt/issue/PR metadata", async () => { const wsPath = join(tmpDir, "ws-app-1"); mkdirSync(wsPath, { recursive: true }); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 6964dd48e..b44bdf462 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -997,6 +997,113 @@ describe("spawn", () => { vi.useRealTimers(); }); + it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawn({ + projectId: "my-app", + issueId: "INT-1343", + prompt: "Focus on the API layer only.", + }); + + expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "app-1", + systemPromptFile: expect.stringContaining("worker-prompt-app-1.md"), + prompt: expect.stringContaining("Focus on the API layer only."), + }), + ); + + const callArgs = vi.mocked(mockAgent.getLaunchCommand).mock.calls[0][0]; + expect(callArgs.prompt).toContain("Focus on the API layer only."); + expect(callArgs.prompt).not.toContain("## Task"); + expect(callArgs.prompt).not.toContain("## Project Context"); + expect(callArgs.prompt).not.toContain("Session Lifecycle"); + + const promptFile = callArgs.systemPromptFile!; + expect(existsSync(promptFile)).toBe(true); + const systemPrompt = readFileSync(promptFile, "utf-8"); + expect(systemPrompt).toContain("Session Lifecycle"); + expect(systemPrompt).toContain("## Project Context"); + expect(systemPrompt).toContain("## Task"); + expect(systemPrompt).toContain("Work on issue: INT-1343"); + expect(systemPrompt).not.toContain("## Additional Instructions"); + }); + + it("injects OPENCODE_CONFIG for OpenCode workers", async () => { + const workspacePath = join(tmpDir, "opencode-worker-ws"); + vi.mocked(mockWorkspace.create).mockResolvedValueOnce({ + path: workspacePath, + branch: "feat/INT-1343", + sessionId: "app-1", + projectId: "my-app", + }); + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + const configWithOpenCode: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + opencodeIssueSessionStrategy: "ignore", + worker: { + agent: "opencode", + }, + }, + }, + }; + + const sm = createSessionManager({ + config: configWithOpenCode, + registry: registryWithOpenCode, + }); + + await sm.spawn({ + projectId: "my-app", + issueId: "INT-1343", + prompt: "Focus on the API layer only.", + }); + + expect(mockRuntime.create).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ + OPENCODE_CONFIG: expect.stringContaining("opencode-config-app-1.json"), + }), + }), + ); + + const runtimeCreateCall = vi.mocked(mockRuntime.create).mock.calls[0][0]; + const opencodeConfigPath = runtimeCreateCall.environment.OPENCODE_CONFIG; + expect(opencodeConfigPath).toBeTruthy(); + expect(existsSync(opencodeConfigPath)).toBe(true); + const opencodeConfig = JSON.parse(readFileSync(opencodeConfigPath, "utf-8")) as { + instructions: string[]; + }; + expect(opencodeConfig.instructions).toHaveLength(1); + expect(opencodeConfig.instructions[0]).toContain("worker-prompt-app-1.md"); + + const systemPromptPath = opencodeConfig.instructions[0]!; + expect(readFileSync(systemPromptPath, "utf-8")).toContain("Work on issue: INT-1343"); + expect(readFileSync(systemPromptPath, "utf-8")).not.toContain("## Additional Instructions"); + + const agentsMdPath = getWorkspaceAgentsMdPath(workspacePath); + expect(existsSync(agentsMdPath)).toBe(false); + }); + it("does not send prompt post-launch when agent.promptDelivery is not set", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); @@ -1005,7 +1112,7 @@ describe("spawn", () => { expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); }); - it("sends AO guidance post-launch even when no explicit prompt is provided", async () => { + it("does not send a post-launch message when no task prompt is available", async () => { vi.useFakeTimers(); const postLaunchAgent = { ...mockAgent, @@ -1024,12 +1131,47 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithPostLaunch }); const spawnPromise = sm.spawn({ projectId: "my-app" }); await vi.advanceTimersByTimeAsync(5_000); - await spawnPromise; + const session = await spawnPromise; + + expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); + expect(session.metadata.promptDelivered).toBeUndefined(); + vi.useRealTimers(); + }); + + it("sends a minimal post-launch task for issue-only spawns", async () => { + vi.useFakeTimers(); + const postLaunchAgent = { + ...mockAgent, + promptDelivery: "post-launch" as const, + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return postLaunchAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithPostLaunch }); + const spawnPromise = sm.spawn({ projectId: "my-app", issueId: "INT-1343" }); + await vi.advanceTimersByTimeAsync(5_000); + const session = await spawnPromise; expect(mockRuntime.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String) }), - expect.stringContaining("ao session claim-pr"), + "Work on issue: INT-1343", ); + expect(session.metadata.promptDelivered).toBe("true"); + + const callArgs = vi.mocked(postLaunchAgent.getLaunchCommand).mock.calls[0][0]; + expect(callArgs.prompt).toBe("Work on issue: INT-1343"); + expect(callArgs.systemPromptFile).toContain("worker-prompt-app-1.md"); + + const systemPrompt = readFileSync(callArgs.systemPromptFile!, "utf-8"); + expect(systemPrompt).toContain("## Task"); + expect(systemPrompt).toContain("Work on issue: INT-1343"); vi.useRealTimers(); }); @@ -2088,6 +2230,7 @@ describe("spawn", () => { "my-app": { ...config.projects["my-app"], agent: "opencode", + orchestratorSessionStrategy: "ignore", }, }, }; @@ -2104,9 +2247,10 @@ describe("spawn", () => { const agentsMdPath = getWorkspaceAgentsMdPath("/tmp/ws"); expect(existsSync(agentsMdPath)).toBe(true); - expect(readFileSync(agentsMdPath, "utf-8")).toBe( - "\n## Agent Orchestrator\n\nYou are the orchestrator.\n\n", - ); + const written = readFileSync(agentsMdPath, "utf-8"); + expect(written).toContain(""); + expect(written).toContain("## Agent Orchestrator"); + expect(written).toContain("You are the orchestrator."); expect(mockRuntime.create).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4af159355..1a107d30a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,6 +155,9 @@ export { getWorkspaceAgentsMdPath, writeWorkspaceOpenCodeAgentsMd, } from "./opencode-agents-md.js"; +export { + writeOpenCodeConfig, +} from "./opencode-config.js"; export { getOrchestratorSessionId, normalizeOrchestratorSessionStrategy, diff --git a/packages/core/src/opencode-config.ts b/packages/core/src/opencode-config.ts new file mode 100644 index 000000000..062d8732c --- /dev/null +++ b/packages/core/src/opencode-config.ts @@ -0,0 +1,27 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +function getOpenCodeConfigPath(baseDir: string, sessionId: string): string { + return join(baseDir, `opencode-config-${sessionId}.json`); +} + +export function writeOpenCodeConfig( + baseDir: string, + sessionId: string, + instructionFiles: string[], +): string { + mkdirSync(baseDir, { recursive: true }); + const configPath = getOpenCodeConfigPath(baseDir, sessionId); + writeFileSync( + configPath, + JSON.stringify( + { + instructions: instructionFiles, + }, + null, + 2, + ), + "utf-8", + ); + return configPath; +} diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index 9090ddf31..f207a8f3b 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -6,8 +6,8 @@ * 2. Config-derived context — project name, repo, default branch, tracker info, reaction rules * 3. User rules — inline agentRules and/or agentRulesFile content * - * buildPrompt() always returns the AO base guidance and project context so - * bare launches still know about AO-specific commands such as PR claiming. + * buildPrompt() returns the split between persistent system instructions and + * task-specific text so callers can route them to agents separately. */ import { readFileSync } from "node:fs"; @@ -178,30 +178,32 @@ function readUserRules(project: ProjectConfig): string | null { /** * Compose a layered prompt for an agent session. - * - * Always returns the AO base guidance plus project context, then layers on - * issue context, user rules, and explicit instructions when available. */ -export function buildPrompt(config: PromptBuildConfig): string { +export function buildPrompt( + config: PromptBuildConfig, +): { systemPrompt: string; taskPrompt?: string } { const userRules = readUserRules(config.project); - const sections: string[] = []; + const systemSections: string[] = []; // Layer 1: Base prompt is always included for every managed session. // Use trimmed prompt when no repo is configured (PR/CI instructions don't apply). - sections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO); + systemSections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO); - // Layer 2: Config-derived context - sections.push(buildConfigLayer(config)); + // Layer 2: Worker sessions are scoped to a single issue, so issue/task + // context belongs in the system prompt with the rest of the session context. + systemSections.push(buildConfigLayer(config)); // Layer 3: User rules if (userRules) { - sections.push(`## Project Rules\n${userRules}`); + systemSections.push(`## Project Rules\n${userRules}`); } - // Explicit user prompt (appended last, highest priority) - if (config.userPrompt) { - sections.push(`## Additional Instructions\n${config.userPrompt}`); - } - - return sections.join("\n\n"); + return { + systemPrompt: systemSections.join("\n\n"), + taskPrompt: config.userPrompt + ? config.userPrompt + : config.issueId + ? `Work on issue: ${config.issueId}` + : undefined, + }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index f210a6cd2..7650f7588 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -77,6 +77,7 @@ import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; import { writeWorkspaceOpenCodeAgentsMd, } from "./opencode-agents-md.js"; +import { writeOpenCodeConfig } from "./opencode-config.js"; import { getOrchestratorSessionId, normalizeOrchestratorSessionStrategy, @@ -1233,7 +1234,42 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } - const composedPrompt = buildPrompt({ + const cleanupSpawnWorkspaceAndMetadata = async ( + promptFile?: string, + opencodeConfigFile?: string, + ): Promise => { + if ( + plugins.workspace && + shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath) + ) { + try { + await plugins.workspace.destroy(workspacePath); + } catch { + /* best effort */ + } + } + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + if (promptFile) { + try { + unlinkSync(promptFile); + } catch { + /* best effort */ + } + } + if (opencodeConfigFile) { + try { + unlinkSync(opencodeConfigFile); + } catch { + /* best effort */ + } + } + }; + + const { systemPrompt, taskPrompt } = buildPrompt({ project, projectId: spawnConfig.projectId, issueId: spawnConfig.issueId, @@ -1241,16 +1277,40 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM userPrompt: spawnConfig.prompt, }); + let systemPromptFile: string | undefined; + + // need a seperate config file to pass instructions for opencode session + let opencodeConfigFile: string | undefined; + + try { + const baseDir = getProjectBaseDir(project.storageKey); + mkdirSync(baseDir, { recursive: true }); + systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`); + writeFileSync(systemPromptFile, systemPrompt, "utf-8"); + if (plugins.agent.name === "opencode") { + opencodeConfigFile = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]); + } + } catch (err) { + await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile); + throw err; + } + // Get agent launch config and create runtime — clean up workspace on failure const opencodeIssueSessionStrategy = project.opencodeIssueSessionStrategy ?? "reuse"; - const reusedOpenCodeSessionId = - plugins.agent.name === "opencode" && spawnConfig.issueId - ? await resolveOpenCodeSessionReuse({ - sessionsDir, - criteria: { issueId: spawnConfig.issueId }, - strategy: opencodeIssueSessionStrategy, - }) - : undefined; + let reusedOpenCodeSessionId: string | undefined; + try { + reusedOpenCodeSessionId = + plugins.agent.name === "opencode" && spawnConfig.issueId + ? await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { issueId: spawnConfig.issueId }, + strategy: opencodeIssueSessionStrategy, + }) + : undefined; + } catch (err) { + await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile); + throw err; + } const agentLaunchConfig = { sessionId, projectConfig: { @@ -1261,7 +1321,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }, }, issueId: spawnConfig.issueId, - prompt: composedPrompt, + prompt: taskPrompt, + systemPromptFile, permissions: selection.permissions, model: selection.model, subagent: spawnConfig.subagent ?? selection.subagent, @@ -1278,6 +1339,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand, environment: { ...environment, + ...(opencodeConfigFile ? { OPENCODE_CONFIG: opencodeConfigFile } : {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && { @@ -1295,22 +1357,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }, }); } catch (err) { - // Clean up workspace and reserved ID if agent config or runtime creation failed - if ( - plugins.workspace && - shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath) - ) { - try { - await plugins.workspace.destroy(workspacePath); - } catch { - /* best effort */ - } - } - try { - deleteMetadata(sessionsDir, sessionId, false); - } catch { - /* best effort */ - } + // Clean up workspace, prompt file, and reserved ID if agent config or runtime creation failed + await cleanupSpawnWorkspaceAndMetadata(systemPromptFile, opencodeConfigFile); throw err; } @@ -1401,27 +1449,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } catch { /* best effort */ } - if ( - plugins.workspace && - shouldDestroyWorkspacePath(project, spawnConfig.projectId, workspacePath) - ) { - try { - await plugins.workspace.destroy(workspacePath); - } catch { - /* best effort */ - } - } - try { - deleteMetadata(sessionsDir, sessionId, false); - } catch { - /* best effort */ - } - invalidateCache(); + await cleanupSpawnWorkspaceAndMetadata(systemPromptFile); throw err; } - // Send initial prompt post-launch for agents that need it (e.g. Claude Code - // exits after -p, so we send the prompt after it starts in interactive mode). + // Send the task-specific prompt post-launch for agents that need it + // (e.g. Claude Code exits after -p, so we send the prompt after it starts + // in interactive mode). // This is intentionally outside the try/catch above — a prompt delivery failure // should NOT destroy the session. The agent is running; user can retry with `ao send`. let promptDelivered = false; @@ -2851,6 +2885,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } + let opencodeConfigPath: string | undefined; + if (plugins.agent.name === "opencode" && selection.role !== "orchestrator") { + const baseDir = getProjectBaseDir(project.storageKey); + const systemPromptFile = join(baseDir, `worker-prompt-${sessionId}.md`); + if (existsSync(systemPromptFile)) { + opencodeConfigPath = writeOpenCodeConfig(baseDir, sessionId, [systemPromptFile]); + } + } + // 6. Destroy old runtime if still alive (e.g. tmux session survives agent crash) if (session.runtimeHandle) { try { @@ -2898,6 +2941,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand, environment: { ...environment, + ...(opencodeConfigPath ? { OPENCODE_CONFIG: opencodeConfigPath } : {}), PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]), GH_PATH: PREFERRED_GH_PATH, ...(process.env["AO_AGENT_GH_TRACE"] && {