diff --git a/packages/core/examples/README.md b/packages/core/examples/README.md index 1a8dc5d..9d88142 100644 --- a/packages/core/examples/README.md +++ b/packages/core/examples/README.md @@ -86,6 +86,7 @@ Hooking the framework up to outside-the-box tooling. | [`integrations/trace-observability`](integrations/trace-observability.ts) | `onTrace` spans for LLM calls, tools, and tasks. | | [`integrations/mcp-github`](integrations/mcp-github.ts) | An MCP server's tools exposed to an agent via `connectMCPTools()`. | | [`integrations/mcp-bilig-workpaper`](integrations/mcp-bilig-workpaper.ts) | Bilig WorkPaper MCP tools for formula readback, recalculation, and persisted workbook JSON. | +| [`integrations/mcp-open-design`](integrations/mcp-open-design.ts) | Batch fan-out over an MCP server's async jobs: N Open Design runs generated in parallel via `runTasks()`, each polling `get_run` to completion with code-driven orchestration. | ## apps — full applications Complete, clone-and-run applications with their own `package.json` and dependencies. These embed OMA in a real backend, so they use `npm install` plus their own start script rather than `npx tsx`. diff --git a/packages/core/examples/integrations/README.md b/packages/core/examples/integrations/README.md index e487e5b..1729eaf 100644 --- a/packages/core/examples/integrations/README.md +++ b/packages/core/examples/integrations/README.md @@ -10,6 +10,10 @@ or frameworks. The OMA team maintains these once merged, regardless of who contributed them. Current: - `mcp-github.ts`: MCP servers via `connectMCPTools()`. +- `mcp-open-design.ts`: batch fan-out over an MCP server's async, long-running + jobs — N Open Design landing-page variants generated in parallel via + `runTasks()`, each variant driven `create_project → start_run → poll get_run` + to a terminal status by deterministic code. - `trace-observability.ts`: the `onTrace` API. - `with-vercel-ai-sdk/`: Next.js + AI SDK + `runTeam()`. - `express-customer-support/`: Express REST API + `runTasks()`. Contributed diff --git a/packages/core/examples/integrations/mcp-open-design.ts b/packages/core/examples/integrations/mcp-open-design.ts new file mode 100644 index 0000000..8cdd88f --- /dev/null +++ b/packages/core/examples/integrations/mcp-open-design.ts @@ -0,0 +1,413 @@ +/** + * Open Design — Batch Landing-Page Variants (OMA driving an MCP server) + * + * Open Design (https://github.com/nexu-io/open-design) is a local-first design + * app whose CLI exposes a stdio MCP server. Each `start_run` spawns a coding + * agent that produces a design, but a run is ASYNCHRONOUS (returns a runId in + * ~1s) and single: one run does one design, serially, over 5–30 minutes. + * + * This example shows OMA supplying the layer above that: it fans out N + * landing-page variants in PARALLEL via `runTasks()`. Each variant's async OD + * flow — create_project → start_run → poll get_run until a terminal status — is + * driven by deterministic TypeScript (a custom tool), not by the LLM polling in + * a turn loop. The worker model only does what a model should: turn a one-line + * creative angle into a rich design brief. The centerpiece is the parallel + * fan-out over async MCP jobs, not a single call. + * + * Run: + * npx tsx packages/core/examples/integrations/mcp-open-design.ts + * + * Prerequisites: + * - A worker-model API key. Defaults to ANTHROPIC_API_KEY (Claude); set + * AGENT_PROVIDER + AGENT_MODEL to use Gemini/OpenAI/DeepSeek instead. + * - @modelcontextprotocol/sdk installed + * - Open Design running, with its local daemon reachable (default 127.0.0.1:7456) + * - The OD MCP launch command. Open Design has no npm package and no CLI on + * PATH; its MCP server ships inside the desktop app. Open the app → + * Settings → "MCP server" to get your machine's exact command/args/env, and + * export them (values below are one machine's layout, yours will differ): + * export OPEN_DESIGN_MCP_COMMAND=".../Open Design Helper" + * export OPEN_DESIGN_MCP_ARGS='[".../daemon-cli.mjs","mcp","--daemon-url","http://127.0.0.1:7456"]' + * export OPEN_DESIGN_MCP_ENV='{"ELECTRON_RUN_AS_NODE":"1","OD_DATA_DIR":".../namespaces/release-stable/data"}' + * + * Optional env: + * VARIANT_COUNT parallel variants (default 3, clamped 1–8) + * OD_AGENT / OD_MODEL OD engine (default opencode / opencode/deepseek-v4-flash-free — FREE) + * AGENT_PROVIDER OMA worker provider (default anthropic) + * AGENT_MODEL OMA worker model (default claude-haiku-4-5; e.g. claude-sonnet-5) + * PRODUCT_BRIEF what to sell (default: a sample SaaS) + * POLL_INTERVAL_SECONDS status poll interval (default 60, clamped 5–120) + * VARIANT_TIMEOUT_MINUTES per-variant deadline (default 40, clamped 5–90) + * + * ⚠️ COST + TIME + * Each OD run takes 5–30 minutes. With a PAID engine (e.g. sonnet) each run + * costs roughly $2, so N variants ≈ N × $2. The OD daemon caps concurrent runs + * at 4. This example DEFAULTS to a FREE OD engine and 3 variants, so a smoke + * run is ~$0 — only the worker model (a little Claude Haiku) is billed. Point + * OD_MODEL at a paid engine only when you accept the per-run cost. Free OD + * engines are also less reliable: a run may report success yet write no files + * (the example flags those as `succeeded-no-output`). For dependable output + * use a paid engine, e.g. OD_AGENT=claude OD_MODEL=claude-haiku-4-5. + */ + +import { z } from 'zod' +import { OpenMultiAgent, defineTool } from '../../src/index.js' +import { connectMCPTools } from '../../src/mcp.js' +import type { + AgentConfig, + OrchestratorEvent, + RunTaskSpec, + ToolDefinition, + ToolResult, + ToolUseContext, +} from '../../src/types.js' + +// --- env helpers: the OD MCP launch is per-machine, so it must be env-driven -- +function fail(message: string): never { + console.error(message) + process.exit(1) +} + +function requireEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + fail( + `Missing ${name}. Open Design → Settings → "MCP server" prints your machine's exact command, args, and env.\n` + + 'Export them, for example:\n' + + ' export OPEN_DESIGN_MCP_COMMAND=".../Open Design Helper"\n' + + ' export OPEN_DESIGN_MCP_ARGS=\'[".../daemon-cli.mjs","mcp","--daemon-url","http://127.0.0.1:7456"]\'\n' + + ' export OPEN_DESIGN_MCP_ENV=\'{"ELECTRON_RUN_AS_NODE":"1","OD_DATA_DIR":".../namespaces/release-stable/data"}\'', + ) + } + return value +} + +function parseJsonArray(name: string): string[] { + const parsed = ((): unknown => { + try { + return JSON.parse(requireEnv(name)) + } catch { + return undefined + } + })() + if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) { + return parsed as string[] + } + return fail(`${name} must be a JSON array of strings, e.g. ["mcp","--daemon-url","http://127.0.0.1:7456"].`) +} + +function parseJsonObjectOptional(name: string): Record { + const raw = process.env[name]?.trim() + if (!raw) return {} + const parsed = ((): unknown => { + try { + return JSON.parse(raw) + } catch { + return undefined + } + })() + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + return fail(`${name}, if set, must be a JSON object mapping string keys to string values.`) +} + +function clampInt(raw: string | undefined, fallback: number, lo: number, hi: number): number { + const parsed = Number.parseInt(raw ?? '', 10) + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, lo), hi) : fallback +} + +// --- worker model: provider-agnostic, Claude by default ---------------------- +type WorkerProvider = NonNullable +const workerProvider = (process.env.AGENT_PROVIDER?.trim() || 'anthropic') as WorkerProvider +const DEFAULT_WORKER_MODEL: Record = { + anthropic: 'claude-haiku-4-5', + gemini: 'gemini-2.5-flash', + openai: 'gpt-5', + deepseek: 'deepseek-chat', +} +const workerModel = process.env.AGENT_MODEL?.trim() || DEFAULT_WORKER_MODEL[workerProvider] || 'claude-haiku-4-5' + +// Fail fast when the worker provider's key is missing (covers the common +// providers; others fall through to the adapter's own error). +const WORKER_KEY_ENV: Record = { + anthropic: ['ANTHROPIC_API_KEY'], + gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + openai: ['OPENAI_API_KEY'], + deepseek: ['DEEPSEEK_API_KEY'], +} +const requiredKeys = WORKER_KEY_ENV[workerProvider] +if (requiredKeys && !requiredKeys.some((k) => process.env[k]?.trim())) { + fail( + `Missing ${requiredKeys.join(' or ')} for the OMA worker model (AGENT_PROVIDER=${workerProvider}). ` + + 'Set it, or pick a different AGENT_PROVIDER / AGENT_MODEL.', + ) +} + +// --- run configuration -------------------------------------------------------- +const variantCount = clampInt(process.env.VARIANT_COUNT, 3, 1, 8) +const maxConcurrency = Math.min(variantCount, 4) // never exceed OD daemon maxConcurrentRuns:4 +const odAgent = process.env.OD_AGENT?.trim() || 'opencode' +const odModel = process.env.OD_MODEL?.trim() || 'opencode/deepseek-v4-flash-free' +const pollMs = clampInt(process.env.POLL_INTERVAL_SECONDS, 60, 5, 120) * 1000 +const timeoutMs = clampInt(process.env.VARIANT_TIMEOUT_MINUTES, 40, 5, 90) * 60_000 +const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14) + +const PRODUCT = + process.env.PRODUCT_BRIEF?.trim() || + [ + 'Product: "Cadence" — an AI meeting assistant that turns calls into decisions.', + 'Audience: busy product teams. Value: automatic summaries, action items, follow-ups.', + 'Include a hero, three feature blocks, social proof, and a primary CTA "Start free".', + ].join(' ') + +// One seed angle per variant so the fan-out is meaningfully different, not N +// copies. The worker expands its assigned angle into a full brief. +const ANGLES = [ + 'Bold and minimal: high-contrast hero, one primary CTA, lots of whitespace.', + 'Playful and colorful: rounded shapes, friendly microcopy, warm palette.', + 'Enterprise trust: testimonials, customer logos, security and compliance emphasis.', + 'Benefit-led long-form: feature grid plus a short FAQ.', + 'Developer-focused: code-sample hero, docs-style layout, dark theme.', + 'Conversion-first: tight above-the-fold, urgency, single email capture.', + 'Editorial: large type, story-driven sections, generous imagery.', + 'Data-driven: metrics and outcomes front and center, chart-style visuals.', +] + +const TERMINAL = new Set(['succeeded', 'failed', 'canceled']) + +interface VariantResult { + projectName: string + runId: string + status: string + artifact?: string + detail?: string +} + +// Authoritative results collected by CODE (not parsed back out of LLM prose). +const results = new Map() + +// --- connect to the OD MCP server (stdio, env-driven command) ----------------- +const { tools, disconnect } = await connectMCPTools({ + command: requireEnv('OPEN_DESIGN_MCP_COMMAND'), + args: parseJsonArray('OPEN_DESIGN_MCP_ARGS'), + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + ...parseJsonObjectOptional('OPEN_DESIGN_MCP_ENV'), + }, + namePrefix: 'od', // → od_create_project, od_start_run, od_get_run, od_list_agents, … + requestTimeoutMs: 120_000, // each MCP call is short; start_run returns a runId immediately +}) + +function od(name: string): ToolDefinition { + const tool = tools.find((candidate) => candidate.name === `od_${name}`) + if (!tool) { + throw new Error(`OD MCP tool "od_${name}" not found. Exposed tools: ${tools.map((t) => t.name).join(', ')}`) + } + return tool +} + +// OD tool results are text blocks holding a JSON string on success; on failure +// they set isError and the text is a human-readable message with embedded JSON. +function parseOd(result: ToolResult, what: string): Record { + if (result.isError) { + throw new Error(`OD ${what} failed: ${result.data}`) + } + try { + return JSON.parse(result.data) as Record + } catch { + throw new Error(`OD ${what} returned non-JSON output: ${result.data.slice(0, 200)}`) + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) +} + +// --- the deterministic executor: one variant, code-driven polling ------------ +async function runVariant(projectName: string, brief: string, ctx: ToolUseContext): Promise { + try { + const created = parseOd(await od('create_project').execute({ name: projectName }, ctx), 'create_project') + // OD assigns a suffixed id (e.g. "landing-…-v1-c110"). Use that exact id for + // the run rather than re-matching on name — name lookup is a substring match + // and races the create under concurrency ("no project matches …"). + const project = created.project as Record | undefined + const projectId = typeof project?.id === 'string' ? project.id : projectName + + // start_run can race create under concurrency: create returns an id, but the + // project isn't queryable for a beat ("no project matches"). The project was + // just created, so a transient miss is a timing window — retry with backoff. + let started: Record | undefined + for (let attempt = 1; attempt <= 4 && !started; attempt++) { + const res = await od('start_run').execute( + { project: projectId, prompt: brief, agent: odAgent, model: odModel }, + ctx, + ) + if (!res.isError) { + started = JSON.parse(res.data) as Record + } else if (attempt === 4) { + return { projectName, runId: '', status: 'start_failed', detail: res.data.slice(0, 160) } + } else { + await sleep(2000 * attempt, ctx.abortSignal) // 2s, 4s, 6s + } + } + const runId = started && typeof started.runId === 'string' ? started.runId : '' + if (!runId) return { projectName, runId: '', status: 'start_failed', detail: 'start_run returned no runId' } + + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (ctx.abortSignal?.aborted) { + await od('cancel_run') + .execute({ runId }, ctx) + .catch(() => undefined) + return { projectName, runId, status: 'aborted' } + } + await sleep(pollMs, ctx.abortSignal) + const run = parseOd(await od('get_run').execute({ runId }, ctx), 'get_run') + const status = typeof run.status === 'string' ? run.status : 'unknown' + if (TERMINAL.has(status)) { + const previewUrl = typeof run.previewUrl === 'string' ? run.previewUrl : undefined + // A run can report `succeeded` yet write no files (some free engines do + // this). Treat "succeeded but no previewable artifact" as a soft failure + // so the batch report never overclaims a real design that isn't there. + if (status === 'succeeded' && !previewUrl) { + return { projectName, runId, status: 'succeeded-no-output' } + } + return { projectName, runId, status, artifact: previewUrl ?? 'none' } + } + } + return { projectName, runId, status: 'timeout' } + } catch (error) { + return { projectName, runId: '', status: 'error', detail: error instanceof Error ? error.message : String(error) } + } +} + +// Each worker gets its OWN tool with projectName bound in code, so the LLM never +// invents or collides project names — it only supplies the creative brief. +function makeVariantTool(projectName: string): ToolDefinition { + return defineTool({ + name: 'run_landing_variant', + description: + 'Generate this variant\'s landing page in Open Design and block until the design run reaches a terminal ' + + 'status. Call this exactly once, passing your full design brief. Returns JSON {projectName, runId, status, artifact}.', + inputSchema: z.object({ + designBrief: z.string().min(1).describe('A rich, self-contained landing-page design brief for this variant.'), + }), + execute: async ({ designBrief }, ctx) => { + const result = await runVariant(projectName, designBrief, ctx) + results.set(projectName, result) + return { data: JSON.stringify(result), isError: result.status !== 'succeeded' } + }, + }) +} + +// --- progress handler: makes the parallel fan-out visible -------------------- +const startedAt = Date.now() +function elapsed(): string { + return `${((Date.now() - startedAt) / 1000).toFixed(1).padStart(6)}s` +} +function handleProgress(event: OrchestratorEvent): void { + if (event.type === 'agent_start') console.log(`[t+${elapsed()}] START ${event.agent}`) + if (event.type === 'agent_complete') console.log(`[t+${elapsed()}] DONE ${event.agent}`) + if (event.type === 'error') console.error(`[t+${elapsed()}] ERROR ${event.agent ?? ''} ${String(event.data ?? '')}`) +} + +const WORKER_SYSTEM_PROMPT = [ + 'You design ONE landing-page variant.', + 'You are given a product and one creative angle. Expand the angle into a single, rich, self-contained design brief', + '(layout, sections, tone, color direction, copy hints) for that product.', + 'Then call run_landing_variant EXACTLY ONCE with that brief as designBrief.', + 'That tool runs the full Open Design pipeline and blocks until it finishes — do not call it more than once,', + 'and do not try to poll or check status yourself; the tool already waited for the terminal result.', + 'When it returns, reply with one line: RESULT .', +].join(' ') + +try { + // --- preflight: deterministic reachability check + engine discovery -------- + const preflightCtx: ToolUseContext = { agent: { name: 'preflight', role: 'preflight', model: '-' } } + const agentsProbe = await od('list_agents').execute({}, preflightCtx) + if (agentsProbe.isError) { + fail( + 'Could not reach the Open Design daemon via list_agents. Is Open Design running with its daemon ' + + `reachable (default 127.0.0.1:7456)?\n${agentsProbe.data}`, + ) + } + console.log(`Open Design reachable. Worker: ${workerProvider} / ${workerModel}. OD engine: ${odAgent} / ${odModel}.`) + console.log(`Generating ${variantCount} variant(s). Available OD engines:\n${agentsProbe.data.slice(0, 900)}\n`) + + // --- fan-out roster: N DISTINCT worker names → real parallelism ------------ + // (AgentPool holds a per-agent-name Semaphore(1); same-name tasks would serialize.) + const projectNames = Array.from({ length: variantCount }, (_, i) => `landing-${stamp}-v${i + 1}`) + const workers: AgentConfig[] = projectNames.map((projectName, i) => ({ + name: `variant-worker-${i + 1}`, + provider: workerProvider, + model: workerModel, + maxTurns: 6, + parallelToolCalls: false, + customTools: [makeVariantTool(projectName)], // registration == grant; no `tools` allowlist needed + systemPrompt: WORKER_SYSTEM_PROMPT, + })) + + const orchestrator = new OpenMultiAgent({ + defaultProvider: workerProvider, + defaultModel: workerModel, + maxConcurrency, // concurrency comes from HERE; TeamConfig.maxConcurrency is inert + onProgress: handleProgress, + }) + const team = orchestrator.createTeam('landing-variants', { + name: 'landing-variants', + agents: workers, + sharedMemory: false, // variants are independent; no cross-task context + }) + + // --- tasks: one per variant, distinct assignee, NO dependsOn → all parallel - + const tasks: RunTaskSpec[] = workers.map((worker, i) => ({ + title: `variant-${i + 1}`, + assignee: worker.name, + maxRetries: 0, // never auto-retry a 5–30 min design run + description: [ + `Design landing-page variant ${i + 1} for this product:`, + PRODUCT, + '', + `Creative angle for THIS variant: ${ANGLES[i % ANGLES.length]}`, + '', + 'Expand that angle into one rich design brief, then call run_landing_variant once with it.', + ].join('\n'), + })) + + console.log(`Fanning out ${variantCount} variant(s), up to ${maxConcurrency} running at once…\n`) + const runResult = await orchestrator.runTasks(team, tasks) + + // --- report from the authoritative code-collected results ------------------ + console.log(`\n${'='.repeat(64)}`) + console.log(`Batch complete. orchestrator success=${runResult.success}\n`) + let succeeded = 0 + for (const projectName of projectNames) { + const r = results.get(projectName) + if (r?.status === 'succeeded') succeeded++ + const badge = r?.status === 'succeeded' ? 'OK ' : 'FAIL' + const detail = r ? `${r.status}${r.detail ? ` (${r.detail})` : ''} — ${r.artifact ?? 'no artifact'}` : 'no result recorded' + console.log(` [${badge}] ${projectName}: ${detail}`) + } + console.log(`\n${succeeded}/${variantCount} variant(s) succeeded.`) + console.log( + `Worker tokens — input: ${runResult.totalTokenUsage.input_tokens}, output: ${runResult.totalTokenUsage.output_tokens}`, + ) + console.log('Open these projects in Open Design to preview the variants.') +} finally { + // Tears down the stdio proxy only; any in-flight OD runs keep going in the + // daemon and stay retrievable by runId via get_run. + await disconnect() +}