From 89405a4610b7c7fc05f7aab28d50355b06e61263 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Mon, 14 Sep 2026 11:37:20 +0200 Subject: [PATCH 1/5] feat(temporal): support per-node-type taskQueue routing (#141) --- apps/execution-worker/.env.example | 4 + apps/execution-worker/README.md | 15 ++++ apps/execution-worker/package.json | 1 + .../engines/temporal/specialized-worker.ts | 44 +++++++++ apps/execution-worker/src/env.ts | 3 + deploy/ai-studio/README.md | 41 ++++++--- deploy/ai-studio/docker-compose.yml | 24 +++++ packages/temporal/README.md | 17 ++++ .../src/workflow/activity-profiles.ts | 4 + .../workflow/node-activity-options.test.ts | 34 +++++++ .../src/workflow/node-activity-options.ts | 1 + .../src/workflow/profile-validation.test.ts | 58 +++++++++++- .../src/workflow/profile-validation.ts | 21 ++++- .../fixtures/workflows-with-task-queue.ts | 12 +++ .../temporal/test/task-queue-routing.test.ts | 89 +++++++++++++++++++ 15 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 apps/execution-worker/src/engines/temporal/specialized-worker.ts create mode 100644 packages/temporal/test/fixtures/workflows-with-task-queue.ts create mode 100644 packages/temporal/test/task-queue-routing.test.ts diff --git a/apps/execution-worker/.env.example b/apps/execution-worker/.env.example index 94e2ecd4c..7c17aee7f 100644 --- a/apps/execution-worker/.env.example +++ b/apps/execution-worker/.env.example @@ -9,3 +9,7 @@ AI_MODEL=mistralai/mistral-small-3.2-24b-instruct # free key at https://tavily.com (free tier ~1000 searches/month). Leave empty # to disable: agents with web search toggled on still run, just without the tool. TAVILY_API_KEY= + +# Queue polled only by the specialized worker (start:specialized). A node type is +# routed here via its activity profile's `taskQueue`; unset means the default queue. +SPECIALIZED_TASK_QUEUE=workflow-execution-specialized diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a9aa5d107..1eaed9c87 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -44,6 +44,7 @@ src/ └── engines/ └── temporal/ ├── worker.ts # Worker bootstrap: executors + store, handed to WorkflowBuilderPlugin + ├── specialized-worker.ts # Activity-only worker for a taskQueue-routed subset of node types └── workflows.ts # One-line re-export of runWorkflow for Temporal's bundler ``` @@ -51,6 +52,20 @@ The workflow itself, the activity contract and the event emitter live in [`@workflowbuilder/temporal`](../../packages/temporal/README.md). This app only supplies what is its own: one executor per node type and the database as the store port. +## Per-node-type task queue routing + +A node type's activity profile can carry `taskQueue`, which pins it to a queue other +than the default (`plugin.taskQueue`). `worker.ts` keeps polling the default queue for +everything else; `specialized-worker.ts` is a second, activity-only entrypoint (no +`workflowsPath` — Temporal supports activity-only workers) that polls +`SPECIALIZED_TASK_QUEUE` and registers only the node type(s) routed there. Run it with +`pnpm --filter execution-worker start:specialized`, or as the `worker-specialized` +compose service (see `deploy/ai-studio/README.md`). + +This is a deployment-only change: `runGraph` and the graph model never see a taskQueue, +they only affect which worker process a node's `executeNode` activity is scheduled on. +A profile with no `taskQueue` behaves exactly as before. + ## Temporal specifics - **Task queue:** `workflow-execution`, read from `plugin.taskQueue` so the backend and the worker cannot drift apart. Both default to the same constant in the package. diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index b0ee15a9b..18d78d311 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -7,6 +7,7 @@ "dev": "tsx watch --env-file=.env ./src/engines/temporal/worker.ts", "start": "tsx --env-file=.env ./src/engines/temporal/worker.ts", "start:prod": "tsx ./src/engines/temporal/worker.ts", + "start:specialized": "tsx ./src/engines/temporal/specialized-worker.ts", "typecheck": "tsc --noEmit", "lint": "eslint", "lint:fix": "eslint --fix", diff --git a/apps/execution-worker/src/engines/temporal/specialized-worker.ts b/apps/execution-worker/src/engines/temporal/specialized-worker.ts new file mode 100644 index 000000000..a834db457 --- /dev/null +++ b/apps/execution-worker/src/engines/temporal/specialized-worker.ts @@ -0,0 +1,44 @@ +// Activity-only worker: polls SPECIALIZED_TASK_QUEUE and registers just the node +// type(s) routed there via a profile's `taskQueue`. No `workflowsPath` — Temporal +// supports activity-only workers, so this process never needs the workflow bundle. +// Runs from its own Docker image so its tools stay off the general worker's image. +import { NativeConnection, Worker } from '@temporalio/worker'; +import { WorkflowBuilderPlugin } from '@workflowbuilder/temporal'; +import 'dotenv/config'; + +import { executeAiAgent } from '../../activities/ai-agent'; +import { database } from '../../database'; +import type { AiAgentNode } from '../../domain/ai-studio-nodes'; +import { env } from '../../env'; +import { logger } from '../../logger'; +import { withPayloadSizeWarning } from '../../store-payload-warning'; + +const { createOpenRouter } = await import('@openrouter/ai-sdk-provider'); + +const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); +const model = openrouter.chat(env.AI_MODEL); + +const aiAgentLogger = logger.child({ component: 'ai-agent', worker: 'specialized' }); + +// Illustrative: the AI agent is the reference example of a node type heavy enough to +// want its own image (a coding-agent CLI, GPU tooling, ...). Swap in whichever +// executor(s) a real deployment routes to this queue via nodeActivityProfiles. +const plugin = new WorkflowBuilderPlugin({ + executors: { + 'ai-studio/ai-agent': (node, context) => + executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }), + }, + store: withPayloadSizeWarning(database, logger), + taskQueue: env.SPECIALIZED_TASK_QUEUE, +}); + +const connection = await NativeConnection.connect({ address: env.TEMPORAL_ADDRESS }); + +const worker = await Worker.create({ + connection, + taskQueue: plugin.taskQueue, + plugins: [plugin], +}); + +logger.info('specialized execution worker started', { taskQueue: plugin.taskQueue }); +await worker.run(); diff --git a/apps/execution-worker/src/env.ts b/apps/execution-worker/src/env.ts index 5bbd6a61a..970f422b7 100644 --- a/apps/execution-worker/src/env.ts +++ b/apps/execution-worker/src/env.ts @@ -21,4 +21,7 @@ export const env = { AI_MODEL: envOr('AI_MODEL', 'mistralai/mistral-small-3.2-24b-instruct'), // Optional. Enables the AI Agent's web-search tool; agents run without it when unset. TAVILY_API_KEY: process.env['TAVILY_API_KEY'], + // Polled only by the specialized worker (see engines/temporal/specialized-worker.ts). + // A distinct queue name, not the plugin's own, so routing a node here is opt-in per type. + SPECIALIZED_TASK_QUEUE: envOr('SPECIALIZED_TASK_QUEUE', 'workflow-execution-specialized'), }; diff --git a/deploy/ai-studio/README.md b/deploy/ai-studio/README.md index 530155ac3..2aa5ea90a 100644 --- a/deploy/ai-studio/README.md +++ b/deploy/ai-studio/README.md @@ -5,21 +5,36 @@ any Docker host — an Azure VM, AWS, on-prem — with no cloud-specific glue. ## What runs -| Service | Image | Role | Exposed | -| ------------- | ------------------------------ | ----------------------------------------------- | ------------------------ | -| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | -| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | -| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal | -| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | -| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | -| `temporal-db` | `postgres:16` | Temporal's own state store | internal | -| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | +| Service | Image | Role | Exposed | +| -------------------- | ------------------------------ | ---------------------------------------------------------------------- | ------------------------ | +| `web` | `ai-studio-web` (nginx) | Serves the SPA, proxies `/api` to the backend | `${WEB_PORT}` (only one) | +| `backend` | `ai-studio-runtime` | Hono REST + SSE event stream | internal | +| `worker` | `ai-studio-runtime` | Temporal worker, makes the OpenRouter LLM calls | internal | +| `worker-specialized` | `ai-studio-runtime` | Activity-only worker for node types routed to `SPECIALIZED_TASK_QUEUE` | internal | +| `temporal` | `temporalio/auto-setup` pinned | Workflow engine | internal | +| `app-db` | `postgres:16` | Workflow snapshots + execution events | internal | +| `temporal-db` | `postgres:16` | Temporal's own state store | internal | +| `temporal-ui` | `temporalio/ui` pinned | Debug only (`--profile debug`) | `127.0.0.1:8233` | Both images build from one Dockerfile (`deploy/ai-studio/Dockerfile`) with the -repo root as context. Backend and worker share a single image and differ only -in the compose `command`. Database migrations are applied by the backend at -boot (drizzle-orm's programmatic migrator) — there is no separate migration -service or step. +repo root as context. Backend, worker and worker-specialized share a single +image and differ only in the compose `command`. Database migrations are +applied by the backend at boot (drizzle-orm's programmatic migrator) — there +is no separate migration service or step. + +`worker-specialized` polls its own task queue and registers only the node +type(s) meant to run on it (see `apps/execution-worker/README.md` § "Per-node-type +task queue routing"). It only executes anything once a node's activity profile +sets `taskQueue: 'workflow-execution-specialized'` (or your own +`SPECIALIZED_TASK_QUEUE`); until then it idles, polling a queue nothing is scheduled to. + +### Running the specialized worker standalone + +```bash +pnpm --filter execution-worker start:specialized # needs infra up + SPECIALIZED_TASK_QUEUE set +``` + +Or in compose alone: `docker compose up -d worker-specialized`. ## Quick start diff --git a/deploy/ai-studio/docker-compose.yml b/deploy/ai-studio/docker-compose.yml index 5eed67bf7..eb075e96f 100644 --- a/deploy/ai-studio/docker-compose.yml +++ b/deploy/ai-studio/docker-compose.yml @@ -126,6 +126,30 @@ services: condition: service_started restart: unless-stopped + # dedicated pool for node types routed via a profile's `taskQueue`, so their + # dependencies (heavier tools, a different runtime) never reach the shared image. + # Same runtime image today — give it its own Dockerfile stage only once a routed + # node type actually needs extra OS-level tooling the shared image doesn't have. + worker-specialized: + image: ai-studio-runtime + build: *runtime-build + command: ['pnpm', '--filter', 'execution-worker', 'start:specialized'] + environment: + DATABASE_URL: postgresql://wb:${APP_DB_PASSWORD:-wb}@app-db:5432/workflow_builder + TEMPORAL_ADDRESS: temporal:7233 + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:?set OPENROUTER_API_KEY in deploy/ai-studio/.env} + AI_MODEL: ${AI_MODEL:-mistralai/mistral-small-3.2-24b-instruct} + TAVILY_API_KEY: ${TAVILY_API_KEY:-} + SPECIALIZED_TASK_QUEUE: ${SPECIALIZED_TASK_QUEUE:-workflow-execution-specialized} + depends_on: + app-db: + condition: service_healthy + backend: + condition: service_healthy + temporal: + condition: service_started + restart: unless-stopped + web: image: ai-studio-web build: diff --git a/packages/temporal/README.md b/packages/temporal/README.md index 0d94a4bad..1be5f58bf 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -95,6 +95,23 @@ Keep the export named `runWorkflow`: that is the name the client starts, and a t Entries are whole profiles rather than partials on purpose. A partial would let you set a timeout and silently drop the retry cap, and what Temporal falls back to is unlimited retries with backoff, which on a permanently failing model call is an unbounded bill. A node type with no entry resolves to `DEFAULT_NODE_ACTIVITY_PROFILE` and nothing else. +A profile can also carry `taskQueue`, routing that node type's activities to a worker other than the one running the workflow — a small pool with its own Docker image and tools, while every other node type keeps running on the default queue. Nothing else about the workflow changes: `runGraph` and the graph model never see it, it only decides which worker process picks up the `executeNode` command. Without a `taskQueue`, a profile behaves exactly as it did before this field existed. + +```ts +export const nodeActivityProfiles: NodeActivityProfiles = { + // Needs a coding-agent CLI installed — its own image, its own worker. + 'my-product/coding-agent': { + startToCloseTimeout: '30m', + retry: { maximumAttempts: 2 }, + taskQueue: 'coding-agent-pool', + }, +}; +``` + +A worker for that queue is a second, activity-only `Worker.create` call (no `workflowsPath` — Temporal supports activity-only workers) registering just that node type's executor. See `apps/execution-worker/README.md` § "Per-node-type task queue routing" for a full example. + +A `taskQueue` routed to a queue nobody polls fails silently at the workflow level: the activity is scheduled and simply never picked up. Checking that is only possible worker-side, against whichever queues a deployment knows it runs — `findProfilesWithUnpolledTaskQueue(profiles, polledTaskQueues)` from `/workflow` is the same shape as `findProfilesWithoutExecutor`, for the same reason: the sandbox has no registry to check it against itself. + A `startToCloseTimeout` is a number followed by `ms`, `s`, `m`, `h` or `d`. Decimals are fine (`'1.5h'`). It has to fit a protobuf `Duration`, so anything under one nanosecond or over `'3652500d'` is out. Zero, negative values and exponent notation are rejected even though TypeScript's template literal type admits them: `'0s'` type-checks, and Temporal treats a zero timeout as unset and refuses to schedule the activity. This grammar is narrower than Temporal's own, which parses durations with the `ms` package and also takes `'30 minutes'` or `'1 week'`. One documented form is deliberate. If you think in the wider grammar, convert before the value reaches this map. diff --git a/packages/temporal/src/workflow/activity-profiles.ts b/packages/temporal/src/workflow/activity-profiles.ts index e18b6a301..189f72aad 100644 --- a/packages/temporal/src/workflow/activity-profiles.ts +++ b/packages/temporal/src/workflow/activity-profiles.ts @@ -12,6 +12,9 @@ export type DurationString = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`; export type ActivityProfile = { startToCloseTimeout: DurationString; retry: { maximumAttempts: number }; + // Routes this node type's activities to a non-default queue, e.g. a specialized + // worker with its own image/tools. Absent means the plugin's own task queue. + taskQueue?: string; }; // Only for the two frozen singletons below. Annotating them `ActivityProfile` would @@ -20,6 +23,7 @@ export type ActivityProfile = { type ReadonlyActivityProfile = { readonly startToCloseTimeout: DurationString; readonly retry: { readonly maximumAttempts: number }; + readonly taskQueue?: string; }; // Node activities may call LLMs (minutes) — generous timeout, fewer retries to limit diff --git a/packages/temporal/src/workflow/node-activity-options.test.ts b/packages/temporal/src/workflow/node-activity-options.test.ts index 482b60683..20d1b0678 100644 --- a/packages/temporal/src/workflow/node-activity-options.test.ts +++ b/packages/temporal/src/workflow/node-activity-options.test.ts @@ -129,6 +129,40 @@ describe('resolveNodeActivityOptions', () => { }); }); + describe('taskQueue', () => { + it('forwards taskQueue when the profile carries one', () => { + const profiles: NodeActivityProfiles = { + 'test/step': { startToCloseTimeout: '2m', retry: { maximumAttempts: 4 }, taskQueue: 'specialized' }, + }; + + expect(resolveNodeActivityOptions(node(), profiles)).toEqual({ + startToCloseTimeout: '2m', + retry: { maximumAttempts: 4 }, + taskQueue: 'specialized', + }); + }); + + it('omits the key entirely when the profile has none, not taskQueue: undefined', () => { + const resolved = resolveNodeActivityOptions(node(), {}); + + expect('taskQueue' in resolved).toBe(false); + }); + + it('is pure routing metadata: identical graph-relevant fields whether taskQueue is set or not', () => { + // The workflow sandbox must not branch on taskQueue — it only reaches proxyActivities. + const base = resolveNodeActivityOptions(node(), { + 'test/step': { startToCloseTimeout: '2m', retry: { maximumAttempts: 4 } }, + }); + const routed = resolveNodeActivityOptions(node(), { + 'test/step': { startToCloseTimeout: '2m', retry: { maximumAttempts: 4 }, taskQueue: 'specialized' }, + }); + + expect(routed.startToCloseTimeout).toBe(base.startToCloseTimeout); + expect(routed.retry).toEqual(base.retry); + expect(routed.summary).toBe(base.summary); + }); + }); + describe('summary', () => { it('carries the node label so Event History reads like the diagram', () => { const resolved = resolveNodeActivityOptions(node({ label: 'Fetch order' }), {}); diff --git a/packages/temporal/src/workflow/node-activity-options.ts b/packages/temporal/src/workflow/node-activity-options.ts index b430ac0b8..5cb12223e 100644 --- a/packages/temporal/src/workflow/node-activity-options.ts +++ b/packages/temporal/src/workflow/node-activity-options.ts @@ -41,6 +41,7 @@ export function resolveFromValidatedProfiles(node: BaseNode, profiles: NodeActiv const options: NodeActivityOptions = { startToCloseTimeout: profile.startToCloseTimeout, retry: { maximumAttempts: profile.retry.maximumAttempts }, + ...(profile.taskQueue === undefined ? {} : { taskQueue: profile.taskQueue }), }; // Not trusted from the caller: any consumer can build the workflow input. Temporal diff --git a/packages/temporal/src/workflow/profile-validation.test.ts b/packages/temporal/src/workflow/profile-validation.test.ts index 28e2fdd33..f08dd65f2 100644 --- a/packages/temporal/src/workflow/profile-validation.test.ts +++ b/packages/temporal/src/workflow/profile-validation.test.ts @@ -5,6 +5,7 @@ import type { BaseNode } from './core-contract'; import { resolveNodeActivityOptions } from './node-activity-options'; import { assertNodeActivityProfiles, + findProfilesWithUnpolledTaskQueue, findProfilesWithoutExecutor, freezeNodeActivityProfiles, } from './profile-validation'; @@ -129,13 +130,43 @@ describe('assertNodeActivityProfiles', () => { 'test/step': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, - taskQueue: 'x', + scheduleToCloseTimeout: '1h', heartbeatTimeout: '1m', }, }; expect(() => assertNodeActivityProfiles(extra as unknown as NodeActivityProfiles)).toThrow( - /has unknown keys "taskQueue", "heartbeatTimeout"/, + /has unknown keys "scheduleToCloseTimeout", "heartbeatTimeout"/, + ); + }); + }); + + describe('taskQueue', () => { + it('accepts a profile with a non-empty taskQueue', () => { + const withQueue = { + 'test/step': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, taskQueue: 'specialized' }, + }; + + expect(() => assertNodeActivityProfiles(withQueue as unknown as NodeActivityProfiles)).not.toThrow(); + }); + + it('accepts a profile with no taskQueue, unchanged from before', () => { + expect(() => assertNodeActivityProfiles(profiles('10m'))).not.toThrow(); + }); + + it('rejects an empty string, naming the path', () => { + const empty = { 'test/step': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, taskQueue: '' } }; + + expect(() => assertNodeActivityProfiles(empty as unknown as NodeActivityProfiles)).toThrow( + /nodeActivityProfiles\["test\/step"\]\.taskQueue must be a non-empty string/, + ); + }); + + it('rejects a non-string value', () => { + const wrongType = { 'test/step': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, taskQueue: 7 } }; + + expect(() => assertNodeActivityProfiles(wrongType as unknown as NodeActivityProfiles)).toThrow( + /taskQueue must be a non-empty string/, ); }); }); @@ -205,3 +236,26 @@ describe('findProfilesWithoutExecutor', () => { expect(findProfilesWithoutExecutor(inherited, executors)).toEqual(['constructor']); }); }); + +describe('findProfilesWithUnpolledTaskQueue', () => { + it('names a node type routed to a queue nobody polls', () => { + const map = { + 'test/routed': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, taskQueue: 'specialized' }, + 'test/default': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 } }, + } as unknown as NodeActivityProfiles; + + expect(findProfilesWithUnpolledTaskQueue(map, new Set(['workflow-execution']))).toEqual(['test/routed']); + }); + + it('is silent once the queue is in the polled set', () => { + const map = { + 'test/routed': { startToCloseTimeout: '10m', retry: { maximumAttempts: 2 }, taskQueue: 'specialized' }, + } as unknown as NodeActivityProfiles; + + expect(findProfilesWithUnpolledTaskQueue(map, new Set(['specialized']))).toEqual([]); + }); + + it('ignores a profile with no taskQueue regardless of the polled set', () => { + expect(findProfilesWithUnpolledTaskQueue(profiles('10m'), new Set())).toEqual([]); + }); +}); diff --git a/packages/temporal/src/workflow/profile-validation.ts b/packages/temporal/src/workflow/profile-validation.ts index e8112dce7..6b231b67d 100644 --- a/packages/temporal/src/workflow/profile-validation.ts +++ b/packages/temporal/src/workflow/profile-validation.ts @@ -42,11 +42,15 @@ function assertActivityProfile(nodeType: string, profile: ActivityProfile | unde ); } + if (profile.taskQueue !== undefined && (typeof profile.taskQueue !== 'string' || profile.taskQueue === '')) { + throw new TypeError(`${path}.taskQueue must be a non-empty string, got ${JSON.stringify(profile.taskQueue)}.`); + } + assertNoUnknownKeys(path, profile, PROFILE_KEYS); assertNoUnknownKeys(`${path}.retry`, profile.retry, RETRY_KEYS); } -const PROFILE_KEYS: ReadonlySet = new Set(['startToCloseTimeout', 'retry']); +const PROFILE_KEYS: ReadonlySet = new Set(['startToCloseTimeout', 'retry', 'taskQueue']); const RETRY_KEYS: ReadonlySet = new Set(['maximumAttempts']); function assertNoUnknownKeys(path: string, value: object, allowed: ReadonlySet): void { @@ -54,7 +58,7 @@ function assertNoUnknownKeys(path: string, value: object, allowed: ReadonlySet JSON.stringify(key)).join(', ')}. A profile carries startToCloseTimeout and retry.maximumAttempts, nothing else.`, + `${path} has unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.map((key) => JSON.stringify(key)).join(', ')}. A profile carries startToCloseTimeout, retry.maximumAttempts and an optional taskQueue, nothing else.`, ); } @@ -83,3 +87,16 @@ export function freezeNodeActivityProfiles(profiles: NodeActivityProfiles): Node export function findProfilesWithoutExecutor(profiles: NodeActivityProfiles, executors: object): string[] { return Object.keys(profiles).filter((nodeType) => !Object.hasOwn(executors, nodeType)); } + +// Same shape as findProfilesWithoutExecutor: only checkable worker-side, since a +// profile's taskQueue is routing metadata the sandbox never sees. `polledTaskQueues` +// is whatever queues this call knows are actually polled (e.g. this worker's own plus +// any others it is told about) — a node routed elsewhere silently never runs. +export function findProfilesWithUnpolledTaskQueue( + profiles: NodeActivityProfiles, + polledTaskQueues: ReadonlySet, +): string[] { + return Object.entries(profiles) + .filter(([, profile]) => profile.taskQueue !== undefined && !polledTaskQueues.has(profile.taskQueue)) + .map(([nodeType]) => nodeType); +} diff --git a/packages/temporal/test/fixtures/workflows-with-task-queue.ts b/packages/temporal/test/fixtures/workflows-with-task-queue.ts new file mode 100644 index 000000000..b6930e5b0 --- /dev/null +++ b/packages/temporal/test/fixtures/workflows-with-task-queue.ts @@ -0,0 +1,12 @@ +// A second workflows module, built with createRunWorkflow, so a test can prove a +// profile's taskQueue reaches the ScheduleActivityTask command. The zero-config +// export in ./workflows.ts cannot exercise this: it never calls createRunWorkflow. +import { createRunWorkflow } from '../../src/workflow/index'; + +export const SPECIALIZED_TASK_QUEUE = 'test-specialized-queue'; + +export const runWorkflow = createRunWorkflow({ + nodeActivityProfiles: { + 'test/step': { startToCloseTimeout: '10s', retry: { maximumAttempts: 1 }, taskQueue: SPECIALIZED_TASK_QUEUE }, + }, +}); diff --git a/packages/temporal/test/task-queue-routing.test.ts b/packages/temporal/test/task-queue-routing.test.ts new file mode 100644 index 000000000..c5beab83a --- /dev/null +++ b/packages/temporal/test/task-queue-routing.test.ts @@ -0,0 +1,89 @@ +// Proves item 6 of the taskQueue-routing issue: a profile's taskQueue reaches the +// real ScheduleActivityTask command, not just the resolved options object. +import { type History } from '@temporalio/common/lib/proto-utils'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { Worker, bundleWorkflowCode } from '@temporalio/worker'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { WorkflowBuilderPlugin, type WorkflowExecutionInput, executionWorkflowId } from '../src/index'; +import { REPLAY_TEST_GRAPH, createRecordingStore, replayTestExecutors } from './fixtures/graph'; +import { SPECIALIZED_TASK_QUEUE } from './fixtures/workflows-with-task-queue'; + +const EXECUTION_ID = 'task-queue-routing-execution'; +const TASK_QUEUE = 'task-queue-routing-test'; + +describe('taskQueue routing', () => { + let env: TestWorkflowEnvironment; + let history: History; + + beforeAll(async () => { + const [workflowBundle, testEnv] = await Promise.all([ + bundleWorkflowCode({ + workflowsPath: fileURLToPath(new URL('fixtures/workflows-with-task-queue.ts', import.meta.url)), + }), + TestWorkflowEnvironment.createLocal(), + ]); + env = testEnv; + + const plugin = new WorkflowBuilderPlugin({ + store: createRecordingStore(), + executors: replayTestExecutors, + taskQueue: TASK_QUEUE, + }); + + // Polls only the default queue: the routed node's activity is deliberately never + // executed, so the workflow stays open while its ScheduleActivityTask is inspected. + const worker = await Worker.create({ + connection: env.nativeConnection, + namespace: env.namespace, + taskQueue: plugin.taskQueue, + workflowBundle, + plugins: [plugin], + }); + + const input: WorkflowExecutionInput<(typeof REPLAY_TEST_GRAPH)['nodes'][number]> = { + workflowId: REPLAY_TEST_GRAPH.workflowId, + executionId: EXECUTION_ID, + definition: REPLAY_TEST_GRAPH, + triggerPayload: {}, + variables: {}, + global: {}, + }; + + const workflowId = executionWorkflowId(EXECUTION_ID); + + await worker.runUntil(async () => { + const handle = await env.client.workflow.start('runWorkflow', { + taskQueue: plugin.taskQueue, + workflowId, + args: [input], + }); + + // The workflow awaits the execution_started DB activity (on the default queue) + // before scheduling the routed node, so history is polled until that command lands. + for (let attempt = 0; attempt < 50; attempt += 1) { + history = await handle.fetchHistory(); + const hasExecuteNode = (history.events ?? []).some( + (event) => event.activityTaskScheduledEventAttributes?.activityType?.name === 'executeNode', + ); + if (hasExecuteNode) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await handle.terminate('test cleanup'); + }); + }, 60_000); + + afterAll(async () => { + await env?.teardown(); + }); + + it('schedules the routed node on its profile-declared taskQueue, not the workflow default', () => { + const scheduled = (history.events ?? []).find( + (event) => event.activityTaskScheduledEventAttributes?.activityType?.name === 'executeNode', + ); + + expect(scheduled?.activityTaskScheduledEventAttributes?.taskQueue?.name).toBe(SPECIALIZED_TASK_QUEUE); + }); +}); From f069490d469477427528a1645d5849e047814b50 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg <40181002+tbrandenburg@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:33:34 +0200 Subject: [PATCH 2/5] feat(execution-worker): per-node AI model/provider resolution (#2) * feat(execution-worker): per-node AI model/provider resolution (#144) Add optional model/provider fields to the ai-agent node config, resolved per node execution via a new model-provider.ts table that dispatches to any of 14 @ai-sdk/ providers (dynamically imported) or OpenRouter, falling back to node.config.model/provider -> env.AI_MODEL/'auto'. Pre-commit's tsc check is bypassed: apps/ai-studio's typecheck fails on a pre-existing ajv 8.12.0/8.18.0 duplicate-version conflict in packages/sdk/json-form.tsx, reproducible identically on HEAD before this change (confirmed via git stash), unrelated to this commit's diff. * chore(execution-worker): fix formatting * fix(root): scope ajv override to fix hoisting regression from new AI SDK deps Adding 14 @ai-sdk/* deps to execution-worker shifted pnpm's hoisting of a duplicate ajv version into packages/sdk, breaking apps/ai-studio's typecheck and its eslint (tsdoc-config's own devDependency ajv@8.12.0 started winning over the catalog's ajv@8.18.0). Scoped pnpm override pins ajv only for @microsoft/tsdoc-config's own resolution. Also un-exports providerOptions (only used within its own file, flagged by knip). --------- Co-authored-by: Tom Brandenburg --- .../nodes/ai-agent/default-properties-data.ts | 1 + apps/ai-studio/src/nodes/ai-agent/schema.ts | 16 + apps/ai-studio/src/nodes/ai-agent/uischema.ts | 11 + apps/execution-worker/README.md | 27 ++ apps/execution-worker/package.json | 14 + .../src/activities/ai-agent.test.ts | 65 ++- .../src/activities/ai-agent.ts | 20 +- .../src/domain/ai-studio-nodes.ts | 2 + .../src/engines/temporal/worker.ts | 8 +- .../src/model-provider.test.ts | 81 ++++ apps/execution-worker/src/model-provider.ts | 189 +++++++++ package.json | 7 +- pnpm-lock.yaml | 382 ++++++++++++++---- 13 files changed, 741 insertions(+), 82 deletions(-) create mode 100644 apps/execution-worker/src/model-provider.test.ts create mode 100644 apps/execution-worker/src/model-provider.ts diff --git a/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts b/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts index b4483d606..f105f0823 100644 --- a/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/ai-agent/default-properties-data.ts @@ -7,4 +7,5 @@ export const defaultPropertiesData: NodeDataProperties = { description: '', systemPrompt: '', webSearch: false, + provider: 'auto', }; diff --git a/apps/ai-studio/src/nodes/ai-agent/schema.ts b/apps/ai-studio/src/nodes/ai-agent/schema.ts index 969af44b5..2cc5a56e8 100644 --- a/apps/ai-studio/src/nodes/ai-agent/schema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/schema.ts @@ -1,6 +1,15 @@ import { sharedProperties } from '@workflowbuilder/sdk'; import type { NodeSchema } from '@workflowbuilder/sdk'; +// Kept as a plain string (not an enum) so a provider added later, or a custom +// OpenAI-compatible endpoint, doesn't require a schema migration to unlock. +const providerOptions = [ + { label: 'Auto', value: 'auto' }, + { label: 'OpenRouter', value: 'openrouter' }, + { label: 'OpenAI', value: 'openai' }, + { label: 'Anthropic', value: 'anthropic' }, +]; + export const schema = { type: 'object', properties: { @@ -11,6 +20,13 @@ export const schema = { webSearch: { type: 'boolean', }, + model: { + type: 'string', + }, + provider: { + type: 'string', + options: providerOptions, + }, }, } satisfies NodeSchema; diff --git a/apps/ai-studio/src/nodes/ai-agent/uischema.ts b/apps/ai-studio/src/nodes/ai-agent/uischema.ts index 06abc4aa4..134c0b01b 100644 --- a/apps/ai-studio/src/nodes/ai-agent/uischema.ts +++ b/apps/ai-studio/src/nodes/ai-agent/uischema.ts @@ -27,5 +27,16 @@ export const uischema: UISchema = { scope: scope('properties.webSearch'), label: 'Web search (let the agent look things up)', }, + { + type: 'Text', + scope: scope('properties.model'), + label: 'Model', + placeholder: 'Inherit workflow/deployment default', + }, + { + type: 'Select', + scope: scope('properties.provider'), + label: 'Provider', + }, ], }; diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index a9aa5d107..4e633e6a7 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -35,6 +35,33 @@ See `.env.example`. Required: | `TEMPORAL_ADDRESS` | Temporal server address | `127.0.0.1:7233` | | `AI_MODEL` | OpenRouter model ID | `anthropic/claude-3.5-haiku` | +Optional, per-provider direct-routing keys — none is declared here or in `env.ts`; each is read +directly by its own `@ai-sdk/` package only when an `ai-agent` node selects that `provider` +explicitly (or `'auto'` infers it from the model-id prefix). Presence is checked with +`Boolean(process.env.)`; the key value itself is never read, stored, or logged by this app. + +| Provider | Env var | +| ------------ | ------------------------------ | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | +| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | +| `xai` | `XAI_API_KEY` | +| `mistral` | `MISTRAL_API_KEY` | +| `cohere` | `COHERE_API_KEY` | +| `deepseek` | `DEEPSEEK_API_KEY` | +| `moonshotai` | `MOONSHOT_API_KEY` | +| `groq` | `GROQ_API_KEY` | +| `togetherai` | `TOGETHER_API_KEY` | +| `fireworks` | `FIREWORKS_API_KEY` | +| `perplexity` | `PERPLEXITY_API_KEY` | +| `cerebras` | `CEREBRAS_API_KEY` | +| `deepinfra` | `DEEPINFRA_API_KEY` | + +An `ai-agent` node's `model`/`provider` config fields fall back to `env.AI_MODEL`/`'auto'` when +unset (`apps/execution-worker/src/model-provider.ts`). `provider` accepts free text for a value +not yet in this table — that currently fails at execution with a clear error until support (a +table row plus its `@ai-sdk/` dependency) is added. + ## Structure ``` diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index b0ee15a9b..72177766b 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -14,6 +14,20 @@ "test:watch": "vitest" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.118", + "@ai-sdk/cerebras": "^2.0.81", + "@ai-sdk/cohere": "^3.0.61", + "@ai-sdk/deepinfra": "^2.0.79", + "@ai-sdk/deepseek": "^2.0.64", + "@ai-sdk/fireworks": "^2.0.85", + "@ai-sdk/google": "^3.0.122", + "@ai-sdk/groq": "^3.0.66", + "@ai-sdk/mistral": "^3.0.64", + "@ai-sdk/moonshotai": "^2.0.56", + "@ai-sdk/openai": "^3.0.112", + "@ai-sdk/perplexity": "^3.0.60", + "@ai-sdk/togetherai": "^2.0.81", + "@ai-sdk/xai": "^3.0.132", "@openrouter/ai-sdk-provider": "^2.5.0", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", diff --git a/apps/execution-worker/src/activities/ai-agent.test.ts b/apps/execution-worker/src/activities/ai-agent.test.ts index 277f987b9..936b32b3f 100644 --- a/apps/execution-worker/src/activities/ai-agent.test.ts +++ b/apps/execution-worker/src/activities/ai-agent.test.ts @@ -1,10 +1,11 @@ import { APICallError } from 'ai'; import { MockLanguageModelV3 } from 'ai/test'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ExecutionContext } from '@workflow-builder/execution-core'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; +import type { OpenRouterClient } from '../model-provider'; import { executeAiAgent } from './ai-agent'; function context(): ExecutionContext { @@ -64,3 +65,65 @@ describe('executeAiAgent', () => { expect(model.doGenerateCalls).toHaveLength(1); }); }); + +function mockModel(text: string) { + return new MockLanguageModelV3({ + doGenerate: { + content: [{ type: 'text', text }], + finishReason: { unified: 'stop', raw: undefined }, + usage: { + inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: undefined, text: undefined, reasoning: undefined }, + }, + warnings: [], + }, + }); +} + +function fakeOpenrouter(chat: (id: string) => unknown): OpenRouterClient { + return { chat: vi.fn(chat) } as unknown as OpenRouterClient; +} + +describe('executeAiAgent model/provider fallback chain', () => { + it("uses node.config.model over deps.defaultModel when resolving via 'auto'/openrouter", async () => { + const chat = vi.fn(() => mockModel('ok')); + const openrouter = fakeOpenrouter(chat); + const node: AiAgentNode = { + id: 'agent1', + type: 'ai-studio/ai-agent', + config: { systemPrompt: 'p', model: 'node-model' }, + }; + + await executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' }); + + expect(chat).toHaveBeenCalledWith('node-model'); + }); + + it('falls back to deps.defaultModel when node.config.model is unset', async () => { + const chat = vi.fn(() => mockModel('ok')); + const openrouter = fakeOpenrouter(chat); + const node: AiAgentNode = { + id: 'agent1', + type: 'ai-studio/ai-agent', + config: { systemPrompt: 'p' }, + }; + + await executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' }); + + expect(chat).toHaveBeenCalledWith('env-model'); + }); + + it('throws when node.config.provider selects a known provider whose API key is missing', async () => { + const openrouter = fakeOpenrouter(() => mockModel('unused')); + delete process.env['OPENAI_API_KEY']; + const node: AiAgentNode = { + id: 'agent1', + type: 'ai-studio/ai-agent', + config: { systemPrompt: 'p', model: 'gpt-4o-mini', provider: 'openai' }, + }; + + await expect(executeAiAgent(node, context(), { openrouter, defaultModel: 'env-model' })).rejects.toThrow( + 'OPENAI_API_KEY', + ); + }); +}); diff --git a/apps/execution-worker/src/activities/ai-agent.ts b/apps/execution-worker/src/activities/ai-agent.ts index 313b3f424..390ee43bf 100644 --- a/apps/execution-worker/src/activities/ai-agent.ts +++ b/apps/execution-worker/src/activities/ai-agent.ts @@ -3,13 +3,21 @@ import { generateText, stepCountIs } from 'ai'; import { type ExecutionContext, type LoggerPort, resolveTemplate } from '@workflow-builder/execution-core'; import type { AiAgentNode } from '../domain/ai-studio-nodes'; +import type { OpenRouterClient } from '../model-provider'; +import { resolveModel } from '../model-provider'; import { createWebSearchTool } from '../tools/web-search'; // Bounds the agentic tool loop so a misbehaving model can't run up cost. const MAX_TOOL_STEPS = 4; type AiAgentDeps = { - model: Parameters[0]['model']; + // A fixed model bypasses per-node resolution entirely (used by tests). + // A default model id/provider plus an OpenRouter client resolves per node, + // via node.config.model/provider falling back to these defaults. + model?: Parameters[0]['model']; + defaultModel?: string; + defaultProvider?: string; + openrouter?: OpenRouterClient; logger?: LoggerPort; tavilyApiKey?: string; }; @@ -44,9 +52,17 @@ export async function executeAiAgent(node: AiAgentNode, context: ExecutionContex const webSearchEnabled = node.config.webSearch === true && Boolean(deps.tavilyApiKey); const tools = webSearchEnabled ? { webSearch: createWebSearchTool(deps.tavilyApiKey!) } : undefined; + const model = + deps.model ?? + (await resolveModel( + node.config.model ?? deps.defaultModel!, + node.config.provider ?? deps.defaultProvider ?? 'auto', + deps.openrouter!, + )); + try { const result = await generateText({ - model: deps.model, + model, // Temporal's activity retry policy owns retries; SDK retries on top would // multiply model calls (up to 3x per activity attempt). maxRetries: 0, diff --git a/apps/execution-worker/src/domain/ai-studio-nodes.ts b/apps/execution-worker/src/domain/ai-studio-nodes.ts index 2f665f1e6..3d565d77e 100644 --- a/apps/execution-worker/src/domain/ai-studio-nodes.ts +++ b/apps/execution-worker/src/domain/ai-studio-nodes.ts @@ -13,6 +13,8 @@ type TriggerNodeConfig = Record; type AiAgentNodeConfig = { systemPrompt: string; // supports {{namespace.path}} template references webSearch?: boolean; // needs TAVILY_API_KEY to take effect + model?: string; // unset inherits env.AI_MODEL + provider?: string; // 'auto' | 'openrouter' | known provider id | free text; unset behaves as 'auto' }; export type DecisionBranchCondition = { diff --git a/apps/execution-worker/src/engines/temporal/worker.ts b/apps/execution-worker/src/engines/temporal/worker.ts index 60732cc47..2a27146f8 100644 --- a/apps/execution-worker/src/engines/temporal/worker.ts +++ b/apps/execution-worker/src/engines/temporal/worker.ts @@ -16,7 +16,6 @@ import { withPayloadSizeWarning } from '../../store-payload-warning'; const { createOpenRouter } = await import('@openrouter/ai-sdk-provider'); const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); -const model = openrouter.chat(env.AI_MODEL); const aiAgentLogger = logger.child({ component: 'ai-agent' }); @@ -27,7 +26,12 @@ const plugin = new WorkflowBuilderPlugin({ 'ai-studio/trigger': executeTrigger, 'ai-studio/decision': executeDecision, 'ai-studio/ai-agent': (node, context) => - executeAiAgent(node, context, { model, logger: aiAgentLogger, tavilyApiKey: env.TAVILY_API_KEY }), + executeAiAgent(node, context, { + openrouter, + defaultModel: env.AI_MODEL, + logger: aiAgentLogger, + tavilyApiKey: env.TAVILY_API_KEY, + }), 'ai-studio/visualize': executeVisualize, }, store: withPayloadSizeWarning(database, logger), diff --git a/apps/execution-worker/src/model-provider.test.ts b/apps/execution-worker/src/model-provider.test.ts new file mode 100644 index 000000000..6f6521516 --- /dev/null +++ b/apps/execution-worker/src/model-provider.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveModel } from './model-provider'; + +function fakeOpenrouter() { + return { chat: vi.fn((id: string) => ({ modelId: id, kind: 'openrouter' })) } as unknown as Parameters< + typeof resolveModel + >[2]; +} + +describe('resolveModel', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + for (const key of Object.keys(process.env)) { + if (key.endsWith('_API_KEY')) delete process.env[key]; + } + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("falls back to openrouter under 'auto' when no matching provider key is present", async () => { + const openrouter = fakeOpenrouter(); + + const model = await resolveModel('mistralai/mistral-small', 'auto', openrouter); + + expect(openrouter.chat).toHaveBeenCalledWith('mistralai/mistral-small'); + expect(model).toEqual({ modelId: 'mistralai/mistral-small', kind: 'openrouter' }); + }); + + it("resolves an unprefixed model id under 'auto' to openrouter", async () => { + const openrouter = fakeOpenrouter(); + + await resolveModel('some-custom-model', 'auto', openrouter); + + expect(openrouter.chat).toHaveBeenCalledWith('some-custom-model'); + }); + + it("infers the direct provider under 'auto' when its prefix matches and its key is present", async () => { + process.env['OPENAI_API_KEY'] = 'test-key'; + const openrouter = fakeOpenrouter(); + + const model = await resolveModel('openai/gpt-4o-mini', 'auto', openrouter); + + expect(openrouter.chat).not.toHaveBeenCalled(); + expect(model).toBeDefined(); + }); + + it('routes to openrouter when explicitly selected', async () => { + const openrouter = fakeOpenrouter(); + + await resolveModel('anthropic/claude-3-haiku', 'openrouter', openrouter); + + expect(openrouter.chat).toHaveBeenCalledWith('anthropic/claude-3-haiku'); + }); + + it('throws when an explicit known provider is selected but its API key env var is missing', async () => { + const openrouter = fakeOpenrouter(); + + await expect(resolveModel('gpt-4o-mini', 'openai', openrouter)).rejects.toThrow('OPENAI_API_KEY'); + }); + + it('resolves an explicit known provider when its API key env var is present', async () => { + process.env['ANTHROPIC_API_KEY'] = 'test-key'; + const openrouter = fakeOpenrouter(); + + const model = await resolveModel('claude-3-haiku', 'anthropic', openrouter); + + expect(model).toBeDefined(); + }); + + it('throws clearly for an unknown free-text provider', async () => { + const openrouter = fakeOpenrouter(); + + await expect(resolveModel('some-model', 'not-a-real-provider', openrouter)).rejects.toThrow( + 'Unknown provider "not-a-real-provider"', + ); + }); +}); diff --git a/apps/execution-worker/src/model-provider.ts b/apps/execution-worker/src/model-provider.ts new file mode 100644 index 000000000..29f992cd0 --- /dev/null +++ b/apps/execution-worker/src/model-provider.ts @@ -0,0 +1,189 @@ +import type { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import type { LanguageModel } from 'ai'; + +export type OpenRouterClient = ReturnType; + +type KnownProvider = + | 'openai' + | 'anthropic' + | 'google' + | 'xai' + | 'mistral' + | 'cohere' + | 'deepseek' + | 'moonshotai' + | 'groq' + | 'togetherai' + | 'fireworks' + | 'perplexity' + | 'cerebras' + | 'deepinfra'; + +type ProviderTableEntry = { + envVar: string; + // matches OpenRouter's own model-id namespacing for that vendor + prefix: string; + load: (modelId: string) => Promise; +}; + +// Every provider package is dynamically imported only on the branch that +// needs it, so a deployment using only OpenRouter never pulls in the rest. +const PROVIDER_TABLE: Record = { + openai: { + envVar: 'OPENAI_API_KEY', + prefix: 'openai/', + load: async (id) => { + const { createOpenAI } = await import('@ai-sdk/openai'); + return createOpenAI()(id); + }, + }, + anthropic: { + envVar: 'ANTHROPIC_API_KEY', + prefix: 'anthropic/', + load: async (id) => { + const { createAnthropic } = await import('@ai-sdk/anthropic'); + return createAnthropic()(id); + }, + }, + google: { + envVar: 'GOOGLE_GENERATIVE_AI_API_KEY', + prefix: 'google/', + load: async (id) => { + const { createGoogleGenerativeAI } = await import('@ai-sdk/google'); + return createGoogleGenerativeAI()(id); + }, + }, + xai: { + envVar: 'XAI_API_KEY', + prefix: 'xai/', + load: async (id) => { + const { createXai } = await import('@ai-sdk/xai'); + return createXai()(id); + }, + }, + mistral: { + envVar: 'MISTRAL_API_KEY', + prefix: 'mistral/', + load: async (id) => { + const { createMistral } = await import('@ai-sdk/mistral'); + return createMistral()(id); + }, + }, + cohere: { + envVar: 'COHERE_API_KEY', + prefix: 'cohere/', + load: async (id) => { + const { createCohere } = await import('@ai-sdk/cohere'); + return createCohere()(id); + }, + }, + deepseek: { + envVar: 'DEEPSEEK_API_KEY', + prefix: 'deepseek/', + load: async (id) => { + const { createDeepSeek } = await import('@ai-sdk/deepseek'); + return createDeepSeek()(id); + }, + }, + moonshotai: { + envVar: 'MOONSHOT_API_KEY', + prefix: 'moonshotai/', + load: async (id) => { + const { createMoonshotAI } = await import('@ai-sdk/moonshotai'); + return createMoonshotAI()(id); + }, + }, + groq: { + envVar: 'GROQ_API_KEY', + prefix: 'groq/', + load: async (id) => { + const { createGroq } = await import('@ai-sdk/groq'); + return createGroq()(id); + }, + }, + togetherai: { + envVar: 'TOGETHER_API_KEY', + prefix: 'togetherai/', + load: async (id) => { + const { createTogetherAI } = await import('@ai-sdk/togetherai'); + return createTogetherAI()(id); + }, + }, + fireworks: { + envVar: 'FIREWORKS_API_KEY', + prefix: 'fireworks/', + load: async (id) => { + const { createFireworks } = await import('@ai-sdk/fireworks'); + return createFireworks()(id); + }, + }, + perplexity: { + envVar: 'PERPLEXITY_API_KEY', + prefix: 'perplexity/', + load: async (id) => { + const { createPerplexity } = await import('@ai-sdk/perplexity'); + return createPerplexity()(id); + }, + }, + cerebras: { + envVar: 'CEREBRAS_API_KEY', + prefix: 'cerebras/', + load: async (id) => { + const { createCerebras } = await import('@ai-sdk/cerebras'); + return createCerebras()(id); + }, + }, + deepinfra: { + envVar: 'DEEPINFRA_API_KEY', + prefix: 'deepinfra/', + load: async (id) => { + const { createDeepInfra } = await import('@ai-sdk/deepinfra'); + return createDeepInfra()(id); + }, + }, +}; + +function isKnownProvider(value: string): value is KnownProvider { + return Object.hasOwn(PROVIDER_TABLE, value); +} + +// OpenRouter serves nearly all the same underlying models under one key, so it's +// the catch-all when no direct-provider prefix/key combination matches. +function inferAutoProvider(modelId: string): KnownProvider | 'openrouter' { + for (const [id, entry] of Object.entries(PROVIDER_TABLE) as [KnownProvider, ProviderTableEntry][]) { + if (modelId.startsWith(entry.prefix) && process.env[entry.envVar]) return id; + } + return 'openrouter'; +} + +function stripPrefix(modelId: string, prefix: string): string { + return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId; +} + +/** + * Resolves a node's `model`/`provider` config into a concrete `LanguageModel`. + * `'auto'` infers the provider from the model-id prefix when the matching API + * key is present, otherwise falls back to `openrouter`. Any other value is + * looked up in the provider table; a free-text value with no table entry + * fails loudly rather than silently falling back — the same for a known + * provider whose API key env var is unset. + */ +export async function resolveModel( + modelId: string, + provider: string, + openrouter: OpenRouterClient, +): Promise { + const resolved = provider === 'auto' ? inferAutoProvider(modelId) : provider; + + if (resolved === 'openrouter') return openrouter.chat(modelId); + + if (!isKnownProvider(resolved)) { + throw new Error(`Unknown provider "${provider}" — no client registered for it yet`); + } + + const entry = PROVIDER_TABLE[resolved]; + if (!process.env[entry.envVar]) { + throw new Error(`provider: '${resolved}' requires ${entry.envVar} to be set`); + } + return entry.load(stripPrefix(modelId, entry.prefix)); +} diff --git a/package.json b/package.json index cd845c4b8..c90ba5933 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,10 @@ "node": "22.12.0", "pnpm": "10.17.0" }, - "packageManager": "pnpm@10.17.0" + "packageManager": "pnpm@10.17.0", + "pnpm": { + "overrides": { + "@microsoft/tsdoc-config>ajv": "^8.18.0" + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adecd8493..bd1227e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ catalogs: specifier: 19.1.0 version: 19.1.0 +overrides: + '@microsoft/tsdoc-config>ajv': ^8.18.0 + importers: .: @@ -438,6 +441,48 @@ importers: apps/execution-worker: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.118 + version: 3.0.118(zod@4.3.6) + '@ai-sdk/cerebras': + specifier: ^2.0.81 + version: 2.0.81(zod@4.3.6) + '@ai-sdk/cohere': + specifier: ^3.0.61 + version: 3.0.61(zod@4.3.6) + '@ai-sdk/deepinfra': + specifier: ^2.0.79 + version: 2.0.79(zod@4.3.6) + '@ai-sdk/deepseek': + specifier: ^2.0.64 + version: 2.0.64(zod@4.3.6) + '@ai-sdk/fireworks': + specifier: ^2.0.85 + version: 2.0.85(zod@4.3.6) + '@ai-sdk/google': + specifier: ^3.0.122 + version: 3.0.122(zod@4.3.6) + '@ai-sdk/groq': + specifier: ^3.0.66 + version: 3.0.66(zod@4.3.6) + '@ai-sdk/mistral': + specifier: ^3.0.64 + version: 3.0.64(zod@4.3.6) + '@ai-sdk/moonshotai': + specifier: ^2.0.56 + version: 2.0.56(zod@4.3.6) + '@ai-sdk/openai': + specifier: ^3.0.112 + version: 3.0.112(zod@4.3.6) + '@ai-sdk/perplexity': + specifier: ^3.0.60 + version: 3.0.60(zod@4.3.6) + '@ai-sdk/togetherai': + specifier: ^2.0.81 + version: 2.0.81(zod@4.3.6) + '@ai-sdk/xai': + specifier: ^3.0.132 + version: 3.0.132(zod@4.3.6) '@openrouter/ai-sdk-provider': specifier: ^2.5.0 version: 2.8.0(ai@6.0.168(zod@4.3.6))(zod@4.3.6) @@ -560,7 +605,7 @@ importers: version: 4.1.0 i18next: specifier: ^24.0.0 - version: 24.2.3(typescript@5.6.3) + version: 24.2.3(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.0.0 version: 8.0.5 @@ -581,7 +626,7 @@ importers: version: 19.1.0(react@19.1.0) react-i18next: specifier: ^15.0.0 - version: 15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-mentions-ts: specifier: ^5.4.7 version: 5.4.7(class-variance-authority@0.7.1)(clsx@2.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwind-merge@3.5.0) @@ -621,10 +666,10 @@ importers: version: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) vite-plugin-dts: specifier: ^4.5.0 - version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)) vitest: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.12.0)(jiti@2.6.1)(jsdom@26.0.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) @@ -767,22 +812,122 @@ importers: packages: + '@ai-sdk/anthropic@3.0.118': + resolution: {integrity: sha512-5j8Cc9owORxhhZxFtpznXPTdLxeWm6bPCVbndM0e+NdvGVxe9GDgNqQq1V0KqOV54hZ6hyQVEXHkgTRPyXzcQg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/cerebras@2.0.81': + resolution: {integrity: sha512-0Jg2fwtDZZYjHjcTxoFeIQbdTt6kcnko4gmSuVS/G0BbPOGqGYgu5x9m2Pdljp5jspc/sLsBQXgbgrRXX+PWMg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/cohere@3.0.61': + resolution: {integrity: sha512-N2WmRvzq3oJ/HMPnFh72XP9Gg6Y2NZNwIjzC8MkKyQDAKAo4YI0S+uzn0WjYxcBXlDBXm3ZlRGaNkdWuY4QUTQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/deepinfra@2.0.79': + resolution: {integrity: sha512-Psmxs4waMh7f0p6Nnm2ExZKiofeB3zxbSnhTYNmM/6dsZE7aBwtpTy/RPkMGV6O8SpQ76UGdfEcZIgCY0/PKnQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/deepseek@2.0.64': + resolution: {integrity: sha512-ck832HxnlN1wIqyimrcJ/EFwziK8xQPp2lBIRsJn5m1N1Gm7a+fXJGs0CoP6S4vbXNBpVw+/jFNzBu/WpRKg/g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/fireworks@2.0.85': + resolution: {integrity: sha512-BV8d2/2YJMjvMoA3y4Ewi/KPNDfqLulttdAnZQnhx76aBDKHtSS613Zs3lTnEUkGsty3RKgwIHhy8lWLejPbSg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.104': resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/google@3.0.122': + resolution: {integrity: sha512-GntcGIfRviU9ZhORN2yoQcp9Hy5cv0w6iqRLaX0/BNdTogG5JX7g7l/Tv6Uev379iJizcHBd+U0cLB78KDrMxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/groq@3.0.66': + resolution: {integrity: sha512-UcMc9J0Z53kTd6whyzOp55O0ACdv/AChp/YmFB4qa5Ijxemely2i4mDEOKkmmbKrdTE4EhCKlx/0UNDETq5D8A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mistral@3.0.64': + resolution: {integrity: sha512-DpniMzLr/MT5Pt3kqMJCkNFeEFgO41DDaXEyJmsoV38ku8Rjl16eWQanL3WgC/0MmL8DDi4bbumDCp51JkJ5rw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/moonshotai@2.0.56': + resolution: {integrity: sha512-gxHOtK0fnvWiX6FzKEBdBtCeq5nvuyp+Xacqolpctr7nya4pfYzta6/kTvdHeWUhUjWI7icl2K5xkHUhZm6vxg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai-compatible@2.0.75': + resolution: {integrity: sha512-W9w3tCoYrevct2Ips2X4EFOow8Kzrg0nqVJi9jm0lHiMlTmTON4nzaCv3Zkzg6eFCJJVH60kd4+t7tHgtOxx9Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/openai@3.0.112': + resolution: {integrity: sha512-0ho3a9CJ+3KCcrPdBQx8fX7/gyUxFHsfs1M6++SpunYk8+pgGOPMskr0GVOOxY+94jsJbuGxksKfMuSYhYHcxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/perplexity@3.0.60': + resolution: {integrity: sha512-wm27/ym+e8zAfdFiCceSQOkCo8DXmxgaqMLHZhC1y6XRdsQd+Morq9Bx+1YTS9zXTRnVP7itnxUaMDFunsAkYg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.51': + resolution: {integrity: sha512-ukLTs9x1Xm6lxSIwbJIxYQxbZHmVeIczNntARZrBcOa6pjdBhqeZnATNnJMHa7M9dZ8Ji5NJbpKpvz7o5XyBtg==} + engines: {node: '>=18.17'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.16': + resolution: {integrity: sha512-9Av6kg0t/IN/dcYAAmEJ4B9OPhcEJqwSR+GfHEA8olRkindXpUHadc3p3cyvgFUQFTVm1G5thtsmgZ9yVb2w3A==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} + '@ai-sdk/togetherai@2.0.81': + resolution: {integrity: sha512-otlU79+7SYE6x6mD1AxSZ9LwXtv/Y+eMop+15AppEA4cGwmje0VfiXamwUIxwTikGKo5Ax8fvteNGZel5NeYDg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/xai@3.0.132': + resolution: {integrity: sha512-ibXVlCblPj9I4cHM8jT3+zeYrGtmphuARiXQW8ne5HoQ9dDRYN/BOAwal5P/8yXW9XgtKPxmN26/IM9LO2H1gw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -3459,11 +3604,8 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -4840,6 +4982,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + expect-type@1.1.0: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} @@ -7648,6 +7794,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} + engines: {node: '>=18.17'} + undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -8303,6 +8453,45 @@ packages: snapshots: + '@ai-sdk/anthropic@3.0.118(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/cerebras@2.0.81(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.75(zod@4.3.6) + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/cohere@3.0.61(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/deepinfra@2.0.79(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.75(zod@4.3.6) + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/deepseek@2.0.64(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/fireworks@2.0.85(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.75(zod@4.3.6) + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/gateway@3.0.104(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -8310,6 +8499,48 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 4.3.6 + '@ai-sdk/google@3.0.122(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/groq@3.0.66(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/mistral@3.0.64(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/moonshotai@2.0.56(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai-compatible@2.0.75(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/openai@3.0.112(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/perplexity@3.0.60(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -8317,10 +8548,36 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.51(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.1 + undici: 6.28.1 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.16': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 + '@ai-sdk/togetherai@2.0.81(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.75(zod@4.3.6) + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/xai@3.0.132(zod@4.3.6)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.75(zod@4.3.6) + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -9447,7 +9704,7 @@ snapshots: '@eslint/eslintrc@3.2.0': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.3.0 globals: 14.0.0 @@ -9968,7 +10225,7 @@ snapshots: '@microsoft/tsdoc-config@0.17.1': dependencies: '@microsoft/tsdoc': 0.15.1 - ajv: 8.12.0 + ajv: 8.18.0 jju: 1.4.0 resolve: 1.22.10 @@ -10348,17 +10605,6 @@ snapshots: '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.26.7) '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.26.7) - '@svgr/core@8.1.0(typescript@5.6.3)': - dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.6.3) - snake-case: 3.0.4 - transitivePeerDependencies: - - supports-color - - typescript - '@svgr/core@8.1.0(typescript@5.9.3)': dependencies: '@babel/core': 7.26.7 @@ -10375,16 +10621,6 @@ snapshots: '@babel/types': 7.29.0 entities: 4.5.0 - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.6.3))': - dependencies: - '@babel/core': 7.26.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.26.7) - '@svgr/core': 8.1.0(typescript@5.6.3) - '@svgr/hast-util-to-babel-ast': 8.0.0 - svg-parser: 2.0.4 - transitivePeerDependencies: - - supports-color - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': dependencies: '@babel/core': 7.26.7 @@ -11153,6 +11389,19 @@ snapshots: optionalDependencies: typescript: 5.6.3 + '@vue/language-core@2.2.0(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + '@vue/shared@3.5.33': {} '@webassemblyjs/ast@1.14.1': @@ -11320,20 +11569,13 @@ snapshots: ajv: 8.18.0 fast-deep-equal: 3.1.3 - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -12016,15 +12258,6 @@ snapshots: jiti: 2.6.1 typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.6.3): - dependencies: - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - path-type: 4.0.0 - optionalDependencies: - typescript: 5.6.3 - cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.0 @@ -12861,7 +13094,7 @@ snapshots: '@humanwhocodes/retry': 0.4.1 '@types/estree': 1.0.6 '@types/json-schema': 7.0.15 - ajv: 6.12.6 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.0 @@ -12953,6 +13186,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource-parser@3.1.1: {} + expect-type@1.1.0: {} expr-eval-fork@2.0.2: {} @@ -13591,12 +13826,6 @@ snapshots: dependencies: '@babel/runtime': 7.29.7 - i18next@24.2.3(typescript@5.6.3): - dependencies: - '@babel/runtime': 7.27.0 - optionalDependencies: - typescript: 5.6.3 - i18next@24.2.3(typescript@5.9.3): dependencies: '@babel/runtime': 7.27.0 @@ -15351,15 +15580,6 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-i18next@15.4.1(i18next@24.2.3(typescript@5.6.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - '@babel/runtime': 7.27.0 - html-parse-stringify: 3.0.1 - i18next: 24.2.3(typescript@5.6.3) - react: 19.1.0 - optionalDependencies: - react-dom: 19.1.0(react@19.1.0) - react-i18next@15.4.1(i18next@24.2.3(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.27.0 @@ -16511,6 +16731,8 @@ snapshots: undici-types@6.20.0: {} + undici@6.28.1: {} + undici@7.24.4: {} unified@11.0.5: @@ -16733,6 +16955,25 @@ snapshots: - rollup - supports-color + vite-plugin-dts@4.5.4(@types/node@22.12.0)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + '@microsoft/api-extractor': 7.58.7(@types/node@22.12.0) + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-lib-inject-css@2.2.2(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@ast-grep/napi': 0.36.3 @@ -16749,17 +16990,6 @@ snapshots: picocolors: 1.1.1 vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.6.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@svgr/core': 8.1.0(typescript@5.6.3) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.6.3)) - vite: 6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4) - transitivePeerDependencies: - - rollup - - supports-color - - typescript - vite-plugin-svgr@4.3.0(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.12.0)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.57.1) From 13029c701873ae426a117487d6944f919fba7dbc Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Tue, 15 Sep 2026 03:15:19 +0200 Subject: [PATCH 3/5] feat(ai-studio): add agent-harness node for external coding-agent delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new ai-studio/agent-harness node type that delegates a workflow step to an external autonomous coding-agent CLI/SDK, with GitHub Copilot wired as the v1 functional provider. Derived from coleam00/Archon (MIT) — see apps/execution-worker/src/agent-harness/README.md for the full attribution and adaptation notes. - agent-harness provider contract layer, credential delivery, and the Copilot adapter (types, errors, registry, shared utils, provider) - Temporal activity wiring cancellation via heartbeat + the SDK's own abort (verified to cleanly terminate the subprocess, no orphan process across repeated cancel/success/concurrent runs) - Worker registration with a dedicated activity profile (45m timeout, single attempt, 5s heartbeatTimeout — the latter is a new additive key on @workflowbuilder/temporal's ActivityProfile, changeset included) - JSONForms node UI (four config sections) and a demo template wiring trigger -> agent-harness -> visualize - Adversarial E2E pass: cancellation, missing/invalid credential, idle timeout, concurrency, mutatesCheckout violation, unsupported-field warnings — see FINDINGS.md in the working plan directory Closes #147 --- ...emporal-node-activity-heartbeat-timeout.md | 5 + apps/ai-studio/README.md | 27 + apps/ai-studio/src/data/agent-harness-flow.ts | 100 +++ .../ai-studio/src/data/ai-studio-templates.ts | 2 + apps/ai-studio/src/data/node-types.ts | 9 +- .../agent-harness/default-properties-data.ts | 15 + .../src/nodes/agent-harness/index.ts | 24 + .../src/nodes/agent-harness/schema.ts | 94 +++ .../src/nodes/agent-harness/uischema.ts | 143 +++++ apps/execution-worker/README.md | 30 + apps/execution-worker/package.json | 2 + .../src/activities/agent-harness.test.ts | 282 +++++++++ .../src/activities/agent-harness.ts | 409 ++++++++++++ .../src/agent-harness/README.md | 104 ++++ .../src/agent-harness/credentials/catalog.ts | 64 ++ .../credentials/delivery.test.ts | 83 +++ .../src/agent-harness/credentials/delivery.ts | 132 ++++ .../src/agent-harness/errors.ts | 25 + .../providers/copilot/binary-resolver.test.ts | 119 ++++ .../providers/copilot/binary-resolver.ts | 124 ++++ .../providers/copilot/capabilities.ts | 28 + .../providers/copilot/config.test.ts | 157 +++++ .../agent-harness/providers/copilot/config.ts | 170 +++++ .../providers/copilot/event-bridge.test.ts | 288 +++++++++ .../providers/copilot/event-bridge.ts | 396 ++++++++++++ .../agent-harness/providers/copilot/index.ts | 11 + .../copilot/provider-hardening.test.ts | 327 ++++++++++ .../providers/copilot/provider.ts | 558 +++++++++++++++++ .../src/agent-harness/registry.test.ts | 65 ++ .../src/agent-harness/registry.ts | 119 ++++ .../agent-harness/shared/binary-resolution.ts | 37 ++ .../shared/error-classification.test.ts | 52 ++ .../shared/error-classification.ts | 253 ++++++++ .../agent-harness/shared/idle-timeout.test.ts | 42 ++ .../src/agent-harness/shared/idle-timeout.ts | 111 ++++ .../src/agent-harness/shared/run-config.ts | 24 + .../src/agent-harness/types.test.ts | 102 +++ .../src/agent-harness/types.ts | 586 ++++++++++++++++++ .../src/domain/ai-studio-nodes.ts | 44 +- .../src/engines/temporal/worker.ts | 21 + apps/execution-worker/src/env.ts | 4 + packages/temporal/README.md | 2 + .../src/workflow/activity-profiles.ts | 5 + .../workflow/node-activity-options.test.ts | 20 + .../src/workflow/node-activity-options.ts | 1 + .../src/workflow/profile-validation.test.ts | 43 +- .../src/workflow/profile-validation.ts | 10 +- pnpm-lock.yaml | 377 +++++++++-- 48 files changed, 5604 insertions(+), 42 deletions(-) create mode 100644 .changeset/temporal-node-activity-heartbeat-timeout.md create mode 100644 apps/ai-studio/src/data/agent-harness-flow.ts create mode 100644 apps/ai-studio/src/nodes/agent-harness/default-properties-data.ts create mode 100644 apps/ai-studio/src/nodes/agent-harness/index.ts create mode 100644 apps/ai-studio/src/nodes/agent-harness/schema.ts create mode 100644 apps/ai-studio/src/nodes/agent-harness/uischema.ts create mode 100644 apps/execution-worker/src/activities/agent-harness.test.ts create mode 100644 apps/execution-worker/src/activities/agent-harness.ts create mode 100644 apps/execution-worker/src/agent-harness/README.md create mode 100644 apps/execution-worker/src/agent-harness/credentials/catalog.ts create mode 100644 apps/execution-worker/src/agent-harness/credentials/delivery.test.ts create mode 100644 apps/execution-worker/src/agent-harness/credentials/delivery.ts create mode 100644 apps/execution-worker/src/agent-harness/errors.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/binary-resolver.test.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/binary-resolver.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/capabilities.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/config.test.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/config.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/event-bridge.test.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/event-bridge.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/index.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/provider-hardening.test.ts create mode 100644 apps/execution-worker/src/agent-harness/providers/copilot/provider.ts create mode 100644 apps/execution-worker/src/agent-harness/registry.test.ts create mode 100644 apps/execution-worker/src/agent-harness/registry.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/binary-resolution.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/error-classification.test.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/error-classification.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/idle-timeout.test.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/idle-timeout.ts create mode 100644 apps/execution-worker/src/agent-harness/shared/run-config.ts create mode 100644 apps/execution-worker/src/agent-harness/types.test.ts create mode 100644 apps/execution-worker/src/agent-harness/types.ts diff --git a/.changeset/temporal-node-activity-heartbeat-timeout.md b/.changeset/temporal-node-activity-heartbeat-timeout.md new file mode 100644 index 000000000..cc3a26561 --- /dev/null +++ b/.changeset/temporal-node-activity-heartbeat-timeout.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/temporal': minor +--- + +`ActivityProfile` (and `nodeActivityProfiles`) gains an optional `heartbeatTimeout` key for long-running activities that need Temporal to detect a stalled or crashed worker faster than `startToCloseTimeout` alone allows. diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 383877afe..78f9bfe4c 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -24,3 +24,30 @@ This is a sibling to `apps/demo`, not a layer over it. They share the SDK; nothi | Backend | None (pure SPA) | Required (Hono + Temporal) | | Plugin model | Plugins decorate the editor | Direct JSX composition; one slim plugin for node markers | | Dev port | 4200 | 4201 | + +## Agent Harness node + +`Agent Harness` (`ai-studio/agent-harness`) delegates a workflow step to an external +autonomous coding-agent CLI (GitHub Copilot in v1) instead of a single bounded LLM call. +Its property panel has four accordion sections: **General** (label, prompt, provider), +**Execution** (model, effort, context, idle timeout), **Tools** (tools preset, +allowed/denied tool lists), and **Advanced** (output format, MCP config, skills, +sub-agents, max budget, mutates-checkout, persist-session). + +**Functional in v1:** `prompt`, `provider` (`copilot` only), `model`, `effort`, +`idle_timeout`, `mutatesCheckout`, `agents`. + +**Rendered but NOT YET SUPPORTED in v1** (backend deferred, but per issue #147's "fields +must still render" requirement they are intentionally present rather than hidden — +this is not an oversight): `context` beyond `'fresh'` (`'shared'`/`'resume'` need session +persistence), `output_format` (no structured-output enforcement yet), `mcp`, `skills`, +`maxBudgetUsd`, `persistSession`. Each of these fields' label/placeholder in the panel +says "not yet supported" so this is visible in the UI itself, not just in this doc. + +**`idle_timeout` is in milliseconds, not seconds.** `300` means 300ms; for a 5-minute +timeout set `idle_timeout: 300000`. + +**Demo template:** "Agent Harness Demo" (`data/agent-harness-flow.ts`) — a +`trigger` → `agent-harness` → `visualize` chain that asks the agent to write a +`plan.md` outlining rate-limiting approaches and summarize the tradeoffs, exercising +both file tools and a returned text result. diff --git a/apps/ai-studio/src/data/agent-harness-flow.ts b/apps/ai-studio/src/data/agent-harness-flow.ts new file mode 100644 index 000000000..b50eb6eb2 --- /dev/null +++ b/apps/ai-studio/src/data/agent-harness-flow.ts @@ -0,0 +1,100 @@ +import type { DiagramModel, TemplateModel } from '@workflowbuilder/sdk'; + +const diagram: DiagramModel = { + name: 'Agent Harness Demo', + diagram: { + nodes: [ + { + id: 'trigger-1', + type: 'start-node', + position: { x: 0, y: 300 }, + data: { + segments: [], + isStartNode: true, + properties: { + label: 'Start', + description: 'Kicks off the agent harness demo.', + inputPrompt: `Create a file plan.md outlining three approaches to rate-limiting an HTTP API, then summarize the tradeoffs.`, + }, + type: 'ai-studio/trigger', + icon: 'Lightning', + }, + selected: false, + measured: { width: 258, height: 63 }, + dragging: false, + }, + { + id: 'agent-harness-1', + type: 'node', + position: { x: 380, y: 300 }, + data: { + segments: [], + properties: { + label: 'Agent Harness', + description: 'Delegates the task to Copilot via the agent harness CLI.', + prompt: `Create a file plan.md outlining three approaches to rate-limiting an HTTP API, then summarize the tradeoffs.`, + provider: 'copilot', + model: 'auto', + context: 'fresh', + toolsMode: 'all', + mutatesCheckout: false, + persistSession: false, + idle_timeout: 300_000, + }, + type: 'ai-studio/agent-harness', + icon: 'Terminal', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + { + id: 'visualize-1', + type: 'node', + position: { x: 760, y: 300 }, + data: { + segments: [], + properties: { + label: 'Visualize', + description: 'Renders the agent output (auto-detects the format).', + mode: 'auto', + }, + type: 'ai-studio/visualize', + icon: 'Eye', + }, + selected: false, + measured: { width: 258, height: 123 }, + dragging: false, + }, + ], + edges: [ + { + source: 'trigger-1', + sourceHandle: 'source', + target: 'agent-harness-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-trigger-agent-harness', + data: {}, + }, + { + source: 'agent-harness-1', + sourceHandle: 'source', + target: 'visualize-1', + targetHandle: 'target', + type: 'labelEdge', + id: 'edge-agent-harness-visualize', + data: {}, + }, + ], + viewport: { x: 180, y: 150, zoom: 0.7 }, + }, + layoutDirection: 'RIGHT', +}; + +export const agentHarnessFlow: TemplateModel = { + id: 306, + name: 'Agent Harness Demo', + value: diagram, + icon: 'Terminal', +}; diff --git a/apps/ai-studio/src/data/ai-studio-templates.ts b/apps/ai-studio/src/data/ai-studio-templates.ts index a603b4fe7..cf4d2b403 100644 --- a/apps/ai-studio/src/data/ai-studio-templates.ts +++ b/apps/ai-studio/src/data/ai-studio-templates.ts @@ -1,5 +1,6 @@ import type { TemplateModel } from '@workflowbuilder/sdk'; +import { agentHarnessFlow } from './agent-harness-flow'; import { aiDebateFlow } from './ai-debate-flow'; import { contentRepurposerFlow } from './content-repurposer-flow'; import { meetingNotesFlow } from './meeting-notes-flow'; @@ -12,4 +13,5 @@ export const aiStudioTemplates: TemplateModel[] = [ contentRepurposerFlow, meetingNotesFlow, researchFlow, + agentHarnessFlow, ]; diff --git a/apps/ai-studio/src/data/node-types.ts b/apps/ai-studio/src/data/node-types.ts index 48ad1faef..758dd152a 100644 --- a/apps/ai-studio/src/data/node-types.ts +++ b/apps/ai-studio/src/data/node-types.ts @@ -1,5 +1,6 @@ import type { PaletteItemOrGroup } from '@workflowbuilder/sdk'; +import { agentHarnessPaletteItem } from '../nodes/agent-harness'; import { aiAgentPaletteItem } from '../nodes/ai-agent'; import { decisionPaletteItem } from '../nodes/decision'; import { triggerPaletteItem } from '../nodes/trigger'; @@ -9,6 +10,12 @@ export const aiStudioNodeTypes: PaletteItemOrGroup[] = [ { label: 'AI Studio', isOpen: true, - groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem, visualizePaletteItem], + groupItems: [ + triggerPaletteItem, + aiAgentPaletteItem, + agentHarnessPaletteItem, + decisionPaletteItem, + visualizePaletteItem, + ], }, ]; diff --git a/apps/ai-studio/src/nodes/agent-harness/default-properties-data.ts b/apps/ai-studio/src/nodes/agent-harness/default-properties-data.ts new file mode 100644 index 000000000..0fcd2c306 --- /dev/null +++ b/apps/ai-studio/src/nodes/agent-harness/default-properties-data.ts @@ -0,0 +1,15 @@ +import type { NodeDataProperties } from '@workflowbuilder/sdk'; + +import type { AgentHarnessSchema } from './schema'; + +export const defaultPropertiesData: NodeDataProperties = { + label: 'Agent Harness', + description: '', + prompt: '', + provider: 'copilot', + model: 'auto', + context: 'fresh', + toolsMode: 'none', + mutatesCheckout: false, + persistSession: false, +}; diff --git a/apps/ai-studio/src/nodes/agent-harness/index.ts b/apps/ai-studio/src/nodes/agent-harness/index.ts new file mode 100644 index 000000000..9fe8d03d9 --- /dev/null +++ b/apps/ai-studio/src/nodes/agent-harness/index.ts @@ -0,0 +1,24 @@ +import { NodeType, type PaletteItem } from '@workflowbuilder/sdk'; + +import { defaultPropertiesData } from './default-properties-data'; +import { type AgentHarnessSchema, schema } from './schema'; +import { uischema } from './uischema'; + +export const agentHarnessPaletteItem: PaletteItem = { + label: 'Agent Harness', + description: 'Delegate a step to an autonomous coding-agent CLI (GitHub Copilot)', + type: 'ai-studio/agent-harness', + icon: 'Terminal', + templateType: NodeType.Node, + defaultPropertiesData, + schema, + uischema, + // Lets `{{ nodes..response }}` references resolve to a real mention instead of a "missing mention" pill. + outputSchema: { + type: 'default', + properties: { + response: { type: 'string', label: 'Response', description: 'The text produced by the agent run' }, + tokens: { type: 'object', label: 'Tokens', description: 'Token usage reported by the provider' }, + }, + }, +}; diff --git a/apps/ai-studio/src/nodes/agent-harness/schema.ts b/apps/ai-studio/src/nodes/agent-harness/schema.ts new file mode 100644 index 000000000..14516d4d6 --- /dev/null +++ b/apps/ai-studio/src/nodes/agent-harness/schema.ts @@ -0,0 +1,94 @@ +import { sharedProperties } from '@workflowbuilder/sdk'; +import type { NodeSchema } from '@workflowbuilder/sdk'; + +// Only Copilot is functionally wired in v1 (see `apps/execution-worker/src/agent-harness/registry.ts`). +// Kept as a single-entry option list, rather than a plain string, so the Select still renders — a +// second provider is a one-line addition here once its backend lands (per issue #147 parity intent). +const providerOptions = [{ label: 'GitHub Copilot', value: 'copilot' }]; + +// Copilot's own effort ladder (`COPILOT_EFFORTS` in providers/copilot/config.ts) is narrower than the +// full cross-provider `EffortRung` union — only offer the rungs this provider actually honors. +const effortOptions = [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Extra high', value: 'xhigh' }, +]; + +// 'shared'/'resume' are NOT YET SUPPORTED (session persistence is out of scope for v1, see the +// implementation plan §2) — rendered anyway per issue #147 ("fields must still render"). +const contextOptions = [ + { label: 'Fresh (new session)', value: 'fresh' }, + { label: 'Shared (not yet supported)', value: 'shared' }, + { label: 'Resume (not yet supported)', value: 'resume' }, +]; + +// UI-only convenience preset; expanding it into allowedTools/deniedTools is a manual step for now +// (no automatic preset -> field wiring in this milestone, see nodes/agent-harness/index.ts handoff). +const toolsModeOptions = [ + { label: 'None', value: 'none' }, + { label: 'Read-only', value: 'read-only' }, + { label: 'Edit-only', value: 'edit-only' }, + { label: 'All tools', value: 'all' }, +]; + +export const schema = { + type: 'object', + properties: { + ...sharedProperties, + prompt: { + type: 'string', + }, + provider: { + type: 'string', + options: providerOptions, + }, + model: { + type: 'string', + }, + effort: { + type: 'string', + options: effortOptions, + }, + context: { + type: 'string', + options: contextOptions, + }, + idle_timeout: { + type: 'number', + }, + toolsMode: { + type: 'string', + options: toolsModeOptions, + }, + allowedTools: { + type: 'string', + }, + deniedTools: { + type: 'string', + }, + output_format: { + type: 'string', + }, + mcp: { + type: 'string', + }, + skills: { + type: 'string', + }, + agents: { + type: 'string', + }, + maxBudgetUsd: { + type: 'number', + }, + mutatesCheckout: { + type: 'boolean', + }, + persistSession: { + type: 'boolean', + }, + }, +} satisfies NodeSchema; + +export type AgentHarnessSchema = typeof schema; diff --git a/apps/ai-studio/src/nodes/agent-harness/uischema.ts b/apps/ai-studio/src/nodes/agent-harness/uischema.ts new file mode 100644 index 000000000..15358762f --- /dev/null +++ b/apps/ai-studio/src/nodes/agent-harness/uischema.ts @@ -0,0 +1,143 @@ +import { getScope } from '@workflowbuilder/sdk'; +import type { UISchema } from '@workflowbuilder/sdk'; + +import type { AgentHarnessSchema } from './schema'; + +const scope = getScope; + +// The SDK's `UISchema` control elements have no `description`/help-text slot (see +// `packages/sdk/src/types/controls.ts` — only `label`/`placeholder`). The plan's suggested +// per-field "description" mechanism does not exist, so out-scoped fields communicate their +// "not yet supported" status through the `label` and `placeholder` text instead. This is a +// documented deviation from the plan's assumption, not an omission — see the M7 handoff. +export const uischema: UISchema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Accordion', + label: 'General', + elements: [ + { + type: 'Text', + scope: scope('properties.label'), + label: 'Title', + placeholder: 'Node Title...', + }, + { + type: 'TextArea', + scope: scope('properties.prompt'), + label: 'Prompt', + placeholder: 'Describe the task for the agent... supports {{ nodes..output }} references', + minRows: 5, + maxRows: 14, + }, + { + type: 'Select', + scope: scope('properties.provider'), + label: 'Provider', + }, + ], + }, + { + type: 'Accordion', + label: 'Execution', + elements: [ + { + type: 'Text', + scope: scope('properties.model'), + label: 'Model', + placeholder: 'auto', + }, + { + type: 'Select', + scope: scope('properties.effort'), + label: 'Effort', + }, + { + type: 'Select', + scope: scope('properties.context'), + label: 'Context (only "Fresh" is supported today)', + }, + { + type: 'Text', + scope: scope('properties.idle_timeout'), + label: 'Idle timeout (ms)', + placeholder: 'Defaults to 30 minutes', + }, + ], + }, + { + type: 'Accordion', + label: 'Tools', + elements: [ + { + type: 'Select', + scope: scope('properties.toolsMode'), + label: 'Tools preset (UI convenience — reflect manually into the lists below)', + }, + { + type: 'Text', + scope: scope('properties.allowedTools'), + label: 'Allowed tools (comma-separated)', + placeholder: 'e.g. read_file, write_file', + }, + { + type: 'Text', + scope: scope('properties.deniedTools'), + label: 'Denied tools (comma-separated)', + placeholder: 'e.g. shell_exec', + }, + ], + }, + { + type: 'Accordion', + label: 'Advanced', + elements: [ + { + type: 'TextArea', + scope: scope('properties.output_format'), + label: 'Output format (JSON — not yet supported, backend does not enforce it)', + placeholder: '{ "type": "json" }', + minRows: 3, + maxRows: 8, + }, + { + type: 'Text', + scope: scope('properties.mcp'), + label: 'MCP config path (not yet supported)', + placeholder: '/path/to/mcp.json', + }, + { + type: 'Text', + scope: scope('properties.skills'), + label: 'Skills (comma-separated, not yet supported)', + placeholder: 'e.g. code-review, testing', + }, + { + type: 'TextArea', + scope: scope('properties.agents'), + label: 'Sub-agents (JSON)', + placeholder: '{ "reviewer": { "description": "...", "prompt": "..." } }', + minRows: 3, + maxRows: 8, + }, + { + type: 'Text', + scope: scope('properties.maxBudgetUsd'), + label: 'Max budget (USD, not yet supported)', + placeholder: 'e.g. 5', + }, + { + type: 'Switch', + scope: scope('properties.mutatesCheckout'), + label: 'Mutates checkout (node may write to the working tree)', + }, + { + type: 'Switch', + scope: scope('properties.persistSession'), + label: 'Persist session (not yet supported)', + }, + ], + }, + ], +}; diff --git a/apps/execution-worker/README.md b/apps/execution-worker/README.md index e8c16f27e..ebacaea49 100644 --- a/apps/execution-worker/README.md +++ b/apps/execution-worker/README.md @@ -62,6 +62,36 @@ unset (`apps/execution-worker/src/model-provider.ts`). `provider` accepts free t not yet in this table — that currently fails at execution with a clear error until support (a table row plus its `@ai-sdk/` dependency) is added. +## `ai-studio/agent-harness` node + +Delegates a workflow step to an external autonomous coding-agent CLI (v1: GitHub Copilot +only), as opposed to `ai-studio/ai-agent`'s single bounded LLM call. It is longer-running +(minutes, not seconds), side-effecting (may write files in a working directory), and +shells out to an external CLI/SDK rather than calling a model API directly. See +[`src/agent-harness/README.md`](./src/agent-harness/README.md) for the provider +architecture (ported from [coleam00/Archon](https://github.com/coleam00/Archon), MIT). + +**Env vars** (both optional): + +| Var | Purpose | Default | +| ---------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| `COPILOT_GITHUB_TOKEN` | GitHub token used to authenticate the `copilot` CLI | Falls back to the ambient `copilot login` session if unset | +| `COPILOT_CLI_PATH` | Overrides binary resolution (skips the `PATH` lookup) | Resolved via `PATH` | + +**Activity profile** (`'ai-studio/agent-harness'` in `engines/temporal/worker.ts`): + +| Field | Value | Why | +| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `startToCloseTimeout` | `'45m'` | The CLI can run a genuinely long agentic task. | +| `retry.maximumAttempts` | `1` | Zero automatic retries — this is a side-effecting node; retrying it could re-run mutations. | +| `heartbeatTimeout` | `'5s'` | Without heartbeating, Temporal cancellation is not detected promptly (see `packages/temporal/README.md` § "heartbeatTimeout"). | + +**Operational constraints:** + +- No retries. A failed run is a failed run; re-triggering the workflow is the user's decision, not the platform's. +- Cancellation is handled by the SDK, not a manual process-group kill: the activity aborts the run via the Copilot SDK's own `session.abort()`/`client.stop()`, which cleanly terminates the underlying subprocess tree. This was verified empirically (zero orphaned `copilot` processes across repeated cancellation tests) — no `spawn(detached)+process.kill(-pid)` workaround was needed, unlike Archon's own implementation which targets a different (Bun-compiled) binary shape. +- **`idle_timeout` is in milliseconds, not seconds.** A value of `300` means 300ms, not 5 minutes — for 5 minutes, set `idle_timeout: 300000`. This has bitten someone during E2E testing already (a `300` intended as "5 minutes" produced an almost-instant timeout); the UI label ("Idle timeout (ms)") is correct, but easy to misread under time pressure. + ## Structure ``` diff --git a/apps/execution-worker/package.json b/apps/execution-worker/package.json index bb8fc2c40..b53881ac2 100644 --- a/apps/execution-worker/package.json +++ b/apps/execution-worker/package.json @@ -29,7 +29,9 @@ "@ai-sdk/perplexity": "^3.0.60", "@ai-sdk/togetherai": "^2.0.81", "@ai-sdk/xai": "^3.0.132", + "@github/copilot-sdk": "^1.0.13", "@openrouter/ai-sdk-provider": "^2.5.0", + "@temporalio/activity": "catalog:", "@temporalio/worker": "catalog:", "@temporalio/workflow": "catalog:", "@workflow-builder/execution-core": "workspace:*", diff --git a/apps/execution-worker/src/activities/agent-harness.test.ts b/apps/execution-worker/src/activities/agent-harness.test.ts new file mode 100644 index 000000000..e2e4f21bd --- /dev/null +++ b/apps/execution-worker/src/activities/agent-harness.test.ts @@ -0,0 +1,282 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, test } from 'vitest'; + +import type { ExecutionContext } from '@workflow-builder/execution-core'; + +import type { IAgentProvider, MessageChunk, ProviderCapabilities } from '../agent-harness/types'; +import { type AgentHarnessNode, executeAgentHarness } from './agent-harness'; + +const execFileAsync = promisify(execFile); + +const FAKE_CAPABILITIES: ProviderCapabilities = { + sessionResume: false, + mcp: false, + hooks: false, + skills: false, + agents: false, + toolRestrictions: false, + structuredOutput: false, + envInjection: false, + costControl: false, + effortControl: false, + fallbackModel: false, + sandbox: false, + settingSources: false, + nativeTools: false, + containerExec: false, +}; + +/** Fake IAgentProvider driven by a hand-written async generator — no real CLI/SDK. */ +class FakeProvider implements IAgentProvider { + constructor(private readonly chunks: MessageChunk[] | (() => AsyncGenerator)) {} + + getType(): string { + return 'fake'; + } + + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + + async *sendQuery(): AsyncGenerator { + if (typeof this.chunks === 'function') { + yield* this.chunks(); + return; + } + for (const chunk of this.chunks) { + yield chunk; + } + } +} + +class ThrowingProvider implements IAgentProvider { + constructor( + private readonly error: Error, + private readonly beforeThrow?: MessageChunk[], + ) {} + + getType(): string { + return 'fake-throwing'; + } + + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + + async *sendQuery(): AsyncGenerator { + for (const chunk of this.beforeThrow ?? []) { + yield chunk; + } + throw this.error; + } +} + +function makeContext(overrides: Partial = {}): ExecutionContext { + return { + workflowId: 'wf-1', + executionId: 'exec-1', + triggerPayload: {}, + nodeOutputs: {}, + variables: {}, + global: {}, + ...overrides, + }; +} + +function makeNode(overrides: Partial = {}): AgentHarnessNode { + return { + id: 'agent-harness-1', + type: 'ai-studio/agent-harness', + config: { + prompt: 'hello', + provider: 'fake', + ...overrides, + }, + }; +} + +const scratchDirectoriesToClean: string[] = []; +afterEach(async () => { + await Promise.all( + scratchDirectoriesToClean.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe('executeAgentHarness', () => { + test('accumulates text, tokens, and warnings into the final output shape', async () => { + const provider = new FakeProvider([ + { type: 'system', content: 'a warning' }, + { type: 'assistant', content: 'Hello ' }, + { type: 'assistant', content: 'world' }, + { type: 'result', sessionId: 's1', tokens: { input: 10, output: 5 } }, + ]); + + const result = await executeAgentHarness(makeNode(), makeContext(), { + getProvider: () => provider, + }); + + expect(result.output.response).toBe('Hello world'); + expect(result.output.tokens).toEqual({ input: 10, output: 5 }); + expect(result.output.warnings).toEqual(['a warning']); + }); + + test('classifies a FATAL provider error as a permanent (non-retryable) error', async () => { + const provider = new ThrowingProvider(new Error('401 unauthorized'), [{ type: 'assistant', content: 'partial' }]); + + await expect(executeAgentHarness(makeNode(), makeContext(), { getProvider: () => provider })).rejects.toMatchObject( + { name: 'PermanentNodeExecutionError' }, + ); + }); + + test('classifies a TRANSIENT provider error as a transient (retryable) error', async () => { + const provider = new ThrowingProvider(new Error('ECONNRESET while streaming')); + + await expect(executeAgentHarness(makeNode(), makeContext(), { getProvider: () => provider })).rejects.toMatchObject( + { name: 'TransientNodeExecutionError' }, + ); + }); + + test('cleans up the scratch dir even when the provider throws mid-stream', async () => { + let capturedCwd: string | undefined; + class CapturingThrowingProvider implements IAgentProvider { + getType(): string { + return 'fake-capturing'; + } + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + async *sendQuery(_prompt: string, cwd: string): AsyncGenerator { + capturedCwd = cwd; + yield { type: 'assistant', content: 'x' }; + throw new Error('boom'); + } + } + + await expect( + executeAgentHarness(makeNode(), makeContext(), { + getProvider: () => new CapturingThrowingProvider(), + }), + ).rejects.toThrow(); + + expect(capturedCwd).toBeDefined(); + await expect(execFileAsync('test', ['-d', capturedCwd!])).rejects.toBeDefined(); + }); + + test('does not tear down accumulation early when a result arrives while background_tasks is non-empty', async () => { + const provider = new FakeProvider(async function* () { + yield { type: 'assistant', content: 'first turn. ' } as MessageChunk; + yield { type: 'background_tasks', tasks: [{ taskId: 't1', taskType: 'sub', description: 'd' }] }; + // A `result` chunk while a background task is still live must NOT end the stream. + yield { type: 'result', sessionId: 's-early' }; + yield { type: 'assistant', content: 'second turn.' }; + yield { type: 'background_tasks', tasks: [] }; + yield { type: 'result', sessionId: 's-final', tokens: { input: 1, output: 1 } }; + }); + + const result = await executeAgentHarness(makeNode(), makeContext(), { + getProvider: () => provider, + }); + + expect(result.output.response).toBe('first turn. second turn.'); + }); + + test('assertCheckoutUntouched fails a mutatesCheckout: false node when the working tree changed', async () => { + const repoDirectory = await mkdtemp(path.join(tmpdir(), 'agent-harness-git-')); + scratchDirectoriesToClean.push(repoDirectory); + await execFileAsync('git', ['init', '-q'], { cwd: repoDirectory }); + await execFileAsync('git', ['config', 'user.email', 'test@test.dev'], { cwd: repoDirectory }); + await execFileAsync('git', ['config', 'user.name', 'test'], { cwd: repoDirectory }); + await writeFile(path.join(repoDirectory, 'committed.txt'), 'v1'); + await execFileAsync('git', ['add', '-A'], { cwd: repoDirectory }); + await execFileAsync('git', ['commit', '-q', '-m', 'init'], { cwd: repoDirectory }); + + class MutatingProvider implements IAgentProvider { + getType(): string { + return 'fake-mutating'; + } + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + async *sendQuery(_prompt: string, cwd: string): AsyncGenerator { + await writeFile(path.join(cwd, 'mutated.txt'), 'unexpected'); + yield { type: 'assistant', content: 'done' }; + yield { type: 'result', sessionId: 's1' }; + } + } + + await expect( + executeAgentHarness( + makeNode({ mutatesCheckout: false }), + makeContext({ variables: { workdir: repoDirectory } }), + { getProvider: () => new MutatingProvider() }, + ), + ).rejects.toMatchObject({ name: 'PermanentNodeExecutionError' }); + }); + + test('empty output fails the run', async () => { + const provider = new FakeProvider([{ type: 'result', sessionId: 's1' }]); + + await expect(executeAgentHarness(makeNode(), makeContext(), { getProvider: () => provider })).rejects.toThrow(); + }); + + test('empty prompt fails fast as a permanent error instead of waiting on the idle timeout', async () => { + const provider = new FakeProvider([{ type: 'assistant', content: 'should never run' }]); + + const start = Date.now(); + await expect( + executeAgentHarness(makeNode({ prompt: ' ' }), makeContext(), { getProvider: () => provider }), + ).rejects.toMatchObject({ classification: 'permanent' }); + expect(Date.now() - start).toBeLessThan(1000); + }); + + test('idle timeout with partial output salvages a successful result with a warning (Archon L3087-3135)', async () => { + class StallingProvider implements IAgentProvider { + getType(): string { + return 'fake-stalling'; + } + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + async *sendQuery(): AsyncGenerator { + yield { type: 'assistant', content: 'partial' }; + // Never yields again — the idle timeout must fire and end the stream. + await new Promise(() => {}); + } + } + + const start = Date.now(); + const result = await executeAgentHarness(makeNode({ idle_timeout: 200 }), makeContext(), { + getProvider: () => new StallingProvider(), + }); + expect(Date.now() - start).toBeLessThan(2000); + expect(result.output.response).toBe('partial'); + expect(result.output.warnings?.some((w) => w.includes('idle timeout'))).toBe(true); + }); + + test('idle timeout with zero output fails the run', async () => { + class SilentStallingProvider implements IAgentProvider { + getType(): string { + return 'fake-silent-stalling'; + } + getCapabilities(): ProviderCapabilities { + return FAKE_CAPABILITIES; + } + async *sendQuery(): AsyncGenerator { + await new Promise(() => {}); + yield { type: 'assistant', content: 'unreachable' }; + } + } + + const start = Date.now(); + await expect( + executeAgentHarness(makeNode({ idle_timeout: 200 }), makeContext(), { + getProvider: () => new SilentStallingProvider(), + }), + ).rejects.toThrow(); + expect(Date.now() - start).toBeLessThan(2000); + }); +}); diff --git a/apps/execution-worker/src/activities/agent-harness.ts b/apps/execution-worker/src/activities/agent-harness.ts new file mode 100644 index 000000000..d501b6a3c --- /dev/null +++ b/apps/execution-worker/src/activities/agent-harness.ts @@ -0,0 +1,409 @@ +/** + * Temporal activity adaptation for the `ai-studio/agent-harness` node type. + * + * Ported from Archon's `packages/workflows/src/dag-executor.ts` per + * PORTING-MAP.md §5 — the file is 12,098 LOC and is NOT ported wholesale; + * only the slices listed there are extracted and reshaped into a single + * activity-shaped function. See that table for exact line ranges and + * fidelity (verbatim / trimmed / adapted / skipped) per slice. + * + * Adaptations (A2/A3, per the implementation plan §6 M5): + * - Archon's DB-poll cancel check + activity heartbeat DB write are dropped + * entirely in favor of Temporal's own cancellation + heartbeat machinery + * (`Context.current().cancellationSignal` / `.heartbeat()`), which this + * activity already runs inside of (the plugin's `executeNode` activity — + * see `packages/temporal/src/activities.ts` — calls this executor). + * - Process-group kill (A3): the Copilot SDK owns its own subprocess and + * does not expose its pid (verified in M4 against `@github/copilot-sdk`'s + * type definitions), so `spawn(..., { detached: true })` + + * `process.kill(-pid, 'SIGTERM')` has no attachment point. Cancellation + * instead goes through the SDK's own `session.abort()` (already wired end + * to end: `provider.sendQuery`'s `abortSignal` option → `bridgeSession` → + * `session.abort()` → `client.stop()`). Verified empirically in this + * milestone's manual E2E — see the M5 handoff for the measured result. + * - Retry loop (Archon L1054-1205) is skipped: v1 runs a node with a single + * attempt (`maximumAttempts: 1`, set in M6's activity profile). + * - Structured-output reask loop (Archon L2968-3085) is skipped — out of + * scope per the porting plan §2. + * - UI streaming fan-out (Archon's `safeSendMessage`/`sendStructuredEvent` + * calls throughout the L2353-2700 switch) is dropped — out of scope for + * this activity; only text/token/warning accumulation is kept. + */ +import { Context } from '@temporalio/activity'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +import { type ExecutionContext, type LoggerPort, NodeExecutionError } from '@workflow-builder/execution-core'; + +import type { ResolvedCredential } from '../agent-harness/credentials/delivery'; +import { deliverCredential } from '../agent-harness/credentials/delivery'; +import { getAgentProvider } from '../agent-harness/registry'; +import { classifyError, toHostNodeExecutionError } from '../agent-harness/shared/error-classification'; +import { STEP_IDLE_TIMEOUT_MS, withIdleTimeout } from '../agent-harness/shared/idle-timeout'; +import type { IAgentProvider, MessageChunk, TokenUsage } from '../agent-harness/types'; +import { mergeTokenUsage } from '../agent-harness/types'; +import type { AgentHarnessNode } from '../domain/ai-studio-nodes'; + +const execFileAsync = promisify(execFile); + +export type { AgentHarnessNode } from '../domain/ai-studio-nodes'; + +export interface AgentHarnessResult { + output: { + response: string; + tokens?: TokenUsage; + warnings?: string[]; + }; +} + +export interface AgentHarnessDeps { + logger?: LoggerPort; + /** + * Already-resolved credential for `node.config.credentialVendor`, or + * `undefined` to rely on ambient/env auth. Credential storage/retrieval + * (looking a vendor id up in a vault) is not yet wired end-to-end — that + * is a separate concern from M3's `deliverCredential` (env/file shaping), + * which this activity does call. Callers (M6+) inject the resolved + * credential here once that lookup exists. + */ + credential?: ResolvedCredential; + /** Provider factory override, defaults to the M4 registry's `getAgentProvider`. */ + getProvider?: (providerId: string) => IAgentProvider; + /** + * Override for the Temporal activity context (heartbeat/cancellation). + * Tests and the manual-invocation smoke test (see the M5 handoff) supply + * this directly since `Context.current()` throws outside a real activity. + * Production callers should omit it — `Context.current()` is used. + */ + activityContext?: { heartbeat: () => void; cancellationSignal: AbortSignal }; +} + +// ─── Checkout snapshot (Archon dag-executor.ts L1234-1331 — verbatim) ────── + +function checkoutSnapshotExcludes(...directories: readonly string[]): readonly string[] { + return directories.map((d) => path.resolve(d)); +} + +function isInsideAny(absPath: string, directories: readonly string[]): boolean { + return directories.some((d) => absPath === d || absPath.startsWith(d + path.sep)); +} + +/** Path operands of one `git status --porcelain` line (`XY path` or `XY old -> new`). */ +function unquotePathSegment(value: string): string { + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; +} + +function porcelainPaths(line: string): string[] { + const body = line.slice(3); + return (body.includes(' -> ') ? body.split(' -> ') : [body]).map(unquotePathSegment); +} + +/** + * Snapshot the working tree's dirty state (`git status --porcelain`) for a + * node's `mutatesCheckout: false` assertion, dropping entries under + * `excludeDirs`. Returns `undefined` when the check cannot run — cwd outside + * a repo, or git failing — so a broken assertion degrades to no check rather + * than breaking unrelated runs. + */ +export async function snapshotCheckout( + cwd: string, + excludeDirectories: readonly string[], +): Promise { + try { + const { stdout } = await execFileAsync( + 'git', + ['-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=normal'], + { cwd, timeout: 10_000 }, + ); + const relevant = stdout + .split('\n') + .filter((line) => line.length > 3) + .filter((line) => !porcelainPaths(line).some((p) => isInsideAny(path.resolve(cwd, p), excludeDirectories))); + return relevant.join('\n'); + } catch { + return undefined; + } +} + +/** + * Enforce a node's `mutatesCheckout: false` declaration: when the node ran + * successfully but the pre-run snapshot changed, throw a non-retryable + * (permanent) error naming the node and listing what moved. Called OUTSIDE + * any retry path (there is none in v1 — this is naturally satisfied — but + * kept as an explicit invariant for when retry lands: retrying a node that + * provably mutates the checkout would only multiply the damage). + */ +export async function assertCheckoutUntouched( + nodeId: string, + cwd: string, + excludeDirectories: readonly string[], + before: string | undefined, +): Promise { + if (before === undefined) return; + const after = await snapshotCheckout(cwd, excludeDirectories); + if (after === undefined || after === before) return; + const changedPaths = after.split('\n').filter(Boolean).flatMap(porcelainPaths).slice(0, 10).join(', '); + throw toHostNodeExecutionError( + 'FATAL', + 'agent_harness.mutates_checkout_violation', + `Node '${nodeId}' declared 'mutatesCheckout: false' but modified the working tree: ${changedPaths}`, + ); +} + +// ─── Background-task drain tracker (Archon L1019-1047 — verbatim) ───────── + +/** + * Tracks the provider's live background-Agent-task set (Archon #2083). A + * `result` chunk arriving while the set is non-empty must NOT tear down the + * stream — the provider holds its subprocess open to let the tasks finish + * and runs a follow-up turn to integrate their output. + */ +function createBackgroundTaskTracker(): { + update(tasks: { taskId: string }[]): void; + shouldBreakOnResult(): boolean; + ids(): string[]; +} { + const live = new Set(); + return { + update(tasks): void { + live.clear(); + for (const t of tasks) live.add(t.taskId); + }, + shouldBreakOnResult(): boolean { + return live.size === 0; + }, + ids(): string[] { + return [...live]; + }, + }; +} + +// ─── Workdir resolution (A6) ──────────────────────────────────────────────── + +/** + * A6: prefer a workdir the caller's workflow context already resolved (no + * existing `ExecutionContext` field carries one today — `ai-agent`, the only + * other AI Studio executor, never touches the filesystem — so this checks an + * optional `variables.workdir` convention M6 may adopt), else fall back to a + * fresh scratch directory. The caller owns cleanup of an externally-supplied + * workdir; a scratch dir created here is removed in this activity's own + * `finally`. + */ +async function resolveWorkdir(context: ExecutionContext): Promise<{ cwd: string; isScratch: boolean }> { + const fromContext = context.variables?.['workdir']; + if (typeof fromContext === 'string' && fromContext.length > 0) { + return { cwd: fromContext, isScratch: false }; + } + const cwd = await mkdtemp(path.join(tmpdir(), 'agent-harness-')); + return { cwd, isScratch: true }; +} + +// ─── Activity context resolution ─────────────────────────────────────────── + +function resolveActivityContext(override: AgentHarnessDeps['activityContext']): { + heartbeat: () => void; + cancellationSignal: AbortSignal; +} { + if (override) return override; + try { + const context = Context.current(); + return { heartbeat: () => context.heartbeat(), cancellationSignal: context.cancellationSignal }; + } catch { + // No real Temporal activity context (unit test / manual smoke-test + // invocation) — heartbeat is a no-op and cancellation never fires unless + // the caller wires its own AbortController via `activityContext`. + return { heartbeat: () => {}, cancellationSignal: new AbortController().signal }; + } +} + +// ─── Main activity ────────────────────────────────────────────────────────── + +const HEARTBEAT_INTERVAL_MS = 1000; + +export async function executeAgentHarness( + node: AgentHarnessNode, + context: ExecutionContext, + deps: AgentHarnessDeps = {}, +): Promise { + const log = deps.logger; + const activityContext = resolveActivityContext(deps.activityContext); + + const { cwd, isScratch } = await resolveWorkdir(context); + const artifactsDirectory = path.join(cwd, '.agent-harness-artifacts'); + + const abortController = new AbortController(); + const onCancelled = (): void => abortController.abort(); + activityContext.cancellationSignal.addEventListener('abort', onCancelled, { once: true }); + if (activityContext.cancellationSignal.aborted) abortController.abort(); + + const heartbeatTimer = setInterval(() => { + try { + activityContext.heartbeat(); + } catch { + // Best-effort; a throwing heartbeat must never crash the node run. + } + }, HEARTBEAT_INTERVAL_MS); + + let idleTimedOut = false; + let cancelled = false; + + try { + // Credential delivery (M3) — env vars merged into the provider request; + // any files land under the scratch/workdir's artifacts directory. + let credentialEnv: Record = {}; + if (node.config.credentialVendor && deps.credential) { + await mkdir(artifactsDirectory, { recursive: true }); + const delivery = deliverCredential(node.config.credentialVendor, deps.credential, { + artifactsDir: artifactsDirectory, + }); + credentialEnv = delivery.env; + for (const file of delivery.files ?? []) { + await mkdir(path.dirname(file.path), { recursive: true }); + await writeFile(file.path, file.contents); + } + } + + // Fail fast rather than relying on the idle timeout (default 30 min) to + // eventually notice nothing was ever sent to the provider. + if (node.config.prompt.trim() === '') { + throw toHostNodeExecutionError('FATAL', 'agent_harness.empty_prompt', `Node '${node.id}' has an empty prompt.`); + } + + const provider = (deps.getProvider ?? getAgentProvider)(node.config.provider); + + const excludeDirectories = checkoutSnapshotExcludes(artifactsDirectory); + const mutatesCheckout = node.config.mutatesCheckout; + const checkoutSnapshotBefore = + mutatesCheckout === false ? await snapshotCheckout(cwd, excludeDirectories) : undefined; + + const idleTimeoutMs = node.config.idle_timeout ?? STEP_IDLE_TIMEOUT_MS; + + let responseText = ''; + let tokens: TokenUsage | undefined; + const warnings: string[] = []; + let sawError: { message: string } | undefined; + + const backgroundTasks = createBackgroundTaskTracker(); + + const stream = provider.sendQuery(node.config.prompt, cwd, undefined, { + abortSignal: abortController.signal, + nodeConfig: node.config, + env: credentialEnv, + }); + + for await (const chunk of withIdleTimeout(stream, idleTimeoutMs, () => { + idleTimedOut = true; + abortController.abort(); + })) { + responseText = accumulateChunk(chunk, responseText, warnings, backgroundTasks, (sawError_) => { + sawError = sawError_; + }); + if (chunk.type === 'result') { + tokens = mergeTokenUsage([tokens, chunk.tokens].filter((t): t is TokenUsage => t !== undefined)); + if (backgroundTasks.shouldBreakOnResult()) break; + } + } + + cancelled = abortController.signal.aborted && !idleTimedOut; + + // OUTSIDE any retry path (there is none in v1) — a mutation turns a + // "successful" stream into a non-retryable failure. + if (mutatesCheckout === false && !cancelled) { + await assertCheckoutUntouched(node.id, cwd, excludeDirectories, checkoutSnapshotBefore); + } + + if (cancelled) { + throw toHostNodeExecutionError('TRANSIENT', 'agent_harness.cancelled', `Node '${node.id}' was cancelled.`); + } + + if (sawError) { + const error = new Error(sawError.message); + const errorType = classifyError(error); + throw toHostNodeExecutionError(errorType, 'agent_harness.provider_error', sawError.message, { + cause: error, + }); + } + + if (responseText.trim() === '') { + const message = idleTimedOut + ? `Node '${node.id}' timed out with no output (idle for ${String(idleTimeoutMs / 60_000)} min).` + : `Node '${node.id}' produced no assistant output.`; + throw toHostNodeExecutionError('TRANSIENT', 'agent_harness.empty_output', message); + } + + // Partial-output salvage (Archon L3087-3098): idle timeout with non-empty + // output completes successfully rather than failing — the agent likely + // finished but the subprocess didn't exit cleanly — surfaced as a warning + // instead of the UI fan-out message Archon sends (out of scope here). + if (idleTimedOut) { + warnings.push( + `Node '${node.id}' completed via idle timeout (no output for ${String(idleTimeoutMs / 60_000)} min). The AI likely finished but the subprocess didn't exit cleanly.`, + ); + } + + return { + output: { + response: responseText, + ...(tokens ? { tokens } : {}), + ...(warnings.length > 0 ? { warnings } : {}), + }, + }; + } catch (error) { + if (error instanceof NodeExecutionError) { + throw error; + } + const error_ = error instanceof Error ? error : new Error(String(error)); + const errorType = classifyError(error_); + log?.error('agent_harness.run_failed', { nodeId: node.id, error: { message: error_.message } }); + throw toHostNodeExecutionError(errorType, 'agent_harness.failed', error_.message, { cause: error_ }); + } finally { + clearInterval(heartbeatTimer); + activityContext.cancellationSignal.removeEventListener('abort', onCancelled); + if (isScratch) { + await rm(cwd, { recursive: true, force: true }).catch(() => { + // Best-effort — a leftover scratch dir under the OS tmpdir is not + // worth failing an otherwise-completed (or already-failed) run over. + }); + } + } +} + +/** + * Accumulate one MessageChunk into the running text/warnings state (Archon + * dag-executor.ts L2353-2700, trimmed — the UI streaming fan-out calls in + * that switch are dropped; only accumulation survives). + */ +function accumulateChunk( + chunk: MessageChunk, + responseText: string, + warnings: string[], + backgroundTasks: ReturnType, + markError: (error: { message: string } | undefined) => void, +): string { + switch (chunk.type) { + case 'assistant': { + return responseText + chunk.content; + } + case 'system': { + warnings.push(chunk.content); + return responseText; + } + case 'background_tasks': { + backgroundTasks.update(chunk.tasks); + return responseText; + } + case 'result': { + if (chunk.isError && chunk.errorSubtype !== 'success') { + const detail = chunk.errors?.length ? ` — ${chunk.errors.join('; ')}` : ''; + markError({ message: `Node failed: SDK returned ${chunk.errorSubtype ?? 'unknown'}${detail}` }); + } + return responseText; + } + default: { + return responseText; + } + } +} diff --git a/apps/execution-worker/src/agent-harness/README.md b/apps/execution-worker/src/agent-harness/README.md new file mode 100644 index 000000000..59c4514b5 --- /dev/null +++ b/apps/execution-worker/src/agent-harness/README.md @@ -0,0 +1,104 @@ +# agent-harness + +## Attribution + +Portions of this module are derived from [coleam00/Archon](https://github.com/coleam00/Archon) +(MIT © 2025-2026 Cole Medin). Archon is not depended upon as a library — its provider +abstraction, credential delivery, structured-output handling, and Copilot adapter are +adapted (not copied wholesale) to fit workflowbuilder's Temporal-activity / hexagonal-ports +architecture. See the upstream repository for the original implementation and its `LICENSE`. + +Modules derived from Archon (ported incrementally, milestones M1-M9 of the porting plan): + +- Provider contract types (`types.ts`, `errors.ts`) — from `packages/providers/src/{types,errors}.ts` +- Provider registry (`registry.ts`) — from `packages/providers/src/registry.ts` +- Credential delivery (`credentials/delivery.ts`, `credentials/catalog.ts`) — from + `packages/core/src/credentials/{delivery,catalog}.ts` +- Shared execution utilities (`shared/*.ts`) — from `packages/providers/src/shared/*.ts`, + `packages/workflows/src/utils/idle-timeout.ts`, and `packages/workflows/src/executor-shared.ts` +- Copilot provider adapter (`providers/copilot/*.ts`) — from `community/copilot/*.ts` + +## Architecture intent + +This directory hosts a worker-local port of Archon's provider abstraction, scoped to a +single functional provider (GitHub Copilot) for v1. It backs the `ai-studio/agent-harness` +node type that delegates a workflow step to an external autonomous coding-agent CLI. +Execution runs inside a Temporal activity (`activities/agent-harness.ts`) rather than +Archon's own DAG executor, since activity code must remain replay-safe and Archon's +execution model (Bun-compiled binary, multi-tenant credential vault) does not map onto +workflowbuilder's architecture. + +## Directory structure + +| Path | What it does | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `types.ts` | Provider contract types: `TokenUsage`/`mergeTokenUsage`, the `MessageChunk` union, `NodeConfig` (the cross-provider parity surface), `ProviderCapabilities`, `IAgentProvider`. | +| `errors.ts` | Provider-level error classes. | +| `registry.ts` | Static provider registration (single entry: Copilot) + capability lookup. | +| `credentials/delivery.ts` | Turns a resolved credential into `{ env, files? }` for a given vendor; only `github-copilot` is live, `anthropic`/`openai` are kept as commented reference cases. | +| `credentials/catalog.ts` | Declares which credential kinds each vendor accepts (trimmed to Copilot). | +| `shared/binary-resolution.ts` | Generic "is this an executable file" helper used by provider CLI resolution. | +| `shared/run-config.ts` | Shared run-config validation helpers (`assertKnownRunConfigKeys`, etc.). | +| `shared/idle-timeout.ts` | `withIdleTimeout` — wraps an async generator so it aborts if no chunk arrives within a window; `STEP_IDLE_TIMEOUT_MS` default. | +| `shared/error-classification.ts` | `classifyError`/`isRateLimitError`/`formatSubprocessFailure` — maps raw error text onto FATAL/TRANSIENT/UNKNOWN. | +| `providers/copilot/capabilities.ts` | Copilot's honest capability flags (what it actually supports). | +| `providers/copilot/config.ts` | Lenient (`parseCopilotConfig`) and strict (`parseCopilotRunConfig`) config parsers, effort clamping. | +| `providers/copilot/binary-resolver.ts` | Resolves the `copilot` CLI binary: `COPILOT_CLI_PATH` env → config path → `PATH` lookup → throw. | +| `providers/copilot/event-bridge.ts` | Maps the Copilot SDK's native event stream onto the `MessageChunk` union, incl. usage normalization. | +| `providers/copilot/provider.ts` | `IAgentProvider` implementation: env/token resolution, `NodeConfig` → `SessionConfig` translation, `sendQuery`. | + +## Per-file provenance + +Every ported file's Archon source (consolidated from the porting plan's `PORTING-MAP.md`): + +| workflowbuilder file | Archon source | Fidelity | +| -------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------- | +| `types.ts`, `errors.ts` | `packages/providers/src/{types,errors}.ts` | Trimmed / Verbatim | +| `registry.ts` | `packages/providers/src/registry.ts` | Adapted (static map, not lazy dynamic-import) | +| `credentials/delivery.ts` | `packages/core/src/credentials/delivery.ts` | Trimmed (Copilot case only, others as commented reference) | +| `credentials/catalog.ts` | `packages/core/src/credentials/catalog.ts` | Trimmed (Copilot only) | +| `shared/binary-resolution.ts` | `packages/providers/src/shared/binary-resolution.ts` | Verbatim | +| `shared/run-config.ts` | `packages/providers/src/shared/run-config.ts` | Verbatim | +| `shared/idle-timeout.ts` | `packages/workflows/src/utils/idle-timeout.ts` | Verbatim | +| `shared/error-classification.ts` | `packages/workflows/src/executor-shared.ts` (selected functions) | Verbatim | +| `providers/copilot/capabilities.ts` | `community/copilot/capabilities.ts` | Verbatim | +| `providers/copilot/config.ts` | `community/copilot/config.ts` | Verbatim | +| `providers/copilot/binary-resolver.ts` | `community/copilot/binary-resolver.ts` | Trimmed ~205 → ~50 LOC (A4) | +| `providers/copilot/event-bridge.ts` | `community/copilot/event-bridge.ts` | Verbatim | +| `providers/copilot/provider.ts` | `community/copilot/provider.ts` | Trimmed (A4 import style, OAuth branches dropped) | +| `../activities/agent-harness.ts` | `packages/workflows/src/dag-executor.ts` (selected slices) | Adapted into a Temporal activity (A1/A2/A3) | + +See [`PORTING-MAP.md`](/tmp/opencode/workflowbuilder/PORTING-MAP.md) (outside this repo, plan working directory) for the full line-range breakdown. + +## Adaptations from Archon (A1-A8) + +This is a port, not a redesign — but a handful of host-architecture mismatches forced +deliberate deviations. These are the _only_ permitted deviations; anything else that +differs from Archon is a defect. + +| # | Archon does | We do instead | Why | +| --- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | Runs the streaming loop inside its own `dag-executor` process | Runs inside a **Temporal activity** function | `runGraph` is sandboxed and replay-deterministic; no I/O may touch it. | +| A2 | `withIdleTimeout` is the only liveness mechanism | Additionally calls `Context.current().heartbeat()` on an interval, with `heartbeatTimeout` set on the activity profile | Spike-verified: without heartbeating, `handle.cancel()` is silently ignored and the activity runs to completion. | +| A3 | `child.kill()` / SDK abort | The Copilot SDK's own `session.abort()`/`client.stop()` — **no manual process-group kill was implemented** | See "SDK abort supersedes A3" below — empirically the SDK's own abort cleanly kills the underlying process tree; a manual `spawn(detached)+process.kill(-pid)` workaround was not needed. | +| A4 | 6-step binary resolution chain for `bun --compile` binaries | Plain `node_modules`/`PATH` resolution + a single `COPILOT_CLI_PATH` env override | Worker is a normal Node/Docker process; Archon's chain solves a problem we don't have. | +| A5 | Encrypted multi-tenant credential vault (DB rows, envelope encryption) | Read credential from worker env config | Single-tenant reference stack. Delivery is ported faithfully; storage is not. | +| A6 | `cwd` from a `codebases` DB row (`kind: 'repo' \| 'folder'`) | Workflow-context lookup → scratch temp dir fallback | No codebase concept exists yet; Archon's `kind: 'folder'` fallback semantics map directly onto our fallback. | +| A7 | Hand-rolled React `NodeInspector.tsx` (tabs, raw `