diff --git a/package-lock.json b/package-lock.json index 1c07cd84..c249707a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,7 @@ "packages/workflow-agents" ], "dependencies": { - "tsx": "^4.20.5", - "yaml": "^2.9.0" + "tsx": "^4.20.5" }, "devDependencies": { "@types/node": "^22.10.0", @@ -501,14 +500,96 @@ } } }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/client/node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@renderinc/sdk": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-0.5.1.tgz", - "integrity": "sha512-SLWFvZaBk6DkewJo4rl8wN41QE65YXNbceB8oBvfxS76Ofssto1KKZlNPLhFfcBsvnGSAhFv6ewNgsRNDvLFgQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@renderinc/sdk/-/sdk-1.0.0.tgz", + "integrity": "sha512-upLn8LBi8j6C43FyIl33cSAIOtsAmvU2CZXNY0Y7rE0Pt8N7g8tX8MmNB10z9BZMh8WLIrwNCtwCd1dPskl45g==", "license": "MIT", "dependencies": { "eventsource": "^4.0.0", - "openapi-fetch": "^0.15.0" + "openapi-fetch": "^0.15.0", + "redis": "^5.11.0" }, "engines": { "node": ">=18.0.0" @@ -1681,6 +1762,22 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -2048,21 +2145,6 @@ "node": ">=0.4" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -2111,7 +2193,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^1.13.7", - "@renderinc/sdk": "^0.5.0", + "@renderinc/sdk": "^1.0.0", "@workshop/agent": "*", "@workshop/db": "*", "@workshop/ui": "*", diff --git a/packages/naive-agent/src/server.ts b/packages/naive-agent/src/server.ts index 70184874..69db410e 100644 --- a/packages/naive-agent/src/server.ts +++ b/packages/naive-agent/src/server.ts @@ -40,12 +40,9 @@ export function createApp(): Hono { const id = await createReview(body.prUrl, { source: 'naive-agent', workflow: 'code-review' }) const ctx = { tracer: storeTracer(), runId: id } - // ┌─────────────────────────────────────────────────────────────────────┐ - // │ BLOCKING: every `await` below holds the HTTP connection open. │ - // │ The client cannot get a response until the *entire* pipeline │ - // │ finishes — including multiple LLM round-trips. This is the core │ - // │ trade-off of Pattern 1: zero infrastructure, zero resilience. │ - // └─────────────────────────────────────────────────────────────────────┘ + // Blocking: every `await` below holds the HTTP connection open, so the + // client waits out the whole pipeline including several LLM round-trips. + // That is the Pattern 1 trade-off — no infrastructure, no resilience. try { // Step 1 — Fetch the PR diff from GitHub. This `await` blocks the request. const allPatches = await prepareDiff({ url: body.prUrl, labels: [] }) diff --git a/packages/queue-agents/src/worker.ts b/packages/queue-agents/src/worker.ts index d3e6dbbb..d9f69025 100644 --- a/packages/queue-agents/src/worker.ts +++ b/packages/queue-agents/src/worker.ts @@ -41,13 +41,10 @@ await consumeReviews( const emit = (event: ReviewEvent) => publishProgress(job.reviewId, event) const ctx = { tracer: storeTracer(), runId: job.reviewId } - // ┌─────────────────────────────────────────────────────────────────────┐ - // │ BACKGROUND: these awaits run in a queue consumer, not an HTTP │ - // │ handler. The web tier already returned 202 to the client. A slow │ - // │ PR doesn't block any request — it just takes longer in this │ - // │ worker. Compare with naive-agent (blocking) and workflow-agents │ - // │ (each step in its own isolated Render task). │ - // └─────────────────────────────────────────────────────────────────────┘ + // Background: these awaits run in a queue consumer, not an HTTP handler, + // and the web tier already returned 202. A slow PR blocks no request, it + // just takes longer here. Compare with naive-agent, which blocks, and + // workflow-agents, which runs each step in its own Render task. try { // Step 1 — Fetch the PR diff from GitHub. await emit({ type: 'phase', phase: 'prepare' }) diff --git a/packages/workflow-agents/package.json b/packages/workflow-agents/package.json index 68236c9a..c917b3a6 100644 --- a/packages/workflow-agents/package.json +++ b/packages/workflow-agents/package.json @@ -7,13 +7,13 @@ "scripts": { "typecheck": "tsc --noEmit", "dev": "RENDER_USE_LOCAL_DEV=true node --env-file-if-exists=../../.env --env-file-if-exists=.env --import tsx src/server.ts", - "dev:workflows": "RENDER_USE_LOCAL_DEV=true RENDER_LOCAL_DEV_URL=http://127.0.0.1:8120 RENDER_API_KEY=local-dev render workflows dev -- npm run dev", + "dev:workflows": "RENDER_USE_LOCAL_DEV=true RENDER_LOCAL_DEV_URL=http://127.0.0.1:8120 RENDER_API_KEY=local-dev ./scripts/dev-workflows.sh", "start": "node --import tsx src/server.ts", "start:workflow": "node --import tsx src/workflow.ts" }, "dependencies": { "@hono/node-server": "^1.13.7", - "@renderinc/sdk": "^0.5.0", + "@renderinc/sdk": "^1.0.0", "@workshop/agent": "*", "@workshop/db": "*", "@workshop/ui": "*", diff --git a/packages/workflow-agents/scripts/dev-workflows.sh b/packages/workflow-agents/scripts/dev-workflows.sh new file mode 100755 index 00000000..80049d95 --- /dev/null +++ b/packages/workflow-agents/scripts/dev-workflows.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# +# Full-fidelity local dev: the Render task server plus the gateway. +# +# `render workflows dev` re-runs its start command for every task run, so that +# each task gets a fresh process the way it gets a fresh instance in production. +# The start command therefore has to be the task-registration entry point +# (src/workflow.ts) and nothing else — pointing it at the gateway would try to +# bind port 3000 once per task run and fail with "start command exited before +# registering tasks". +# +# So the gateway runs alongside the CLI rather than under it. It reaches the +# task server over RENDER_LOCAL_DEV_URL. +set -e + +# Kill the whole process group on exit so Ctrl-C takes the gateway down too. +trap 'kill 0' EXIT INT TERM + +npm run dev & + +exec render workflows dev -- npm run start:workflow diff --git a/packages/workflow-agents/src/workflows/code-review/index.ts b/packages/workflow-agents/src/workflows/code-review/index.ts index 3d15ec58..74f7602b 100644 --- a/packages/workflow-agents/src/workflows/code-review/index.ts +++ b/packages/workflow-agents/src/workflows/code-review/index.ts @@ -17,7 +17,7 @@ * The agents themselves come from @workshop/agent — identical to the ones the * naive and queue patterns run. Only the substrate differs. */ -import { task } from "@renderinc/sdk/workflows"; +import { task, type TaskContext } from "@renderinc/sdk/workflows"; import { prepareDiff, filterDiff, @@ -30,15 +30,18 @@ import { } from "@workshop/agent"; import { storeTracer } from "@workshop/db"; -// ┌─────────────────────────────────────────────────────────────────────────┐ -// │ TASK REGISTRATION: each shared agent becomes its own Render task. │ -// │ `agent.run()` is the same call naive-agent and queue-agents make; │ -// │ wrapping it in `task()` buys isolation, retries, timeouts, and │ -// │ per-task traces in the Render Dashboard — for free. │ -// └─────────────────────────────────────────────────────────────────────────┘ +// Task registration: each shared agent becomes its own Render task, which adds +// isolation, retries, timeouts, and per-task traces around the same +// `agent.run()` call the other patterns make. +// +// Every handler takes a TaskContext first. `task()` returns a definition, not a +// callable — run it with `ctx.run(definition, ...args)`. type Patches = Array<{ file: string; diff: string }>; type Findings = Array<{ agent: string; note: string }>; -const ctx = (runId?: string) => ({ tracer: storeTracer(), ...(runId ? { runId } : {}) }); +const agentContext = (runId?: string) => ({ + tracer: storeTracer(), + ...(runId ? { runId } : {}), +}); const agentTaskOptions = { timeoutSeconds: 120, @@ -47,22 +50,26 @@ const agentTaskOptions = { const securityTask = task( { name: "security", ...agentTaskOptions }, - async (input: { patches: Patches }, runId?: string) => securityReviewer.run(input, ctx(runId)), + async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) => + securityReviewer.run(input, agentContext(runId)), ); const performanceTask = task( { name: "performance", ...agentTaskOptions }, - async (input: { patches: Patches }, runId?: string) => performanceReviewer.run(input, ctx(runId)), + async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) => + performanceReviewer.run(input, agentContext(runId)), ); const uxTask = task( { name: "ux", ...agentTaskOptions }, - async (input: { patches: Patches }, runId?: string) => uxReviewer.run(input, ctx(runId)), + async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) => + uxReviewer.run(input, agentContext(runId)), ); const judgeTask = task( { name: "judge", ...agentTaskOptions }, - async (input: { findings: Findings }, runId?: string) => judge.run(input, ctx(runId)), + async (_ctx: TaskContext, input: { findings: Findings }, runId?: string) => + judge.run(input, agentContext(runId)), ); interface CodeReviewInput { @@ -77,7 +84,7 @@ export default task( timeoutSeconds: 600, retry: { maxRetries: 2, waitDurationMs: 2000, backoffScaling: 2 }, }, - async function codeReview(input: CodeReviewInput) { + async function codeReview(ctx: TaskContext, input: CodeReviewInput) { const runId = input._runId; // Step 1 — Fetch the PR diff from GitHub. Runs in-process inside the root @@ -93,25 +100,29 @@ export default task( // times out, the others are unaffected (compare with naive-agent where a // single failure kills the entire HTTP response). const reviewerTasks = [ - { name: securityReviewer.name, run: securityTask }, - { name: performanceReviewer.name, run: performanceTask }, + { name: securityReviewer.name, definition: securityTask }, + { name: performanceReviewer.name, definition: performanceTask }, ]; if (hasFrontendFiles(patches)) { - reviewerTasks.push({ name: uxReviewer.name, run: uxTask }); + reviewerTasks.push({ name: uxReviewer.name, definition: uxTask }); } // Step 4 — Fan out in parallel. Same `Promise.all` as the other patterns, - // but each `run()` dispatches to its own Render task instance with its own - // retry budget and timeout. + // but each `ctx.run()` dispatches to its own Render task instance with its + // own retry budget and timeout. const reviewerResults = await Promise.all( - reviewerTasks.map(async ({ name, run }) => { - const result = await run({ patches }, runId); + reviewerTasks.map(async ({ name, definition }) => { + const result = await ctx.run(definition, { patches }, runId); return { agent: name, note: result.text, usage: result.usage }; }), ); // Step 5 — Judge: weigh findings and produce a verdict. Also its own task. - const decision = await judgeTask({ findings: reviewerResults.map(({ agent, note }) => ({ agent, note })) }, runId); + const decision = await ctx.run( + judgeTask, + { findings: reviewerResults.map(({ agent, note }) => ({ agent, note })) }, + runId, + ); // Step 6 — Summarize (shared helper across all three patterns). return toReviewSummary(reviewerResults, decision); diff --git a/packages/workflow-agents/src/workflows/loader.ts b/packages/workflow-agents/src/workflows/loader.ts index e3e53395..4b750970 100644 --- a/packages/workflow-agents/src/workflows/loader.ts +++ b/packages/workflow-agents/src/workflows/loader.ts @@ -1,9 +1,9 @@ /** * Auto-discover workflows from the `workflows/` directory. * - * Convention: each `workflows/{name}/index.ts` must export at least one - * function (the Render task). The folder name becomes the route name and - * the Render slug is derived as `{serviceName}/{folderName}`. + * Convention: each `workflows/{name}/index.ts` must export at least one task + * definition (the value `task()` returns). The folder name becomes the route + * name and the Render slug is derived as `{serviceName}/{folderName}`. * * Returns both the WorkflowMapping (for remote dispatch) and localTasks * (for in-process dispatch), eliminating the need for a hardcoded registry. @@ -12,18 +12,31 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import type { TaskContext, TaskDefinition } from "@renderinc/sdk/workflows"; export interface DiscoveredWorkflows { /** Maps route name → Render task slug (for production dispatch). */ mapping: Record; /** Maps route name → callable task function (for local dev dispatch). */ - localTasks: Record unknown | Promise>; + localTasks: Record Promise>; } +/** + * A TaskContext for running tasks in this process, without a Render workflow + * environment. `ctx.run` calls the target task's function directly instead of + * dispatching it to its own instance, so the whole workflow runs inline. + * Used by the in-process dev mode and by tests. + */ +export const localTaskContext: TaskContext = { + run(taskDefinition, ...args) { + return Promise.resolve(taskDefinition.func(localTaskContext, ...args)); + }, +}; + export async function loadWorkflows(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); const mapping: Record = {}; - const localTasks: Record unknown | Promise> = {}; + const localTasks: Record Promise> = {}; const workflowSlug = process.env.RENDER_WORKFLOW_SLUG?.trim(); for (const entry of entries) { @@ -33,27 +46,33 @@ export async function loadWorkflows(dir: string): Promise { const mod = await import(pathToFileURL(indexPath).href); - const taskFn = findTaskExport(mod); - if (!taskFn) continue; + const definition = findTaskExport(mod); + if (!definition) continue; mapping[name] = workflowSlug ? `${workflowSlug}/${name}` : name; - localTasks[name] = taskFn; + localTasks[name] = (input: unknown) => localTaskContext.run(definition, input); } return { mapping, localTasks }; } /** - * Find the first exported function that looks like a Render task. - * Skips type-only exports and non-function values. + * Find the first exported Render task definition. `task()` returns a frozen + * `{ name, func }` object, so skip type-only exports and plain values. */ function findTaskExport( mod: Record, -): ((input: unknown) => unknown) | undefined { - for (const key of Object.keys(mod)) { - if (typeof mod[key] === "function") { - return mod[key] as (input: unknown) => unknown; +): TaskDefinition<[unknown], unknown> | undefined { + for (const value of Object.values(mod)) { + if (isTaskDefinition(value)) { + return value as TaskDefinition<[unknown], unknown>; } } return undefined; } + +function isTaskDefinition(value: unknown): value is TaskDefinition { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return typeof candidate.name === "string" && typeof candidate.func === "function"; +} diff --git a/packages/workflow-agents/src/workflows/your-review/index.ts b/packages/workflow-agents/src/workflows/your-review/index.ts index b79c4bee..06472cc6 100644 --- a/packages/workflow-agents/src/workflows/your-review/index.ts +++ b/packages/workflow-agents/src/workflows/your-review/index.ts @@ -9,7 +9,7 @@ * a `task()` for isolation and retries, and called from the root workflow. This * is the minimum viable agent-in-a-workflow — modify it freely. */ -import { task } from "@renderinc/sdk/workflows"; +import { task, type TaskContext } from "@renderinc/sdk/workflows"; import { defineAgent, prepareDiff, @@ -18,15 +18,9 @@ import { } from "@workshop/agent"; import { storeTracer } from "@workshop/db"; -// ┌─────────────────────────────────────────────────────────────────────────┐ -// │ YOUR AGENT — defined inline with defineAgent(). │ -// │ │ -// │ Try changing: │ -// │ • The systemPrompt — focus on docs, naming, error handling, etc. │ -// │ • The model tier — 'small' is fast/cheap, 'large' is thorough │ -// │ • The tools — try 'scan_for_secrets', 'contrast_ratio', or add │ -// │ your own in shared/agent/src/tools/ │ -// └─────────────────────────────────────────────────────────────────────────┘ +// Your agent, defined inline. Try changing the systemPrompt, the model tier +// ('small' is fast and cheap, 'large' is thorough), or the tools — add your own +// in shared/agent/src/tools/. const myReviewer = defineAgent({ name: "my-reviewer", model: resolveModelSpec("medium"), @@ -55,17 +49,12 @@ Return a short list of findings. Each finding has: If you find nothing, say so explicitly.`, }); -// ┌─────────────────────────────────────────────────────────────────────────┐ -// │ TASK WRAPPING — same pattern as code-review/index.ts. │ -// │ │ -// │ Wrapping the agent in task() gives you: │ -// │ • Isolation — runs in its own Render instance │ -// │ • Retries — transient LLM failures retry automatically │ -// │ • Traces — appears in the Render Dashboard with duration + logs │ -// │ │ -// │ Try: add retry config, change the timeout, or force a failure: │ -// │ if (Math.random() < 0.5) throw new Error("flaky!"); │ -// └─────────────────────────────────────────────────────────────────────────┘ +// Task wrapping, the same pattern as code-review/index.ts. Wrapping the agent in +// task() gives it its own Render instance, automatic retries on transient LLM +// failures, and a Dashboard trace with duration and logs. +// +// Every handler takes a TaskContext first, then its own inputs. task() returns a +// definition — run it with ctx.run(definition, ...args). type Patches = Array<{ file: string; diff: string }>; const myReviewerTask = task( @@ -74,7 +63,7 @@ const myReviewerTask = task( timeoutSeconds: 120, retry: { maxRetries: 2, waitDurationMs: 1000, backoffScaling: 2 }, }, - async (input: { patches: Patches }, runId?: string) => { + async (_ctx: TaskContext, input: { patches: Patches }, runId?: string) => { return myReviewer.run(input, { tracer: storeTracer(), ...(runId ? { runId } : {}), @@ -93,7 +82,7 @@ export default task( timeoutSeconds: 300, retry: { maxRetries: 2, waitDurationMs: 2000, backoffScaling: 2 }, }, - async function yourReview(input: YourReviewInput) { + async function yourReview(ctx: TaskContext, input: YourReviewInput) { const runId = input._runId; // Step 1 — Fetch the PR diff from GitHub. @@ -103,7 +92,7 @@ export default task( const { patches } = filterDiff(allPatches); // Step 3 — Run your custom agent as its own Render task. - const result = await myReviewerTask({ patches }, runId); + const result = await ctx.run(myReviewerTask, { patches }, runId); // Return a result the gateway can persist. Including `verdict` and `reviews` // tells the server to use the standard persistReview path — same as @@ -127,8 +116,8 @@ export default task( // task(), and fan out both with Promise.all: // // const [clarity, errors] = await Promise.all([ -// myReviewerTask({ patches }, runId), -// errorHandlingTask({ patches }, runId), +// ctx.run(myReviewerTask, { patches }, runId), +// ctx.run(errorHandlingTask, { patches }, runId), // ]); // // 3. ADD A JUDGE — import `judge` from @workshop/agent and wire it after the @@ -137,9 +126,9 @@ export default task( // import { judge } from "@workshop/agent"; // const judgeTask = task( // { name: "judge", timeoutSeconds: 120 }, -// async (input, runId?) => judge.run(input, { tracer: storeTracer(), runId }), +// async (_ctx, input, runId?) => judge.run(input, { tracer: storeTracer(), runId }), // ); -// const decision = await judgeTask({ findings }, runId); +// const decision = await ctx.run(judgeTask, { findings }, runId); // // 4. FORCE A FAILURE — uncomment the line below inside the task to watch // Render retry in a fresh instance (then remove it): diff --git a/tests/integration/agentTask.test.ts b/tests/integration/agentTask.test.ts index 8378d224..0e1ac2a3 100644 --- a/tests/integration/agentTask.test.ts +++ b/tests/integration/agentTask.test.ts @@ -1,31 +1,41 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { task } from '@renderinc/sdk/workflows' +import type { TaskContext } from '@renderinc/sdk/workflows' import { securityReviewer } from '@workshop/agent' import { storeTracer } from '@workshop/db' +import { localTaskContext } from '../../packages/workflow-agents/src/workflows/loader.js' -test('an agent wrapped in task() runs in-process outside a workflow context', async () => { - const securityTask = task( - { name: 'security' }, - async (input: { patches: Array<{ file: string; diff: string }> }, runId?: string) => { - return securityReviewer.run(input, { tracer: storeTracer(), runId }) - }, - ) - assert.equal(typeof securityTask, 'function') +const securityTask = task( + { name: 'security' }, + async ( + _ctx: TaskContext, + input: { patches: Array<{ file: string; diff: string }> }, + runId?: string, + ) => { + return securityReviewer.run(input, { tracer: storeTracer(), runId }) + }, +) + +test('task() returns a definition naming the task and holding its function', () => { + assert.equal(securityTask.name, 'security') + assert.equal(typeof securityTask.func, 'function') +}) - const result = await securityTask({ patches: [{ file: 'a.ts', diff: '+x' }] }) +test('an agent wrapped in task() runs in-process against a supplied context', async () => { + const result = await localTaskContext.run(securityTask, { + patches: [{ file: 'a.ts', diff: '+x' }], + }) assert.equal(typeof result.text, 'string') assert.ok(result.text.length > 0) assert.equal(typeof result.usage.inputTokens, 'number') }) test('an agent task accepts an optional runId for span correlation', async () => { - const securityTask = task( - { name: 'security' }, - async (input: { patches: Array<{ file: string; diff: string }> }, runId?: string) => { - return securityReviewer.run(input, { tracer: storeTracer(), runId }) - }, + const result = await localTaskContext.run( + securityTask, + { patches: [{ file: 'a.ts', diff: '+x' }] }, + 'test-run-id', ) - const result = await securityTask({ patches: [{ file: 'a.ts', diff: '+x' }] }, 'test-run-id') assert.equal(typeof result.text, 'string') })