From 3e51335d914c70519060df6cf01bfb8ae837cded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 06:54:03 +0000 Subject: [PATCH 1/7] =?UTF-8?q?refactor(core):=20RFC-005=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20ThreadContext,=20AgentFn,=20Role=20signature=20(clo?= =?UTF-8?q?ses=20#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/index.ts | 1 + packages/core/src/workflow.ts | 60 ++++++++++++++++------------------- 2 files changed, 29 insertions(+), 32 deletions(-) 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 = { -- 2.43.0 From 975f15c66d7b2ca8be6f958055749484055c4a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 06:59:15 +0000 Subject: [PATCH 2/7] =?UTF-8?q?refactor(workflow-utils):=20RFC-005=20Phase?= =?UTF-8?q?=202=20=E2=80=94=20adapt=20to=20ThreadContext,=20new=20AgentFn?= =?UTF-8?q?=20signature=20(closes=20#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/adapter-cursor/src/index.ts | 10 +- packages/adapter-hermes/src/index.ts | 8 +- packages/daemon/src/agent-adapters/echo.ts | 4 +- packages/role-committer/src/index.ts | 10 +- packages/role-reviewer/src/reviewer.ts | 4 +- .../src/develop-sense/roles/coder.ts | 4 +- .../src/develop-sense/roles/planner.ts | 4 +- .../src/develop-sense/roles/tester.ts | 4 +- .../src/develop-workflow/roles/coder.ts | 4 +- .../src/develop-workflow/roles/planner.ts | 4 +- .../src/develop-workflow/roles/tester.ts | 4 +- .../src/__tests__/create-role.test.ts | 108 +++++++++--------- .../src/__tests__/role-decorators.test.ts | 48 ++++---- .../src/__tests__/role-factories.test.ts | 44 ++++--- packages/workflow-utils/src/create-role.ts | 40 ++----- packages/workflow-utils/src/index.ts | 2 +- packages/workflow-utils/src/role-cursor.ts | 8 +- .../workflow-utils/src/role-decorators.ts | 18 +-- packages/workflow-utils/src/role-hermes.ts | 10 +- packages/workflow-utils/src/role-llm.ts | 16 ++- packages/workflow-utils/src/role-react.ts | 8 +- packages/workflow-utils/src/role-types.ts | 18 +-- .../workflow-utils/src/shared/hermes-agent.ts | 32 ++++-- 23 files changed, 210 insertions(+), 202 deletions(-) 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/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/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..e3aca06 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, @@ -45,6 +44,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..be540c8 100644 --- a/packages/workflow-utils/src/role-types.ts +++ b/packages/workflow-utils/src/role-types.ts @@ -1,18 +1,13 @@ -import type { SpawnEnv, StartStep } from "@uncaged/nerve-core"; +import type { SpawnEnv, 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; -} - -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 +32,7 @@ export type CursorRoleDefaults = { model: string; env: SpawnEnv; timeoutMs: number; + dryRun: boolean; }; export type HermesRoleRequired = { @@ -52,6 +48,7 @@ export type HermesRoleDefaults = { maxTurns: number; env: SpawnEnv; timeoutMs: number; + dryRun: boolean; }; export type LlmRoleRequired = { @@ -60,6 +57,10 @@ export type LlmRoleRequired = { extract: MetaExtractConfig; }; +export type LlmRoleDefaults = { + dryRun: boolean; +}; + export type ReActRoleRequired = { provider: LlmProvider; tools: ReActTool[]; @@ -69,4 +70,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; } -- 2.43.0 From d13b59e78718026b015dae44614588f38c6f2a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 07:10:58 +0000 Subject: [PATCH 3/7] =?UTF-8?q?refactor(daemon):=20RFC-005=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20workflow-worker=20uses=20ThreadContext=20(closes=20?= =?UTF-8?q?#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/daemon/src/workflow-manager.ts | 4 +- packages/daemon/src/workflow-worker.ts | 83 +++++++++++++------------ 2 files changed, 45 insertions(+), 42 deletions(-) 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 }); -- 2.43.0 From f799cee51f07d9bdd170a9c9063a9146ac17bd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 07:24:11 +0000 Subject: [PATCH 4/7] =?UTF-8?q?refactor(cli,docs):=20RFC-005=20Phase=204?= =?UTF-8?q?=20=E2=80=94=20update=20templates,=20tests,=20docs=20(closes=20?= =?UTF-8?q?#271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .knowledge/workflow.md | 8 +-- CLAUDE.md | 25 +++++++ package.json | 1 + .../cli/src/__tests__/create-workflow.test.ts | 10 ++- packages/cli/src/__tests__/e2e-harness.ts | 70 +++++++++++++------ packages/cli/src/commands/create.ts | 14 ++-- packages/daemon/src/sense-worker.ts | 12 +--- 7 files changed, 96 insertions(+), 44 deletions(-) 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/package.json b/package.json index f324577..d10b97d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "prepare": "husky", "build": "pnpm -r run build", + "test": "pnpm -r run test", "check": "biome check .", "format": "biome format --write .", "link:dev": "bash scripts/link-dev.sh" 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/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index bffc4ce..b0375f6 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -41,6 +41,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import type { NerveConfig } from "@uncaged/nerve-core"; import { createKernel } from "@uncaged/nerve-daemon"; @@ -61,6 +62,9 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js"); +const requireDaemon = createRequire(join(nerveDaemonRoot, "package.json")); +const drizzleSqliteCoreHref = pathToFileURL(requireDaemon.resolve("drizzle-orm/sqlite-core")).href; + const nerveYamlTemplate = `senses: counter: group: e2e @@ -88,17 +92,17 @@ const echoWorkflowIndexJs = `const END = "__end__"; export default { name: "echo", roles: { - echo: async (start, _messages) => { + echo: async (ctx) => { await new Promise((r) => setTimeout(r, 350)); - const p = typeof start.content === "string" ? start.content : ""; + const p = typeof ctx.start.content === "string" ? ctx.start.content : ""; return { content: p.length > 0 ? "echo:" + p : "echo:empty", meta: {}, }; }, }, - moderator({ steps }) { - if (steps.length === 0) return "echo"; + moderator(ctx) { + if (ctx.steps.length === 0) return "echo"; return END; }, }; @@ -121,30 +125,47 @@ api: host: 127.0.0.1 `; -/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */ -const counterMigration = `-- no-op migration for e2e counter sense -SELECT 1; +/** Schema for \`table\` export — rows mirror signal payloads inserted by the runtime. */ +const counterMigration = `CREATE TABLE IF NOT EXISTS sense_payload ( + count INTEGER, + launched INTEGER, + idle INTEGER +); `; -/** - * Minimal counter sense — each compute returns an incrementing count. - * Does NOT touch the DB directly; signal persistence is handled by the daemon - * (`runtime.persistSignal`) which writes to `_signals` automatically. - */ -const counterIndexJs = `let _count = 0; -export async function compute(_db, _peers, _options) { +function buildCounterIndexJs(): string { + return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}"; + +export const table = sqliteTable("sense_payload", { + count: integer("count"), + launched: integer("launched"), + idle: integer("idle"), +}); + +let _count = 0; +export async function compute() { _count += 1; return { signal: { count: _count }, workflow: null }; } `; +} /** First trigger launches local noop workflow; later triggers emit a plain signal. */ -const counterIndexJsWithNoopWorkflow = `let _launched = false; -export async function compute(_db, _peers, _options) { +function buildCounterIndexJsWithNoopWorkflow(): string { + return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}"; + +export const table = sqliteTable("sense_payload", { + count: integer("count"), + launched: integer("launched"), + idle: integer("idle"), +}); + +let _launched = false; +export async function compute() { if (!_launched) { _launched = true; return { - signal: { launched: true }, + signal: { launched: 1 }, workflow: { name: "noop", maxRounds: 3, @@ -153,18 +174,25 @@ export async function compute(_db, _peers, _options) { }, }; } - return { signal: { idle: true }, workflow: null }; + return { signal: { idle: 1 }, workflow: null }; } `; +} /** Minimal workflow: moderator ends immediately (no role rounds). */ const noopWorkflowIndexJs = `const END = "__end__"; export default { name: "noop", roles: { - bot: async () => ({ content: "ok", meta: {} }), + bot: async (ctx) => { + void ctx; + return { content: "ok", meta: {} }; + }, + }, + moderator(ctx) { + void ctx; + return END; }, - moderator: () => END, }; `; @@ -222,7 +250,7 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi ); writeFileSync( join(nerveRoot, "senses", "counter", "index.js"), - withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs, + withNoopWorkflow ? buildCounterIndexJsWithNoopWorkflow() : buildCounterIndexJs(), "utf8", ); writeFileSync( 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/daemon/src/sense-worker.ts b/packages/daemon/src/sense-worker.ts index e6d2b35..5933050 100644 --- a/packages/daemon/src/sense-worker.ts +++ b/packages/daemon/src/sense-worker.ts @@ -19,7 +19,7 @@ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { parseNerveConfig } from "@uncaged/nerve-core"; -import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core"; +import type { NerveConfig } from "@uncaged/nerve-core"; import type { WorkerToParentMessage } from "./ipc.js"; import { parseParentMessage } from "./ipc.js"; @@ -49,10 +49,6 @@ function sendError(sense: string, error: string): void { send({ type: "error", sense, error }); } -function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void { - send({ type: "sense-workflow-trigger", sense, workflow }); -} - // --------------------------------------------------------------------------- // Initialisation helpers // --------------------------------------------------------------------------- @@ -154,10 +150,8 @@ async function runCompute( } clearGracePeriodTimer(senseName); if (result.value != null) { - sendSignal(senseName, result.value.signal); - if (result.value.workflow !== null) { - sendWorkflowTrigger(senseName, result.value.workflow); - } + // Full ComputeResult — kernel `routeSenseComputeOutput` extracts signal + optional workflow. + sendSignal(senseName, result.value); } } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); -- 2.43.0 From de8c7c5150f01e17ba073b24d677d7c998ac7f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 08:00:46 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20rever?= =?UTF-8?q?t=20unrelated=20sense-worker=20change,=20restore=20isDryRun=20a?= =?UTF-8?q?s=20deprecated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 - packages/cli/src/__tests__/e2e-harness.ts | 70 +++++++---------------- packages/daemon/src/sense-worker.ts | 12 +++- packages/workflow-utils/src/index.ts | 1 + packages/workflow-utils/src/role-types.ts | 10 +++- 5 files changed, 40 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index d10b97d..f324577 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "prepare": "husky", "build": "pnpm -r run build", - "test": "pnpm -r run test", "check": "biome check .", "format": "biome format --write .", "link:dev": "bash scripts/link-dev.sh" diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index b0375f6..bffc4ce 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -41,7 +41,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; import type { NerveConfig } from "@uncaged/nerve-core"; import { createKernel } from "@uncaged/nerve-daemon"; @@ -62,9 +61,6 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js"); -const requireDaemon = createRequire(join(nerveDaemonRoot, "package.json")); -const drizzleSqliteCoreHref = pathToFileURL(requireDaemon.resolve("drizzle-orm/sqlite-core")).href; - const nerveYamlTemplate = `senses: counter: group: e2e @@ -92,17 +88,17 @@ const echoWorkflowIndexJs = `const END = "__end__"; export default { name: "echo", roles: { - echo: async (ctx) => { + echo: async (start, _messages) => { await new Promise((r) => setTimeout(r, 350)); - const p = typeof ctx.start.content === "string" ? ctx.start.content : ""; + const p = typeof start.content === "string" ? start.content : ""; return { content: p.length > 0 ? "echo:" + p : "echo:empty", meta: {}, }; }, }, - moderator(ctx) { - if (ctx.steps.length === 0) return "echo"; + moderator({ steps }) { + if (steps.length === 0) return "echo"; return END; }, }; @@ -125,47 +121,30 @@ api: host: 127.0.0.1 `; -/** Schema for \`table\` export — rows mirror signal payloads inserted by the runtime. */ -const counterMigration = `CREATE TABLE IF NOT EXISTS sense_payload ( - count INTEGER, - launched INTEGER, - idle INTEGER -); +/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */ +const counterMigration = `-- no-op migration for e2e counter sense +SELECT 1; `; -function buildCounterIndexJs(): string { - return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}"; - -export const table = sqliteTable("sense_payload", { - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _count = 0; -export async function compute() { +/** + * Minimal counter sense — each compute returns an incrementing count. + * Does NOT touch the DB directly; signal persistence is handled by the daemon + * (`runtime.persistSignal`) which writes to `_signals` automatically. + */ +const counterIndexJs = `let _count = 0; +export async function compute(_db, _peers, _options) { _count += 1; return { signal: { count: _count }, workflow: null }; } `; -} /** First trigger launches local noop workflow; later triggers emit a plain signal. */ -function buildCounterIndexJsWithNoopWorkflow(): string { - return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}"; - -export const table = sqliteTable("sense_payload", { - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _launched = false; -export async function compute() { +const counterIndexJsWithNoopWorkflow = `let _launched = false; +export async function compute(_db, _peers, _options) { if (!_launched) { _launched = true; return { - signal: { launched: 1 }, + signal: { launched: true }, workflow: { name: "noop", maxRounds: 3, @@ -174,25 +153,18 @@ export async function compute() { }, }; } - return { signal: { idle: 1 }, workflow: null }; + return { signal: { idle: true }, workflow: null }; } `; -} /** Minimal workflow: moderator ends immediately (no role rounds). */ const noopWorkflowIndexJs = `const END = "__end__"; export default { name: "noop", roles: { - bot: async (ctx) => { - void ctx; - return { content: "ok", meta: {} }; - }, - }, - moderator(ctx) { - void ctx; - return END; + bot: async () => ({ content: "ok", meta: {} }), }, + moderator: () => END, }; `; @@ -250,7 +222,7 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi ); writeFileSync( join(nerveRoot, "senses", "counter", "index.js"), - withNoopWorkflow ? buildCounterIndexJsWithNoopWorkflow() : buildCounterIndexJs(), + withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs, "utf8", ); writeFileSync( diff --git a/packages/daemon/src/sense-worker.ts b/packages/daemon/src/sense-worker.ts index 5933050..e6d2b35 100644 --- a/packages/daemon/src/sense-worker.ts +++ b/packages/daemon/src/sense-worker.ts @@ -19,7 +19,7 @@ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { parseNerveConfig } from "@uncaged/nerve-core"; -import type { NerveConfig } from "@uncaged/nerve-core"; +import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core"; import type { WorkerToParentMessage } from "./ipc.js"; import { parseParentMessage } from "./ipc.js"; @@ -49,6 +49,10 @@ function sendError(sense: string, error: string): void { send({ type: "error", sense, error }); } +function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void { + send({ type: "sense-workflow-trigger", sense, workflow }); +} + // --------------------------------------------------------------------------- // Initialisation helpers // --------------------------------------------------------------------------- @@ -150,8 +154,10 @@ async function runCompute( } clearGracePeriodTimer(senseName); if (result.value != null) { - // Full ComputeResult — kernel `routeSenseComputeOutput` extracts signal + optional workflow. - sendSignal(senseName, result.value); + sendSignal(senseName, result.value.signal); + if (result.value.workflow !== null) { + sendWorkflowTrigger(senseName, result.value.workflow); + } } } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index e3aca06..7500025 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -50,5 +50,6 @@ export type { ReActRoleDefaults, ReActRoleRequired, ReActTool, + isDryRun, } from "./role-types.js"; export type { LlmChatError } from "./shared/llm-chat.js"; diff --git a/packages/workflow-utils/src/role-types.ts b/packages/workflow-utils/src/role-types.ts index be540c8..3b0c284 100644 --- a/packages/workflow-utils/src/role-types.ts +++ b/packages/workflow-utils/src/role-types.ts @@ -1,8 +1,16 @@ -import type { SpawnEnv, ThreadContext } 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"; +/** + * @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 = (ctx: ThreadContext) => Promise; export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; -- 2.43.0 From e6093c35db6d6a008ff4c7d027d6ca4acd16b01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 08:09:05 +0000 Subject: [PATCH 6/7] docs: update knowledge cards for RFC-005 (ThreadContext, AgentFn) --- .knowledge/adapter.md | 4 ++-- .knowledge/coding-conventions.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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**: -- 2.43.0 From bfadfffd4092a69fdd55149b7d7f27b0a5cf9400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Thu, 30 Apr 2026 08:27:07 +0000 Subject: [PATCH 7/7] fix: move isDryRun to value export (not type-only) --- packages/workflow-utils/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 7500025..418bfef 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -36,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, @@ -50,6 +51,5 @@ export type { ReActRoleDefaults, ReActRoleRequired, ReActTool, - isDryRun, } from "./role-types.js"; export type { LlmChatError } from "./shared/llm-chat.js"; -- 2.43.0