diff --git a/.knowledge/adapter.md b/.knowledge/adapter.md index db124b0..fe6baf4 100644 --- a/.knowledge/adapter.md +++ b/.knowledge/adapter.md @@ -5,10 +5,10 @@ Adapter = capability. Role = scenario. Workflows declare adapters directly via i ## AgentFn Protocol ```ts -type AgentFn = (prompt: string, context: WorkflowContext) => Promise +type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise ``` -- Input: prompt + context (start frame, messages, workdir, AbortSignal) +- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity) - Output: **single-shot `Promise`** — no streaming support - Adapter handles tool-specific details internally diff --git a/.knowledge/coding-conventions.md b/.knowledge/coding-conventions.md index f037779..7514490 100644 --- a/.knowledge/coding-conventions.md +++ b/.knowledge/coding-conventions.md @@ -57,7 +57,7 @@ No compiler enforcement - relies on manual discipline and TypeScript's flow cont **Primary exports** use descriptive, unambiguous names: - Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`) -- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `WorkflowContext`) +- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`) - Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_SENSE_SIGNAL_RETENTION`, `CURSOR_ADAPTER_DEFAULT_MS`) **Avoiding ambiguity**: diff --git a/.knowledge/workflow.md b/.knowledge/workflow.md index f9d0d50..1ec8e95 100644 --- a/.knowledge/workflow.md +++ b/.knowledge/workflow.md @@ -6,8 +6,8 @@ Stateful multi-step execution driven by Roles and a Moderator. - **Workflow** — definition with concurrency strategy - **Thread** — one execution instance, unique `runId` -- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }` -- **Moderator** — pure routing function. `(context) → next role | END` +- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise>` +- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END` ## Thread Lifecycle @@ -54,7 +54,7 @@ const workflow: WorkflowDefinition = { ``` - `adapter: AgentFn` — direct function reference -- `prompt: string | ((start, messages) => Promise)` — static or dynamic +- `prompt: string | ((ctx: ThreadContext) => Promise)` — static or dynamic - `meta: z.ZodType` — Zod schema, directly (no wrapper needed) - `extract: LlmExtractorConfig` — provider for structured extraction @@ -85,7 +85,7 @@ const workflow: WorkflowDefinition = { - Pure function constraint: cannot perform side effects **Causal Chain Integrity**: -- Moderator receives immutable history: `{ start, steps }` +- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }` - Steps array contains ALL role outputs in chronological order - No role can modify prior steps or start metadata - Thread context built from log store on crash recovery diff --git a/CLAUDE.md b/CLAUDE.md index 5c203e0..0c1fe51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,31 @@ type ComputeResult = | { signal: T; workflow: WorkflowTrigger | null }; ``` +### Workflow authoring (user modules) + +Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays. + +```typescript +import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core"; +import { END } from "@uncaged/nerve-core"; + +type MyMeta = { round: number }; + +async function planner(ctx: ThreadContext): Promise> { + void ctx.start; + void ctx.steps; + return { content: "plan", meta: { round: ctx.steps.length } }; +} + +const workflow: WorkflowDefinition> = { + name: "example", + roles: { planner }, + moderator(ctx: ThreadContext>) { + return ctx.steps.length === 0 ? "planner" : END; + }, +}; +``` + ## Modules & Exports - Always named exports, never default exports diff --git a/packages/adapter-cursor/src/index.ts b/packages/adapter-cursor/src/index.ts index 649e65d..a9bf382 100644 --- a/packages/adapter-cursor/src/index.ts +++ b/packages/adapter-cursor/src/index.ts @@ -1,4 +1,4 @@ -import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; +import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core"; import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core"; export type CursorAgentMode = "plan" | "ask" | "default"; @@ -96,16 +96,16 @@ export function createCursorAdapter(config: CursorAdapterConfig): AgentFn { const timeoutMs = config.timeout; const mode = config.mode ?? "default"; - return async (prompt: string, context: WorkflowContext): Promise => { + return async (_ctx: ThreadContext, prompt: string): Promise => { const run = await cursorAgent({ prompt, mode, model: config.model, - cwd: context.workdir, + cwd: process.cwd(), env: null, timeoutMs, - dryRun: context.start.meta.dryRun, - abortSignal: context.signal, + dryRun: false, + abortSignal: null, }); if (!run.ok) { throwCursorSpawnError(run.error); diff --git a/packages/adapter-hermes/src/index.ts b/packages/adapter-hermes/src/index.ts index 03fa9ad..085c85a 100644 --- a/packages/adapter-hermes/src/index.ts +++ b/packages/adapter-hermes/src/index.ts @@ -1,4 +1,4 @@ -import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; +import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core"; import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core"; /** @@ -96,7 +96,7 @@ export function createHermesAdapter(config: AgentConfig): AgentFn { const modelFromConfig = config.model === "auto" ? null : config.model; const timeoutMs = config.timeout; - return async (prompt: string, context: WorkflowContext): Promise => { + return async (_ctx: ThreadContext, prompt: string): Promise => { const run = await hermesAgent({ prompt, model: modelFromConfig, @@ -106,8 +106,8 @@ export function createHermesAdapter(config: AgentConfig): AgentFn { maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS, env: null, timeoutMs, - dryRun: context.start.meta.dryRun, - abortSignal: context.signal, + dryRun: false, + abortSignal: null, }); if (!run.ok) { throwHermesSpawnError(run.error); diff --git a/packages/cli/src/__tests__/create-workflow.test.ts b/packages/cli/src/__tests__/create-workflow.test.ts index d1593b1..212c654 100644 --- a/packages/cli/src/__tests__/create-workflow.test.ts +++ b/packages/cli/src/__tests__/create-workflow.test.ts @@ -33,9 +33,11 @@ describe("buildWorkflowScaffold", () => { expect(indexTs).toContain("@uncaged/nerve-core"); }); - it("root index wires moderator and END", () => { + it("root index wires moderator with ThreadContext and END", () => { const { indexTs } = buildWorkflowScaffold("test"); expect(indexTs).toContain("moderator"); + expect(indexTs).toContain("ThreadContext"); + expect(indexTs).toContain("ctx.steps.length"); expect(indexTs).toContain("END"); }); @@ -46,9 +48,13 @@ describe("buildWorkflowScaffold", () => { expect(indexTs).toContain("./roles/main/index.js"); }); - it("main role module exports mainRole function", () => { + it("main role module exports mainRole with ThreadContext", () => { const { roleMainIndexTs } = buildWorkflowScaffold("test"); expect(roleMainIndexTs).toContain("export async function mainRole"); + expect(roleMainIndexTs).toContain("ThreadContext"); + expect(roleMainIndexTs).toContain("RoleResult"); + expect(roleMainIndexTs).not.toContain("StartStep"); + expect(roleMainIndexTs).not.toContain("WorkflowMessage"); }); it("uses different names per call", () => { diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index c8685a2..0db440e 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -52,7 +52,7 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles { } function buildWorkflowIndexTs(name: string): string { - return `import type { WorkflowDefinition } from "@uncaged/nerve-core"; + return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core"; import { mainRole } from "./roles/main/index.js"; @@ -64,8 +64,8 @@ const workflow: WorkflowDefinition> = { roles: { main: mainRole, }, - moderator({ steps }) { - if (steps.length === 0) { + moderator(ctx: ThreadContext>) { + if (ctx.steps.length === 0) { return "main"; } return END; @@ -77,18 +77,16 @@ export default workflow; } function buildWorkflowMainRoleIndexTs(name: string): string { - return `import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; + return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core"; /** * Main role — implement LLM calls, scripts, HTTP, etc. * Optional: align behavior with \`prompt.md\` in this directory. */ export async function mainRole( - start: StartStep, - messages: WorkflowMessage[], + ctx: ThreadContext, ): Promise>> { - void start; - void messages; + void ctx; // TODO: implement your role logic here return { content: "${name} started", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f18cdab..7eec532 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export type { Role, RoleMeta, StartStep, + ThreadContext, WorkflowContext, AgentFn, RoleStep, diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 3b1fb0a..f66a170 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -21,39 +21,41 @@ export type WorkflowMessage = { /** The typed output of a Role execution. */ export type RoleResult = { content: string; meta: Meta }; -/** - * A Role is a pure async function: receives the engine start frame plus prior - * role messages only (the start frame is not included in `messages`). - * Returns typed content + meta. Implementation can be an agent, LLM call, - * script, HTTP request, etc. - */ -export type Role = ( - start: StartStep, - messages: WorkflowMessage[], -) => Promise>; - /** Maps role names to their meta types — the single generic that drives all inference. */ export type RoleMeta = Record>; -/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */ +/** Engine start frame: initial prompt, max rounds cap, and thread identity. */ export type StartStep = { role: START; content: string; /** Thread identity (same as workflow `runId`); for role prompts and CLI `nerve thread` context. */ - meta: { maxRounds: number; dryRun: boolean; threadId: string }; + meta: { maxRounds: number; threadId: string }; timestamp: number; }; -/** Thread context passed to agent adapters (RFC-003): conversation frame, repo root, cancellation. */ -export type WorkflowContext = { +/** + * Thread-scoped context for roles, moderator, and agent adapters (RFC-005). + * `workdir` and `signal` are adapter/engine config — not part of this shape. + */ +export type ThreadContext = { + threadId: string; start: StartStep; - messages: WorkflowMessage[]; - workdir: string; - signal: AbortSignal; + steps: RoleStep[]; }; +/** + * @deprecated Use {@link ThreadContext}. + */ +export type WorkflowContext = ThreadContext; + +/** + * A Role receives the full thread context (start frame + prior role steps) and returns + * typed content + meta. Implementation can be an agent, LLM call, script, HTTP request, etc. + */ +export type Role = (ctx: ThreadContext) => Promise>; + /** Unified agent invocation — raw string output; structured meta uses the extract layer. */ -export type AgentFn = (prompt: string, context: WorkflowContext) => Promise; +export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise; /** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */ export type RoleStep = { @@ -61,23 +63,17 @@ export type RoleStep = { }[keyof M & string]; /** - * Moderator input: the complete workflow history. - * Contains the start frame and all role steps so far. - * On initial call, `steps` is empty — moderator can check `steps.length === 0`. - * Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`. + * @deprecated Use {@link ThreadContext}. */ -export type ModeratorContext = { - start: StartStep; - steps: RoleStep[]; -}; +export type ModeratorContext = ThreadContext; /** - * The moderator — a pure routing function. Receives the full workflow context - * (start frame + all prior steps). Returns the next role name or END. + * The moderator — a pure routing function. Receives the full thread context + * (start frame + all prior steps). On initial call, `steps` is empty. + * Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`. + * Returns the next role name or END. */ -export type Moderator = ( - context: ModeratorContext, -) => (keyof M & string) | END; +export type Moderator = (ctx: ThreadContext) => (keyof M & string) | END; /** The complete definition of a workflow, as authored by users. */ export type WorkflowDefinition = { diff --git a/packages/daemon/src/agent-adapters/echo.ts b/packages/daemon/src/agent-adapters/echo.ts index 357992b..5a21a84 100644 --- a/packages/daemon/src/agent-adapters/echo.ts +++ b/packages/daemon/src/agent-adapters/echo.ts @@ -1,9 +1,9 @@ -import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; +import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core"; /** * Echo adapter (`type: "echo"`) — returns the assembled prompt unchanged. * Used for tests and dry-run wiring before real adapters exist. */ export function createEchoAgent(_config: AgentConfig): AgentFn { - return async (prompt: string, _context: WorkflowContext) => prompt; + return async (_ctx: ThreadContext, prompt: string) => prompt; } diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index ffd8ce9..f0ef447 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -127,7 +127,6 @@ function ensureThreadMessagesWithStart( threadId: string, fallbackPrompt: string, fallbackMaxRounds: number, - fallbackDryRun: boolean, ): WorkflowMessage[] { const mapped: WorkflowMessage[] = messages.map((m) => ({ role: m.role, @@ -141,7 +140,7 @@ function ensureThreadMessagesWithStart( const start: WorkflowMessage = { role: START, content: fallbackPrompt, - meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun, threadId }, + meta: { maxRounds: fallbackMaxRounds, threadId }, timestamp: Date.now(), }; return [start, ...mapped]; @@ -408,7 +407,6 @@ export function createWorkflowManager( runId, launch.prompt, launch.maxRounds, - launch.dryRun, ); state.active.add(runId); const msg: ResumeThreadMessage = { diff --git a/packages/daemon/src/workflow-worker.ts b/packages/daemon/src/workflow-worker.ts index 17e8d93..212c9c3 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/daemon/src/workflow-worker.ts @@ -14,7 +14,14 @@ import "./experimental-warning-suppression.js"; import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; -import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core"; +import type { + RoleMeta, + RoleStep, + StartStep, + ThreadContext, + WorkflowDefinition, + WorkflowMessage, +} from "@uncaged/nerve-core"; import { END, START, isPlainRecord } from "@uncaged/nerve-core"; import type { @@ -87,15 +94,14 @@ function normalizeStartMeta( threadIdFallback: string, ): StartStep["meta"] { if (!isPlainRecord(meta)) { - return { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback }; + return { maxRounds: maxRoundsFallback, threadId: threadIdFallback }; } const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback; - const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false; const threadId = typeof meta.threadId === "string" && meta.threadId.length > 0 ? meta.threadId : threadIdFallback; - return { maxRounds, dryRun, threadId }; + return { maxRounds, threadId }; } function startStepFromWorkflowMessage( @@ -107,7 +113,7 @@ function startStepFromWorkflowMessage( return { role: START, content: "", - meta: { maxRounds: maxRoundsFallback, dryRun: false, threadId: threadIdFallback }, + meta: { maxRounds: maxRoundsFallback, threadId: threadIdFallback }, timestamp: Date.now(), }; } @@ -124,8 +130,30 @@ type ThreadMessagesState = { start: StartStep; /** Role outputs only; never includes the `__start__` frame. */ messages: WorkflowMessage[]; + /** From IPC (`start-thread` / `resume-thread`); not part of `StartStep.meta`. */ + dryRun: boolean; }; +function workflowMessagesToRoleSteps(messages: WorkflowMessage[]): RoleStep[] { + return messages.map((m) => ({ + role: m.role, + meta: m.meta as Record, + content: m.content, + timestamp: m.timestamp, + })) as RoleStep[]; +} + +function buildThreadContext( + start: StartStep, + roleMessages: WorkflowMessage[], +): ThreadContext { + return { + threadId: start.meta.threadId, + start, + steps: workflowMessagesToRoleSteps(roleMessages), + }; +} + function initThreadMessages( runId: string, resumeMessages: WorkflowMessage[], @@ -139,6 +167,7 @@ function initThreadMessages( return { start: startStepFromWorkflowMessage(first, maxRounds, runId), messages: [...rest], + dryRun, }; } const prompt = freshPrompt ?? ""; @@ -146,17 +175,18 @@ function initThreadMessages( start: { role: START, content: prompt, - meta: { maxRounds, dryRun, threadId: runId }, + meta: { maxRounds, threadId: runId }, timestamp: Date.now(), }, messages: [...resumeMessages], + dryRun, }; } const prompt = freshPrompt ?? ""; const start: StartStep = { role: START, content: prompt, - meta: { maxRounds, dryRun, threadId: runId }, + meta: { maxRounds, threadId: runId }, timestamp: Date.now(), }; sendWorkflowMessage(runId, { @@ -165,14 +195,13 @@ function initThreadMessages( meta: start.meta, timestamp: start.timestamp, }); - return { start, messages: [] }; + return { start, messages: [], dryRun }; } async function executeRole( def: WorkflowDefinition, nextRole: string, - start: StartStep, - messages: WorkflowMessage[], + ctx: ThreadContext, runId: string, ): Promise<{ content: string; meta: Record } | null> { const role = def.roles[nextRole]; @@ -183,7 +212,7 @@ async function executeRole( let result: { content: string; meta: Record }; try { - result = await role(start, messages); + result = await role(ctx); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 }); @@ -213,37 +242,20 @@ async function runThread( dryRun, ); - const steps: Array<{ - role: string; - meta: Record; - content: string; - timestamp: number; - }> = []; - - // Rebuild steps from any resumed messages - for (const msg of roleMessages) { - steps.push({ - role: msg.role, - meta: msg.meta as Record, - content: msg.content, - timestamp: msg.timestamp, - }); - } - if (killFlag.value) { sendThreadEvent(runId, "killed", { exitCode: 137 }); return; } - let nextRole = def.moderator({ start, steps }); + let nextRole = def.moderator(buildThreadContext(start, roleMessages)); if (nextRole === END) { sendThreadEvent(runId, "completed", { exitCode: 0 }); return; } - while (steps.length < maxRounds) { - const result = await executeRole(def, nextRole, start, roleMessages, runId); + while (roleMessages.length < maxRounds) { + const result = await executeRole(def, nextRole, buildThreadContext(start, roleMessages), runId); if (killFlag.value) { sendThreadEvent(runId, "killed", { exitCode: 137 }); @@ -261,14 +273,7 @@ async function runThread( roleMessages.push(message); sendWorkflowMessage(runId, message); - steps.push({ - role: nextRole, - meta: result.meta, - content: result.content, - timestamp: message.timestamp, - }); - - nextRole = def.moderator({ start, steps }); + nextRole = def.moderator(buildThreadContext(start, roleMessages)); if (nextRole === END) { sendThreadEvent(runId, "completed", { exitCode: 0 }); diff --git a/packages/role-committer/src/index.ts b/packages/role-committer/src/index.ts index d2a084e..6c39e84 100644 --- a/packages/role-committer/src/index.ts +++ b/packages/role-committer/src/index.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -43,13 +43,17 @@ export function createCommitterRole( ): Role { const inner = createRole( adapter, - async (start: StartStep) => committerPrompt(start.meta.threadId), + async (ctx: ThreadContext) => committerPrompt(ctx.threadId), committerMetaSchema, extract, ); return decorateRole(inner, [ - withDryRun({ label: "committer", meta: { committed: true } as CommitterMeta }), + withDryRun({ + label: "committer", + meta: { committed: true } as CommitterMeta, + dryRun: extract.dryRun === true, + }), onFail({ label: "committer", meta: { committed: false } as CommitterMeta }), ]) as Role; } diff --git a/packages/role-reviewer/src/reviewer.ts b/packages/role-reviewer/src/reviewer.ts index 6b7e512..449c6a4 100644 --- a/packages/role-reviewer/src/reviewer.ts +++ b/packages/role-reviewer/src/reviewer.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -84,7 +84,7 @@ export function createReviewerRole( const resolved: ReviewerConfig = { ...defaults, ...config }; return createRole( adapter, - async (start: StartStep) => reviewerPrompt({ threadId: start.meta.threadId, config: resolved }), + async (ctx: ThreadContext) => reviewerPrompt({ threadId: ctx.threadId, config: resolved }), reviewerMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-sense/roles/coder.ts b/packages/workflow-meta/src/develop-sense/roles/coder.ts index 4bee332..88a7175 100644 --- a/packages/workflow-meta/src/develop-sense/roles/coder.ts +++ b/packages/workflow-meta/src/develop-sense/roles/coder.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -43,7 +43,7 @@ Return \`done: false\` if you made progress but there is still work to do.`; export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role { return createRole( adapter, - async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }), + async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }), coderMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-sense/roles/planner.ts b/packages/workflow-meta/src/develop-sense/roles/planner.ts index 7c47f8c..f9f8715 100644 --- a/packages/workflow-meta/src/develop-sense/roles/planner.ts +++ b/packages/workflow-meta/src/develop-sense/roles/planner.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -32,7 +32,7 @@ export function createPlannerRole( ): Role { return createRole( adapter, - async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }), + async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }), plannerMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-sense/roles/tester.ts b/packages/workflow-meta/src/develop-sense/roles/tester.ts index 6f8b81b..4ca663a 100644 --- a/packages/workflow-meta/src/develop-sense/roles/tester.ts +++ b/packages/workflow-meta/src/develop-sense/roles/tester.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -53,7 +53,7 @@ export function createTesterRole( ): Role { return createRole( adapter, - async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }), + async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }), testerMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-workflow/roles/coder.ts b/packages/workflow-meta/src/develop-workflow/roles/coder.ts index c09f562..6098974 100644 --- a/packages/workflow-meta/src/develop-workflow/roles/coder.ts +++ b/packages/workflow-meta/src/develop-workflow/roles/coder.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -62,7 +62,7 @@ Return \`done: false\` if you made progress but there is still work to do, or if export function createCoderRole(adapter: AgentFn, extract: LlmExtractorConfig): Role { return createRole( adapter, - async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }), + async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }), coderMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-workflow/roles/planner.ts b/packages/workflow-meta/src/develop-workflow/roles/planner.ts index 20148fa..85f22aa 100644 --- a/packages/workflow-meta/src/develop-workflow/roles/planner.ts +++ b/packages/workflow-meta/src/develop-workflow/roles/planner.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -61,7 +61,7 @@ export function createPlannerRole( ): Role { return createRole( adapter, - async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }), + async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }), plannerMetaSchema, extract, ); diff --git a/packages/workflow-meta/src/develop-workflow/roles/tester.ts b/packages/workflow-meta/src/develop-workflow/roles/tester.ts index 766a825..2a35ef6 100644 --- a/packages/workflow-meta/src/develop-workflow/roles/tester.ts +++ b/packages/workflow-meta/src/develop-workflow/roles/tester.ts @@ -1,4 +1,4 @@ -import type { AgentFn, Role, StartStep } from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils"; import { createRole } from "@uncaged/nerve-workflow-utils"; import { z } from "zod"; @@ -54,7 +54,7 @@ export function createTesterRole( ): Role { return createRole( adapter, - async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }), + async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }), testerMetaSchema, extract, ); diff --git a/packages/workflow-utils/src/__tests__/create-role.test.ts b/packages/workflow-utils/src/__tests__/create-role.test.ts index 6b16c1d..c2aca98 100644 --- a/packages/workflow-utils/src/__tests__/create-role.test.ts +++ b/packages/workflow-utils/src/__tests__/create-role.test.ts @@ -2,9 +2,8 @@ import type { AgentFn, ModeratorContext, RoleMeta, - WorkflowContext, + ThreadContext, WorkflowDefinition, - WorkflowMessage, } from "@uncaged/nerve-core"; import { END, START } from "@uncaged/nerve-core"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -50,17 +49,25 @@ function toolCallResponse(argsJson: string): { function makeStart(threadId: string): { role: typeof START; content: string; - meta: { maxRounds: number; dryRun: boolean; threadId: string }; + meta: { maxRounds: number; threadId: string }; timestamp: number; } { return { role: START, content: "", - meta: { maxRounds: 10, dryRun: false, threadId }, + meta: { maxRounds: 10, threadId }, timestamp: Date.now(), }; } +function makeCtx(threadId: string): ThreadContext { + return { + threadId, + start: makeStart(threadId), + steps: [], + }; +} + describe("createRole", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -71,88 +78,83 @@ describe("createRole", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 3 })))); const schema = z.object({ n: z.number() }); - const adapter: AgentFn = async (prompt) => prompt; - const role = createRole(adapter, "hello", schema, { provider }); + const adapter: AgentFn = async (_ctx, prompt) => prompt; + const role = createRole(adapter, "hello", schema, { provider, dryRun: null }); - const out = await role(makeStart("t1"), []); + const out = await role(makeCtx("t1")); expect(out.content).toBe("hello"); expect(out.meta).toEqual({ n: 3 }); }); - it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => { + it("passes ThreadContext to AgentFn", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 })))); - const seen: WorkflowContext[] = []; - const adapter: AgentFn = async (_prompt, ctx) => { + const seen: ThreadContext[] = []; + const adapter: AgentFn = async (ctx, _prompt) => { seen.push(ctx); return "x"; }; - const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider }); - await role(makeStart("t1"), []); + const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider, dryRun: null }); + await role(makeCtx("t1")); expect(seen).toHaveLength(1); - expect(seen[0].workdir).toBe(process.cwd()); + expect(seen[0].threadId).toBe("t1"); + expect(seen[0].steps).toEqual([]); }); it("resolves dynamic prompt functions before AgentFn", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 99 })))); const schema = z.object({ n: z.number() }); - const adapter: AgentFn = async (prompt) => prompt; + const adapter: AgentFn = async (_ctx, prompt) => prompt; const role = createRole( adapter, - async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`, + async (ctx) => `tid=${ctx.threadId} n=${ctx.steps.length}`, schema, - { provider }, + { provider, dryRun: null }, ); - const start = makeStart("thread-x"); - const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }]; - const out = await role(start, msgs); - expect(out.content).toBe("tid=thread-x n=1"); + const ctx: ThreadContext = { + threadId: "thread-x", + start: makeStart("thread-x"), + steps: [], + }; + const out = await role(ctx); + expect(out.content).toBe("tid=thread-x n=0"); expect(out.meta).toEqual({ n: 99 }); }); - it("uses start.meta.dryRun when extract.dryRun is omitted", async () => { - const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); - - const adapter: AgentFn = async () => "raw"; - const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider }); - const start = { - role: START, - content: "", - meta: { maxRounds: 10, dryRun: true, threadId: "x" }, - timestamp: 1, - }; - await role(start, []); - - expect(spy).toHaveBeenCalledWith( - "raw", - expect.anything(), - expect.objectContaining({ provider, dryRun: true }), - ); - }); - - it("prefers extract.dryRun over start.meta.dryRun", async () => { + it("uses extract dryRun null as live extract", async () => { const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); const adapter: AgentFn = async () => "raw"; const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider, - dryRun: false, + dryRun: null, }); - const start = { - role: START, - content: "", - meta: { maxRounds: 10, dryRun: true, threadId: "x" }, - timestamp: 1, - }; - await role(start, []); + await role(makeCtx("x")); expect(spy).toHaveBeenCalledWith( "raw", expect.anything(), - expect.objectContaining({ dryRun: false }), + expect.objectContaining({ provider, dryRun: false }), + ); + }); + + it("uses extract.dryRun true for structured extract", async () => { + const spy = vi.spyOn(extractFn, "extractMetaOrThrow").mockResolvedValue({ n: 0 }); + + const adapter: AgentFn = async () => "raw"; + const role = createRole(adapter, "p", z.object({ n: z.number() }), { + provider, + dryRun: true, + }); + await role(makeCtx("x")); + + expect(spy).toHaveBeenCalledWith( + "raw", + expect.anything(), + expect.objectContaining({ dryRun: true }), ); }); }); @@ -164,7 +166,7 @@ describe("WorkflowDefinition compatibility", () => { const manual: WorkflowDefinition = { name: "legacy", roles: { - legacy: async (_start, _messages) => ({ + legacy: async (_ctx) => ({ content: "hi", meta: { id: "a" }, }), @@ -172,8 +174,8 @@ describe("WorkflowDefinition compatibility", () => { moderator: (_ctx: ModeratorContext) => END, }; - const start = makeStart("t1"); - const out = await manual.roles.legacy(start, []); + const ctx = makeCtx("t1"); + const out = await manual.roles.legacy(ctx); expect(out.content).toBe("hi"); expect(out.meta.id).toBe("a"); }); diff --git a/packages/workflow-utils/src/__tests__/role-decorators.test.ts b/packages/workflow-utils/src/__tests__/role-decorators.test.ts index ecf91e1..26aa03e 100644 --- a/packages/workflow-utils/src/__tests__/role-decorators.test.ts +++ b/packages/workflow-utils/src/__tests__/role-decorators.test.ts @@ -1,22 +1,25 @@ import { describe, expect, it } from "vitest"; -import type { Role, StartStep } from "@uncaged/nerve-core"; +import type { Role, ThreadContext } from "@uncaged/nerve-core"; import { START } from "@uncaged/nerve-core"; import { decorateRole, onFail, withDryRun } from "../role-decorators.js"; type TestMeta = Record & { ok: boolean }; -function fakeStart(dryRun: boolean): StartStep { +function fakeCtx(): ThreadContext { return { - role: START, - content: "", - meta: { - threadId: "t1", - dryRun, - maxRounds: 10, + threadId: "t1", + start: { + role: START, + content: "", + meta: { + threadId: "t1", + maxRounds: 10, + }, + timestamp: Date.now(), }, - timestamp: Date.now(), + steps: [], }; } @@ -38,18 +41,19 @@ const failNonErrorRole: Role = async () => { // --------------------------------------------------------------------------- describe("withDryRun", () => { - const dec = withDryRun({ label: "test", meta: { ok: true } }); + const dec = withDryRun({ label: "test", meta: { ok: true }, dryRun: true }); it("short-circuits on dry-run", async () => { const role = dec(successRole); - const result = await role(fakeStart(true), []); + const result = await role(fakeCtx()); expect(result.content).toBe("[dry-run] test skipped"); expect(result.meta).toEqual({ ok: true }); }); it("delegates when not dry-run", async () => { - const role = dec(successRole); - const result = await role(fakeStart(false), []); + const innerDec = withDryRun({ label: "test", meta: { ok: true }, dryRun: false }); + const role = innerDec(successRole); + const result = await role(fakeCtx()); expect(result.content).toBe("done"); expect(result.meta).toEqual({ ok: true }); }); @@ -64,21 +68,21 @@ describe("onFail", () => { it("passes through on success", async () => { const role = dec(successRole); - const result = await role(fakeStart(false), []); + const result = await role(fakeCtx()); expect(result.content).toBe("done"); expect(result.meta).toEqual({ ok: true }); }); it("catches Error and returns structured failure", async () => { const role = dec(failRole); - const result = await role(fakeStart(false), []); + const result = await role(fakeCtx()); expect(result.content).toBe("test failed: boom"); expect(result.meta).toEqual({ ok: false }); }); it("catches non-Error throws", async () => { const role = dec(failNonErrorRole); - const result = await role(fakeStart(false), []); + const result = await role(fakeCtx()); expect(result.content).toBe("test failed: string error"); expect(result.meta).toEqual({ ok: false }); }); @@ -91,21 +95,21 @@ describe("onFail", () => { describe("decorateRole", () => { it("applies decorators left-to-right", async () => { const role = decorateRole(failRole, [ - withDryRun({ label: "x", meta: { ok: true } }), - onFail({ label: "x", meta: { ok: false } }), + withDryRun({ label: "x", meta: { ok: true }, dryRun: false }), + onFail({ label: "x", meta: { ok: false } }), ]); // Not dry-run, so withDryRun passes through → failRole throws → onFail catches - const result = await role(fakeStart(false), []); + const result = await role(fakeCtx()); expect(result.content).toBe("x failed: boom"); expect(result.meta).toEqual({ ok: false }); }); it("dry-run short-circuits before onFail", async () => { const role = decorateRole(failRole, [ - withDryRun({ label: "x", meta: { ok: true } }), - onFail({ label: "x", meta: { ok: false } }), + withDryRun({ label: "x", meta: { ok: true }, dryRun: true }), + onFail({ label: "x", meta: { ok: false } }), ]); - const result = await role(fakeStart(true), []); + const result = await role(fakeCtx()); expect(result.content).toBe("[dry-run] x skipped"); expect(result.meta).toEqual({ ok: true }); }); diff --git a/packages/workflow-utils/src/__tests__/role-factories.test.ts b/packages/workflow-utils/src/__tests__/role-factories.test.ts index 1eabe44..6494ca8 100644 --- a/packages/workflow-utils/src/__tests__/role-factories.test.ts +++ b/packages/workflow-utils/src/__tests__/role-factories.test.ts @@ -1,20 +1,24 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { START } from "@uncaged/nerve-core"; +import { START, type ThreadContext } from "@uncaged/nerve-core"; import { createCursorRole } from "../role-cursor.js"; import { createHermesRole } from "../role-hermes.js"; import { createLlmRole } from "../role-llm.js"; import { createReActRole } from "../role-react.js"; -function startFrame(dryRun: boolean, threadId: string) { +function threadCtx(threadId: string): ThreadContext { return { - role: START, - content: "user prompt", - meta: { maxRounds: 10, dryRun, threadId }, - timestamp: 1, - } as const; + threadId, + start: { + role: START, + content: "user prompt", + meta: { maxRounds: 10, threadId }, + timestamp: 1, + }, + steps: [], + }; } describe("createCursorRole", () => { @@ -27,13 +31,14 @@ describe("createCursorRole", () => { const schema = z.object({ done: z.boolean() }); const role = createCursorRole({ cwd: process.cwd(), - prompt: async (tid) => { - expect(tid).toBe("run-1"); + prompt: async (ctx) => { + expect(ctx.threadId).toBe("run-1"); return "task"; }, extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema }, + dryRun: true, }); - const out = await role(startFrame(true, "run-1"), []); + const out = await role(threadCtx("run-1")); expect(out.content).toContain("dryRun"); expect(out.meta).toEqual({ done: false }); }); @@ -48,13 +53,14 @@ describe("createHermesRole", () => { it("uses dry run stub and extract defaults", async () => { const schema = z.object({ done: z.boolean() }); const role = createHermesRole({ - prompt: async (tid) => { - expect(tid).toBe("h1"); + prompt: async (ctx) => { + expect(ctx.threadId).toBe("h1"); return "hermes task"; }, extract: { provider: { baseUrl: "https://x", apiKey: "k", model: "m" }, schema }, + dryRun: true, }); - const out = await role(startFrame(true, "h1"), []); + const out = await role(threadCtx("h1")); expect(out.content).toContain("hermes"); expect(out.meta).toEqual({ done: false }); }); @@ -99,13 +105,13 @@ describe("createLlmRole", () => { const role = createLlmRole({ provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" }, - prompt: async (tid) => { - expect(tid).toBe("llm1"); + prompt: async (ctx) => { + expect(ctx.threadId).toBe("llm1"); return [{ role: "user" as const, content: "hi" }]; }, extract: { provider: { baseUrl: "https://ext", apiKey: "k2", model: "small" }, schema }, }); - const out = await role(startFrame(false, "llm1"), []); + const out = await role(threadCtx("llm1")); expect(out.content).toBe("hello from model"); expect(out.meta).toEqual({ n: 7 }); }); @@ -183,8 +189,8 @@ describe("createReActRole", () => { const role = createReActRole({ provider: { baseUrl: "https://api", apiKey: "k", model: "gpt" }, tools: [tool], - prompt: async (tid) => { - expect(tid).toBe("r1"); + prompt: async (ctx) => { + expect(ctx.threadId).toBe("r1"); return [{ role: "user" as const, content: "go" }]; }, extract: { @@ -193,7 +199,7 @@ describe("createReActRole", () => { }, maxIterations: 5, }); - const out = await role(startFrame(false, "r1"), []); + const out = await role(threadCtx("r1")); expect(out.content).toBe("final answer"); expect(out.meta).toEqual({ done: true }); }); diff --git a/packages/workflow-utils/src/create-role.ts b/packages/workflow-utils/src/create-role.ts index c938c44..c3768a7 100644 --- a/packages/workflow-utils/src/create-role.ts +++ b/packages/workflow-utils/src/create-role.ts @@ -1,32 +1,19 @@ -import type { - AgentFn, - Role, - StartStep, - WorkflowContext, - WorkflowMessage, -} from "@uncaged/nerve-core"; +import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core"; import type { z } from "zod"; import { extractMetaOrThrow } from "./shared/extract-fn.js"; import type { LlmProvider } from "./shared/llm-extract.js"; -type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise); +type PromptInput = string | ((ctx: ThreadContext) => Promise); export type LlmExtractorConfig = { provider: LlmProvider; - /** When omitted, uses `start.meta.dryRun` at runtime. */ - dryRun?: boolean; + /** When null, structured extract runs in live mode (not dry-run). */ + dryRun: boolean | null; }; -type StartMetaWithWorkdir = StartStep["meta"] & { workdir?: string | null }; - -function resolveWorkdir(start: StartStep): string { - const m = start.meta as StartMetaWithWorkdir; - return m.workdir ?? process.cwd(); -} - -function resolveDryRun(extract: LlmExtractorConfig, start: StartStep): boolean { - return extract.dryRun ?? start.meta.dryRun; +function resolveExtractDryRun(extract: LlmExtractorConfig): boolean { + return extract.dryRun === true; } /** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */ @@ -36,19 +23,12 @@ export function createRole>( meta: z.ZodType, extract: LlmExtractorConfig, ): Role { - return async (start: StartStep, messages: WorkflowMessage[]) => { - const ctx: WorkflowContext = { - start, - messages, - workdir: resolveWorkdir(start), - signal: new AbortController().signal, - }; - - const promptText = typeof prompt === "string" ? prompt : await prompt(start, messages); - const raw = await adapter(promptText, ctx); + return async (ctx: ThreadContext) => { + const promptText = typeof prompt === "string" ? prompt : await prompt(ctx); + const raw = await adapter(ctx, promptText); const result = await extractMetaOrThrow(raw, meta, { provider: extract.provider, - dryRun: resolveDryRun(extract, start), + dryRun: resolveExtractDryRun(extract), }); return { content: raw, meta: result }; }; diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 56bc046..418bfef 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -19,7 +19,6 @@ export { type NerveYamlError, type ReadNerveYamlOptions, } from "./shared/context.js"; -export { isDryRun } from "./role-types.js"; export { decorateRole, withDryRun, @@ -37,6 +36,7 @@ export { type SpawnSafeOptions, } from "@uncaged/nerve-core"; export type { LlmError, LlmProvider } from "./shared/llm-extract.js"; +export { isDryRun } from "./role-types.js"; export type { CliPromptFn, CursorRoleDefaults, @@ -45,6 +45,7 @@ export type { HermesRoleRequired, LlmMessage, LlmPromptFn, + LlmRoleDefaults, LlmRoleRequired, MetaExtractConfig, ReActRoleDefaults, diff --git a/packages/workflow-utils/src/role-cursor.ts b/packages/workflow-utils/src/role-cursor.ts index a827260..72673c5 100644 --- a/packages/workflow-utils/src/role-cursor.ts +++ b/packages/workflow-utils/src/role-cursor.ts @@ -2,7 +2,6 @@ import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor import type { Role, SpawnEnv } from "@uncaged/nerve-core"; import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js"; -import { isDryRun } from "./role-types.js"; import { formatLlmError } from "./shared/format-error.js"; import { llmExtract } from "./shared/llm-extract.js"; @@ -11,6 +10,7 @@ const CURSOR_DEFAULTS: CursorRoleDefaults = { model: "auto", env: {}, timeoutMs: 300_000, + dryRun: false, }; function pick(opts: Record, key: string, fallback: T): T { @@ -27,14 +27,14 @@ function pick(opts: Record, key: string, fallback: T): T { export function createCursorRole( options: CursorRoleRequired & Partial, ): Role { - return async (start, _messages) => { - const dry = isDryRun(start); + return async (ctx) => { const d = CURSOR_DEFAULTS; const mode = pick(options as Record, "mode", d.mode); const model = pick(options as Record, "model", d.model); const env = pick(options as Record, "env", d.env); const timeoutMs = pick(options as Record, "timeoutMs", d.timeoutMs); - const prompt = await options.prompt(start.meta.threadId); + const dry = pick(options as Record, "dryRun", d.dryRun); + const prompt = await options.prompt(ctx); const run = await cursorAgent({ prompt, mode, diff --git a/packages/workflow-utils/src/role-decorators.ts b/packages/workflow-utils/src/role-decorators.ts index bc31cd4..b8155a0 100644 --- a/packages/workflow-utils/src/role-decorators.ts +++ b/packages/workflow-utils/src/role-decorators.ts @@ -1,6 +1,4 @@ -import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; - -import { isDryRun } from "./role-types.js"; +import type { Role, ThreadContext } from "@uncaged/nerve-core"; // --------------------------------------------------------------------------- // Decorator types @@ -38,23 +36,25 @@ export type WithDryRunOptions = { label: string; /** Meta returned when dry-run skips execution. */ meta: M; + /** Adapter-level dry-run flag (e.g. from extract / wiring config). */ + dryRun: boolean; }; /** * Returns a decorator that short-circuits with a stable result when - * `start.meta.dryRun` is true. + * `dryRun` is true. */ export function withDryRun>( opts: WithDryRunOptions, ): RoleDecorator { - return (role) => async (start: StartStep, messages: WorkflowMessage[]) => { - if (isDryRun(start)) { + return (role) => async (ctx: ThreadContext) => { + if (opts.dryRun) { return { content: `[dry-run] ${opts.label} skipped`, meta: opts.meta, }; } - return role(start, messages); + return role(ctx); }; } @@ -76,9 +76,9 @@ export type OnFailOptions = { export function onFail>( opts: OnFailOptions, ): RoleDecorator { - return (role) => async (start: StartStep, messages: WorkflowMessage[]) => { + return (role) => async (ctx: ThreadContext) => { try { - return await role(start, messages); + return await role(ctx); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { diff --git a/packages/workflow-utils/src/role-hermes.ts b/packages/workflow-utils/src/role-hermes.ts index 1bd7531..d23ee80 100644 --- a/packages/workflow-utils/src/role-hermes.ts +++ b/packages/workflow-utils/src/role-hermes.ts @@ -1,7 +1,6 @@ import type { Role } from "@uncaged/nerve-core"; import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js"; -import { isDryRun } from "./role-types.js"; import { formatLlmError } from "./shared/format-error.js"; import { hermesAgent, resolveHermesOptions } from "./shared/hermes-agent.js"; import { llmExtract } from "./shared/llm-extract.js"; @@ -9,10 +8,9 @@ import { llmExtract } from "./shared/llm-extract.js"; export function createHermesRole( options: HermesRoleRequired & Partial, ): Role { - return async (start, _messages) => { - const dry = isDryRun(start); + return async (ctx) => { const h = resolveHermesOptions(options); - const prompt = await options.prompt(start.meta.threadId); + const prompt = await options.prompt(ctx); const run = await hermesAgent({ prompt, model: h.model, @@ -22,7 +20,7 @@ export function createHermesRole( maxTurns: h.maxTurns, env: Object.keys(h.env).length === 0 ? null : h.env, timeoutMs: h.timeoutMs, - dryRun: dry, + dryRun: h.dryRun, abortSignal: null, }); if (!run.ok) { @@ -43,7 +41,7 @@ export function createHermesRole( text, schema: options.extract.schema, provider: options.extract.provider, - dryRun: dry, + dryRun: h.dryRun, }); if (!metaR.ok) { throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`); diff --git a/packages/workflow-utils/src/role-llm.ts b/packages/workflow-utils/src/role-llm.ts index f8a5709..9ebb54e 100644 --- a/packages/workflow-utils/src/role-llm.ts +++ b/packages/workflow-utils/src/role-llm.ts @@ -1,15 +1,19 @@ import type { Role } from "@uncaged/nerve-core"; -import type { LlmMessage, LlmRoleRequired } from "./role-types.js"; -import { isDryRun } from "./role-types.js"; +import type { LlmMessage, LlmRoleDefaults, LlmRoleRequired } from "./role-types.js"; import { formatLlmError } from "./shared/format-error.js"; import { chatCompletionText } from "./shared/llm-chat.js"; import { llmExtract } from "./shared/llm-extract.js"; -export function createLlmRole(options: LlmRoleRequired): Role { - return async (start, _messages) => { - const dry = isDryRun(start); - const messages: LlmMessage[] = await options.prompt(start.meta.threadId); +const LLM_DEFAULTS: LlmRoleDefaults = { + dryRun: false, +}; + +export function createLlmRole(options: LlmRoleRequired & Partial): Role { + return async (ctx) => { + const dry = + "dryRun" in options && options.dryRun !== undefined ? options.dryRun : LLM_DEFAULTS.dryRun; + const messages: LlmMessage[] = await options.prompt(ctx); const result = await chatCompletionText({ provider: options.provider, messages }); if (!result.ok) { throw new Error(`llm: ${formatLlmError(result.error)}`); diff --git a/packages/workflow-utils/src/role-react.ts b/packages/workflow-utils/src/role-react.ts index d855217..579a90d 100644 --- a/packages/workflow-utils/src/role-react.ts +++ b/packages/workflow-utils/src/role-react.ts @@ -1,26 +1,26 @@ import type { Role } from "@uncaged/nerve-core"; import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js"; -import { isDryRun } from "./role-types.js"; import { formatLlmError } from "./shared/format-error.js"; import { reActIterativeChat } from "./shared/llm-chat.js"; import { llmExtract } from "./shared/llm-extract.js"; const REACT_DEFAULTS: ReActRoleDefaults = { maxIterations: 10, + dryRun: false, }; export function createReActRole( options: ReActRoleRequired & Partial, ): Role { - return async (start, _messages) => { - const dry = isDryRun(start); + return async (ctx) => { const def = REACT_DEFAULTS; const maxIt = "maxIterations" in options && options.maxIterations !== undefined ? options.maxIterations : def.maxIterations; - const messages: LlmMessage[] = await options.prompt(start.meta.threadId); + const dry = "dryRun" in options && options.dryRun !== undefined ? options.dryRun : def.dryRun; + const messages: LlmMessage[] = await options.prompt(ctx); const result = await reActIterativeChat({ provider: options.provider, tools: options.tools, diff --git a/packages/workflow-utils/src/role-types.ts b/packages/workflow-utils/src/role-types.ts index 63503f9..3b0c284 100644 --- a/packages/workflow-utils/src/role-types.ts +++ b/packages/workflow-utils/src/role-types.ts @@ -1,18 +1,21 @@ -import type { SpawnEnv, StartStep } from "@uncaged/nerve-core"; +import type { SpawnEnv, StartStep, ThreadContext } from "@uncaged/nerve-core"; import type { z } from "zod"; import type { LlmProvider } from "./shared/llm-extract.js"; -/** Returns the thread-level dry-run flag from the workflow start frame. */ -export function isDryRun(start: StartStep): boolean { - return start.meta.dryRun; +/** + * @deprecated `dryRun` has been removed from `StartStep.meta` (RFC-005). + * Use adapter/role-level `dryRun` config instead. + */ +export function isDryRun(_start: StartStep): boolean { + return false; } -export type CliPromptFn = (threadId: string) => Promise; +export type CliPromptFn = (ctx: ThreadContext) => Promise; export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; -export type LlmPromptFn = (threadId: string) => Promise; +export type LlmPromptFn = (ctx: ThreadContext) => Promise; export type MetaExtractConfig = { provider: LlmProvider; @@ -37,6 +40,7 @@ export type CursorRoleDefaults = { model: string; env: SpawnEnv; timeoutMs: number; + dryRun: boolean; }; export type HermesRoleRequired = { @@ -52,6 +56,7 @@ export type HermesRoleDefaults = { maxTurns: number; env: SpawnEnv; timeoutMs: number; + dryRun: boolean; }; export type LlmRoleRequired = { @@ -60,6 +65,10 @@ export type LlmRoleRequired = { extract: MetaExtractConfig; }; +export type LlmRoleDefaults = { + dryRun: boolean; +}; + export type ReActRoleRequired = { provider: LlmProvider; tools: ReActTool[]; @@ -69,4 +78,5 @@ export type ReActRoleRequired = { export type ReActRoleDefaults = { maxIterations: number; + dryRun: boolean; }; diff --git a/packages/workflow-utils/src/shared/hermes-agent.ts b/packages/workflow-utils/src/shared/hermes-agent.ts index 759b928..fc0b24c 100644 --- a/packages/workflow-utils/src/shared/hermes-agent.ts +++ b/packages/workflow-utils/src/shared/hermes-agent.ts @@ -13,22 +13,30 @@ const HERMES_DEFAULTS: HermesRoleDefaults = { maxTurns: 90, env: {}, timeoutMs: 600_000, + dryRun: false, }; +const HERMES_OPTION_KEYS = [ + "model", + "provider", + "skills", + "quiet", + "maxTurns", + "env", + "timeoutMs", + "dryRun", +] as const satisfies readonly (keyof HermesRoleDefaults)[]; + export function resolveHermesOptions( options: HermesRoleRequired & Partial, ): HermesRoleDefaults { const d = HERMES_DEFAULTS; - return { - model: "model" in options && options.model !== undefined ? options.model : d.model, - provider: - "provider" in options && options.provider !== undefined ? options.provider : d.provider, - skills: "skills" in options && options.skills !== undefined ? options.skills : d.skills, - quiet: "quiet" in options && options.quiet !== undefined ? options.quiet : d.quiet, - maxTurns: - "maxTurns" in options && options.maxTurns !== undefined ? options.maxTurns : d.maxTurns, - env: "env" in options && options.env !== undefined ? options.env : d.env, - timeoutMs: - "timeoutMs" in options && options.timeoutMs !== undefined ? options.timeoutMs : d.timeoutMs, - }; + const o = options as Partial; + let resolved: HermesRoleDefaults = { ...d }; + for (const k of HERMES_OPTION_KEYS) { + if (k in o && o[k] !== undefined) { + resolved = { ...resolved, [k]: o[k] } as HermesRoleDefaults; + } + } + return resolved; }