diff --git a/defaults/devclaw/prompts/orchestrator.md b/defaults/devclaw/prompts/orchestrator.md new file mode 100644 index 00000000..bd78f769 --- /dev/null +++ b/defaults/devclaw/prompts/orchestrator.md @@ -0,0 +1,8 @@ +# Orchestrator Instructions + +You are the DevClaw orchestrator. + +- Use DevClaw tools to manage the workflow. +- Plan, triage, and delegate, but do not implement code changes directly. +- Prefer deterministic tool actions over ad hoc coordination. +- Keep responses concise, clear, and grounded in the current project context. diff --git a/lib/dispatch/bootstrap-hook.test.ts b/lib/dispatch/bootstrap-hook.test.ts index 5b086368..2dafacc2 100644 --- a/lib/dispatch/bootstrap-hook.test.ts +++ b/lib/dispatch/bootstrap-hook.test.ts @@ -4,8 +4,14 @@ */ import { describe, it } from "node:test"; import assert from "node:assert"; -import { parseDevClawSessionKey, loadRoleInstructions } from "./bootstrap-hook.js"; -import { DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; +import { + isMainOrchestratorSession, + parseDevClawSessionKey, + parseMainOrchestratorSessionScope, + loadOrchestratorInstructions, + loadRoleInstructions, +} from "./bootstrap-hook.js"; +import { DEFAULT_ORCHESTRATOR_INSTRUCTIONS, DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; @@ -57,6 +63,52 @@ describe("parseDevClawSessionKey", () => { }); }); +describe("parseMainOrchestratorSessionScope", () => { + it("should parse a real telegram topic session scope", () => { + assert.deepStrictEqual( + parseMainOrchestratorSessionScope("agent:devclaw:telegram:group:-1003581929219:topic:190"), + { channel: "telegram", channelId: "-1003581929219", messageThreadId: "190" }, + ); + }); + + it("should parse a chat-backed orchestrator session without a topic", () => { + assert.deepStrictEqual( + parseMainOrchestratorSessionScope("agent:devclaw:discord:channel:ops-room"), + { channel: "discord", channelId: "ops-room" }, + ); + }); + + it("should reject legacy main and unknown session shapes", () => { + assert.strictEqual(parseMainOrchestratorSessionScope("agent:devclaw:main"), null); + assert.strictEqual(parseMainOrchestratorSessionScope("agent:devclaw:foo:bar"), null); + }); +}); + +describe("isMainOrchestratorSession", () => { + it("should recognize the legacy main session key", () => { + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:main"), true); + }); + + it("should recognize real telegram group orchestrator sessions", () => { + assert.strictEqual( + isMainOrchestratorSession("agent:devclaw:telegram:group:-1003581929219:topic:190"), + true, + ); + }); + + it("should reject worker subagent sessions", () => { + assert.strictEqual( + isMainOrchestratorSession("agent:devclaw:subagent:devclaw-developer-medior-cami"), + false, + ); + }); + + it("should reject unknown non-main session shapes", () => { + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:orchestrator"), false); + assert.strictEqual(isMainOrchestratorSession("agent:devclaw:foo:bar"), false); + }); +}); + describe("loadRoleInstructions", () => { it("should load project-specific instructions from devclaw/projects//prompts/", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); @@ -127,3 +179,41 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); }); + +describe("loadOrchestratorInstructions", () => { + it("should prefer project-specific orchestrator prompt over workspace default", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const projectDir = path.join(tmpDir, "devclaw", "projects", "test-project", "prompts"); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(projectDir, { recursive: true }); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, "orchestrator.md"), "project orchestrator"); + await fs.writeFile(path.join(promptsDir, "orchestrator.md"), "workspace orchestrator"); + + const result = await loadOrchestratorInstructions(tmpDir, "test-project"); + assert.strictEqual(result, "project orchestrator"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should fall back to workspace orchestrator prompt", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(promptsDir, "orchestrator.md"), "workspace orchestrator"); + + const result = await loadOrchestratorInstructions(tmpDir, "missing-project"); + assert.strictEqual(result, "workspace orchestrator"); + + await fs.rm(tmpDir, { recursive: true }); + }); + + it("should fall back to package default orchestrator prompt when present", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + + const result = await loadOrchestratorInstructions(tmpDir, "missing-project"); + assert.strictEqual(result, DEFAULT_ORCHESTRATOR_INSTRUCTIONS ?? ""); + + await fs.rm(tmpDir, { recursive: true }); + }); +}); diff --git a/lib/dispatch/bootstrap-hook.ts b/lib/dispatch/bootstrap-hook.ts index f5d47f07..b1af5764 100644 --- a/lib/dispatch/bootstrap-hook.ts +++ b/lib/dispatch/bootstrap-hook.ts @@ -13,9 +13,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginContext } from "../context.js"; +import { getProject, readProjects } from "../projects/index.js"; import { getSessionKeyRolePattern } from "../roles/index.js"; import { DATA_DIR } from "../setup/migrate-layout.js"; -import { DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; +import { DEFAULT_ORCHESTRATOR_INSTRUCTIONS, DEFAULT_ROLE_INSTRUCTIONS } from "../setup/templates.js"; /** * Parse a DevClaw subagent session key to extract project name and role. @@ -50,7 +51,7 @@ export function parseDevClawSessionKey( /** * Result of loading role instructions — includes the source for traceability. */ -export type RoleInstructionsResult = { +export type PromptInstructionsResult = { content: string; /** Which file the instructions were loaded from, or null if none found. */ source: string | null; @@ -78,13 +79,13 @@ export async function loadRoleInstructions( projectName: string, role: string, opts: { withSource: true }, -): Promise; +): Promise; export async function loadRoleInstructions( workspaceDir: string, projectName: string, role: string, opts?: { withSource: true }, -): Promise { +): Promise { const dataDir = path.join(workspaceDir, DATA_DIR); const candidates = [ @@ -115,6 +116,118 @@ export async function loadRoleInstructions( return ""; } +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName?: string, +): Promise; +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName: string | undefined, + opts: { withSource: true }, +): Promise; +export async function loadOrchestratorInstructions( + workspaceDir: string, + projectName?: string, + opts?: { withSource: true }, +): Promise { + const dataDir = path.join(workspaceDir, DATA_DIR); + const candidates = [ + ...(projectName + ? [path.join(dataDir, "projects", projectName, "prompts", "orchestrator.md")] + : []), + path.join(dataDir, "prompts", "orchestrator.md"), + ]; + + for (const filePath of candidates) { + try { + const content = await fs.readFile(filePath, "utf-8"); + if (opts?.withSource) return { content, source: filePath }; + return content; + } catch { + /* not found, try next */ + } + } + + if (DEFAULT_ORCHESTRATOR_INSTRUCTIONS) { + if (opts?.withSource) { + return { content: DEFAULT_ORCHESTRATOR_INSTRUCTIONS, source: "package-default" }; + } + return DEFAULT_ORCHESTRATOR_INSTRUCTIONS; + } + + if (opts?.withSource) return { content: "", source: null }; + return ""; +} + +const MAIN_SESSION_PATTERNS = [ + /^agent:[^:]+:main$/, + /^agent:[^:]+:(telegram|whatsapp|discord|slack):(group|dm|channel):[^:]+(?::topic:[^:]+)?$/, +]; + +export function isMainOrchestratorSession(sessionKey: string): boolean { + return MAIN_SESSION_PATTERNS.some((pattern) => pattern.test(sessionKey)); +} + +export type OrchestratorSessionScope = { + channel: string; + channelId: string; + messageThreadId?: string; +}; + +export function parseMainOrchestratorSessionScope( + sessionKey: string, +): OrchestratorSessionScope | null { + const match = sessionKey.match( + /^agent:[^:]+:(telegram|whatsapp|discord|slack):(group|dm|channel):([^:]+)(?::topic:([^:]+))?$/, + ); + if (!match) return null; + return { + channel: match[1], + channelId: match[3], + ...(match[4] ? { messageThreadId: match[4] } : {}), + }; +} + +async function resolveProjectNameForBootstrap( + workspaceDir: string, + context: Record, + sessionKey?: string, +): Promise { + const sessionScope = sessionKey ? parseMainOrchestratorSessionScope(sessionKey) : null; + const channelId = + (typeof context.channelId === "string" && context.channelId.trim() ? context.channelId : undefined) ?? + (typeof context.conversationId === "string" && context.conversationId.trim() + ? context.conversationId + : undefined) ?? + (typeof context.peerId === "string" && context.peerId.trim() ? context.peerId : undefined) ?? + sessionScope?.channelId; + + if (!channelId) return undefined; + + const messageThreadId = + typeof context.messageThreadId === "number" || typeof context.messageThreadId === "string" + ? context.messageThreadId + : typeof context.threadId === "number" || typeof context.threadId === "string" + ? context.threadId + : sessionScope?.messageThreadId; + + try { + const data = await readProjects(workspaceDir); + const project = getProject(data, { + channelId, + channel: + typeof context.channel === "string" && context.channel.trim() + ? context.channel + : sessionScope?.channel ?? "telegram", + accountId: typeof context.accountId === "string" ? context.accountId : undefined, + messageThreadId, + }); + return project?.name; + } catch { + return undefined; + } +} + /** * Register the agent:bootstrap hook for DevClaw worker sessions. * @@ -136,10 +249,18 @@ export function registerBootstrapHook(api: OpenClawPluginApi, ctx: PluginContext if (!sessionKey) return; const parsed = parseDevClawSessionKey(sessionKey); - if (!parsed) return; + const isOrchestrator = isMainOrchestratorSession(sessionKey); + if (!parsed && !isOrchestrator) return; const context = event.context as { workspaceDir?: string; + channelId?: string; + conversationId?: string; + peerId?: string; + channel?: string; + accountId?: string; + threadId?: number | string; + messageThreadId?: number | string; bootstrapFiles?: Array<{ name: string; path: string; @@ -154,37 +275,74 @@ export function registerBootstrapHook(api: OpenClawPluginApi, ctx: PluginContext const agentsEntry = bootstrapFiles.find((f) => f.name === "AGENTS.md"); if (!agentsEntry) return; - // Load role instructions from workspace (project-specific → default fallback) const workspaceDir = context.workspaceDir; if (!workspaceDir) { - agentsEntry.content = ""; - agentsEntry.missing = true; - ctx.logger.info( - `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no workspaceDir)`, + if (parsed) { + agentsEntry.content = ""; + agentsEntry.missing = true; + ctx.logger.info( + `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no workspaceDir)`, + ); + } + return; + } + + if (parsed) { + const { content, source } = await loadRoleInstructions( + workspaceDir, + parsed.projectName, + parsed.role, + { withSource: true }, ); + + if (content.trim()) { + agentsEntry.content = content; + agentsEntry.missing = false; + ctx.logger.info( + `agent:bootstrap: injected ${parsed.role} instructions for "${parsed.projectName}" from ${source}`, + ); + } else { + agentsEntry.content = ""; + agentsEntry.missing = true; + ctx.logger.info( + `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no role instructions found)`, + ); + } return; } - const { content, source } = await loadRoleInstructions( + const projectName = await resolveProjectNameForBootstrap( workspaceDir, - parsed.projectName, - parsed.role, - { withSource: true }, + context as Record, + sessionKey, ); + const { content, source } = await loadOrchestratorInstructions(workspaceDir, projectName, { + withSource: true, + }); + if (!content.trim()) return; - if (content.trim()) { - agentsEntry.content = content; - agentsEntry.missing = false; - ctx.logger.info( - `agent:bootstrap: injected ${parsed.role} instructions for "${parsed.projectName}" from ${source}`, - ); + const existing = bootstrapFiles.find((f) => f.name === "orchestrator.md"); + if (existing) { + existing.content = content; + existing.missing = false; } else { - agentsEntry.content = ""; - agentsEntry.missing = true; - ctx.logger.info( - `agent:bootstrap: stripped AGENTS.md for ${parsed.role} worker in "${parsed.projectName}" (no role instructions found)`, - ); + bootstrapFiles.push({ + name: "orchestrator.md", + path: path.join(workspaceDir, "orchestrator.md"), + content, + missing: false, + }); + } + + const synthetic = bootstrapFiles.find((f) => f.name === "DEVCLAW_ORCHESTRATOR_PROMPT.md"); + if (synthetic) { + synthetic.content = ""; + synthetic.missing = true; } + + ctx.logger.info( + `agent:bootstrap: injected orchestrator instructions${projectName ? ` for "${projectName}"` : ""} from ${source}`, + ); }, { name: "devclaw-bootstrap-role-instructions", diff --git a/lib/services/bootstrap.e2e.test.ts b/lib/services/bootstrap.e2e.test.ts index 23127f5d..563e1bb5 100644 --- a/lib/services/bootstrap.e2e.test.ts +++ b/lib/services/bootstrap.e2e.test.ts @@ -199,27 +199,115 @@ describe("E2E bootstrap — extraSystemPrompt injection", () => { }); }); -describe("E2E bootstrap — agent:bootstrap hook (AGENTS.md stripping)", () => { +describe("E2E bootstrap — agent:bootstrap hook", () => { let h: TestHarness; afterEach(async () => { if (h) await h.cleanup(); }); - it("should strip AGENTS.md for DevClaw worker sessions", async () => { + it("should keep worker bootstrap scoped to AGENTS.md only", async () => { h = await createTestHarness({ projectName: "my-app" }); const result = await h.simulateBootstrap( "agent:main:subagent:my-app-developer-medior-Ada", ); - assert.strictEqual(result.agentsMdStripped, true); + assert.ok(result.agentsContent); + assert.ok(!result.agentsContent?.includes("Orchestrator instructions")); + assert.ok(!result.bootstrapFileNames.includes("orchestrator.md")); }); - it("should NOT strip AGENTS.md for non-DevClaw sessions", async () => { + it("should inject the project-specific orchestrator prompt into the legacy main session", async () => { + h = await createTestHarness({ projectName: "my-app" }); + await h.writePrompt("orchestrator", "# My App Orchestrator\nUse the app-specific workflow.", "my-app"); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:main:main", { + channelId: h.channelId, + channel: "telegram", + }); + + assert.strictEqual(result.agentsMdStripped, false); + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("My App Orchestrator")); + assert.ok(!result.orchestratorContent?.includes("Generic workflow")); + }); + + it("should inject the project-specific orchestrator prompt into a real chat-backed orchestrator session", async () => { + h = await createTestHarness({ projectName: "my-app", channelId: "-1003581929219", messageThreadId: 190 }); + await h.writePrompt("orchestrator", "# My App Orchestrator\nUse the app-specific workflow.", "my-app"); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003581929219:topic:190", { + channelId: "-1003581929219", + channel: "telegram", + messageThreadId: 190, + }); + + assert.strictEqual(result.agentsMdStripped, false); + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("My App Orchestrator")); + assert.ok(!result.orchestratorContent?.includes("Generic workflow")); + }); + + it("should resolve the project-specific orchestrator prompt from the real session key when bootstrap context omits chat scope", async () => { + h = await createTestHarness({ projectName: "firstlight", channelId: "-1003746138337", messageThreadId: 2270 }); + await h.writePrompt( + "orchestrator", + "You must follow a three step process when killing ticks:\n1. steady\n2. aim\n3. fire stitcher", + "firstlight", + ); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:devclaw:telegram:group:-1003746138337:topic:2270", { + channel: "telegram", + }); + + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("three step process when killing ticks")); + assert.ok(result.orchestratorContent?.includes("fire stitcher")); + assert.ok(!result.orchestratorContent?.includes("Generic workflow")); + }); + + it("should fall back to workspace orchestrator prompt for chat-scoped resolution misses", async () => { + h = await createTestHarness({ projectName: "my-app" }); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:main:main", { + channelId: "unbound-chat", + channel: "telegram", + }); + + assert.ok(result.bootstrapFileNames.includes("orchestrator.md")); + assert.ok(result.orchestratorContent?.includes("Workspace Orchestrator")); + }); + + it("should resolve project-specific orchestrator prompt by topic when applicable", async () => { + h = await createTestHarness({ projectName: "my-app", messageThreadId: 42 }); + await h.writePrompt("orchestrator", "# Topic Orchestrator\nUse topic-specific workflow.", "my-app"); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:main:main", { + channelId: h.channelId, + channel: "telegram", + messageThreadId: 42, + }); + + assert.ok(result.orchestratorContent?.includes("Topic Orchestrator")); + assert.ok(!result.orchestratorContent?.includes("Generic workflow")); + }); + + it("should NOT inject orchestrator.md for non-main, non-worker sessions", async () => { h = await createTestHarness(); + await h.writePrompt("orchestrator", "# Workspace Orchestrator\nGeneric workflow."); + + const result = await h.simulateBootstrap("agent:main:orchestrator", { + channelId: h.channelId, + channel: "telegram", + }); - const result = await h.simulateBootstrap("agent:main:orchestrator"); assert.strictEqual(result.agentsMdStripped, false); + assert.ok(!result.bootstrapFileNames.includes("orchestrator.md")); }); it("should NOT strip AGENTS.md for unknown roles", async () => { @@ -229,5 +317,6 @@ describe("E2E bootstrap — agent:bootstrap hook (AGENTS.md stripping)", () => { "agent:main:subagent:custom-app-investigator-medior", ); assert.strictEqual(result.agentsMdStripped, false); + assert.ok(!result.bootstrapFileNames.includes("orchestrator.md")); }); }); diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 3740bb5b..924673c8 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -50,6 +50,15 @@ const DEFAULT_QA_INSTRUCTIONS = loadDefault("devclaw/prompts/tester.md"); const DEFAULT_ARCHITECT_INSTRUCTIONS = loadDefault("devclaw/prompts/architect.md"); const DEFAULT_REVIEWER_INSTRUCTIONS = loadDefault("devclaw/prompts/reviewer.md"); +function loadOptionalDefault(filename: string): string | null { + const filePath = path.join(DEFAULTS_DIR, filename); + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + /** Default role instructions indexed by role ID. Used by project scaffolding. */ export const DEFAULT_ROLE_INSTRUCTIONS: Record = { developer: DEFAULT_DEV_INSTRUCTIONS, @@ -58,6 +67,9 @@ export const DEFAULT_ROLE_INSTRUCTIONS: Record = { reviewer: DEFAULT_REVIEWER_INSTRUCTIONS, }; +export const DEFAULT_ORCHESTRATOR_INSTRUCTIONS = + loadOptionalDefault("devclaw/prompts/orchestrator.md"); + // --------------------------------------------------------------------------- // Workspace templates — defaults/AGENTS.md, defaults/SOUL.md, etc. // --------------------------------------------------------------------------- diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts index 8f58742c..2418eea6 100644 --- a/lib/testing/harness.ts +++ b/lib/testing/harness.ts @@ -23,6 +23,12 @@ import type { PluginContext } from "../context.js"; export type BootstrapResult = { /** Whether AGENTS.md was stripped from bootstrap files. */ agentsMdStripped: boolean; + /** Names of bootstrap files present after hook mutation. */ + bootstrapFileNames: string[]; + /** Content injected into orchestrator.md, if any. */ + orchestratorContent?: string; + /** Final AGENTS.md content after hook mutation. */ + agentsContent?: string; }; // --------------------------------------------------------------------------- @@ -170,7 +176,7 @@ export type TestHarness = { * Simulate the agent:bootstrap hook firing for a session key. * Tests that AGENTS.md is stripped from bootstrap files for DevClaw workers. */ - simulateBootstrap(sessionKey: string): Promise; + simulateBootstrap(sessionKey: string, contextOverrides?: Record): Promise; /** Clean up temp directory. */ cleanup(): Promise; }; @@ -180,6 +186,8 @@ export type HarnessOptions = { projectName?: string; /** Channel ID (default: "-1234567890"). */ channelId?: string; + /** Optional Telegram topic binding for topic-aware project resolution tests. */ + messageThreadId?: number; /** Repo path (default: "/tmp/test-repo"). */ repo?: string; /** Base branch (default: "main"). */ @@ -196,6 +204,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise = {}, + ) { // Capture the agent:bootstrap hook callback let internalHookCb: ((event: any) => Promise) | null = null; const mockApi = { @@ -320,12 +338,16 @@ export async function createTestHarness(opts?: HarnessOptions): Promise f.name === "orchestrator.md"); return { agentsMdStripped: bootstrapFiles[0].missing === true && bootstrapFiles[0].content === "", + bootstrapFileNames: bootstrapFiles.map((f) => f.name), + orchestratorContent: orchestratorEntry?.content, + agentsContent: bootstrapFiles[0]?.content, }; }, async cleanup() {