refactor: RFC-005 — Separate Agent and Role types #272
@@ -5,10 +5,10 @@ Adapter = capability. Role = scenario. Workflows declare adapters directly via i
|
||||
## AgentFn Protocol
|
||||
|
||||
```ts
|
||||
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>
|
||||
```
|
||||
|
||||
- Input: prompt + context (start frame, messages, workdir, AbortSignal)
|
||||
- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity)
|
||||
- Output: **single-shot `Promise<string>`** — no streaming support
|
||||
- Adapter handles tool-specific details internally
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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<RoleResult<M>>`
|
||||
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
|
||||
|
||||
## Thread Lifecycle
|
||||
|
||||
@@ -54,7 +54,7 @@ const workflow: WorkflowDefinition<MyMeta> = {
|
||||
```
|
||||
|
||||
- `adapter: AgentFn` — direct function reference
|
||||
- `prompt: string | ((start, messages) => Promise<string>)` — static or dynamic
|
||||
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
|
||||
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
|
||||
- `extract: LlmExtractorConfig` — provider for structured extraction
|
||||
|
||||
@@ -85,7 +85,7 @@ const workflow: WorkflowDefinition<MyMeta> = {
|
||||
- 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
|
||||
|
||||
@@ -98,6 +98,31 @@ type ComputeResult<T> =
|
||||
| { 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<RoleResult<MyMeta>> {
|
||||
void ctx.start;
|
||||
void ctx.steps;
|
||||
return { content: "plan", meta: { round: ctx.steps.length } };
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
|
||||
name: "example",
|
||||
roles: { planner },
|
||||
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
|
||||
return ctx.steps.length === 0 ? "planner" : END;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
|
||||
@@ -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<string> => {
|
||||
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
|
||||
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);
|
||||
|
||||
@@ -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<string> => {
|
||||
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
|
||||
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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<Record<"main", MainMeta>> = {
|
||||
roles: {
|
||||
main: mainRole,
|
||||
},
|
||||
moderator({ steps }) {
|
||||
if (steps.length === 0) {
|
||||
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
|
||||
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<RoleResult<Record<string, unknown>>> {
|
||||
void start;
|
||||
void messages;
|
||||
void ctx;
|
||||
// TODO: implement your role logic here
|
||||
return {
|
||||
content: "${name} started",
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
Role,
|
||||
RoleMeta,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowContext,
|
||||
AgentFn,
|
||||
RoleStep,
|
||||
|
||||
@@ -21,39 +21,41 @@ export type WorkflowMessage = {
|
||||
/** The typed output of a Role execution. */
|
||||
export type RoleResult<Meta> = { 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<Meta> = (
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
) => Promise<RoleResult<Meta>>;
|
||||
|
||||
/** Maps role names to their meta types — the single generic that drives all inference. */
|
||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** 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<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
messages: WorkflowMessage[];
|
||||
workdir: string;
|
||||
signal: AbortSignal;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @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<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
|
||||
|
||||
/** Unified agent invocation — raw string output; structured meta uses the extract layer. */
|
||||
export type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>;
|
||||
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
|
||||
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
@@ -61,23 +63,17 @@ export type RoleStep<M extends RoleMeta> = {
|
||||
}[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<M extends RoleMeta> = {
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
export type ModeratorContext<M extends RoleMeta> = ThreadContext<M>;
|
||||
|
||||
/**
|
||||
* 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<M extends RoleMeta> = (
|
||||
context: ModeratorContext<M>,
|
||||
) => (keyof M & string) | END;
|
||||
export type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
|
||||
|
||||
/** The complete definition of a workflow, as authored by users. */
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<RoleMeta>[] {
|
||||
return messages.map((m) => ({
|
||||
role: m.role,
|
||||
meta: m.meta as Record<string, unknown>,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})) as RoleStep<RoleMeta>[];
|
||||
}
|
||||
|
||||
function buildThreadContext(
|
||||
start: StartStep,
|
||||
roleMessages: WorkflowMessage[],
|
||||
): ThreadContext<RoleMeta> {
|
||||
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<RoleMeta>,
|
||||
nextRole: string,
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
ctx: ThreadContext<RoleMeta>,
|
||||
runId: string,
|
||||
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
|
||||
const role = def.roles[nextRole];
|
||||
@@ -183,7 +212,7 @@ async function executeRole(
|
||||
|
||||
let result: { content: string; meta: Record<string, unknown> };
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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 });
|
||||
|
||||
@@ -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<CommitterMeta> {
|
||||
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<CommitterMeta>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<CoderMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
|
||||
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
|
||||
coderMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<PlannerMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
|
||||
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
|
||||
plannerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<TesterMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
|
||||
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<CoderMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => coderPrompt({ threadId: start.meta.threadId }),
|
||||
async (ctx: ThreadContext) => coderPrompt({ threadId: ctx.threadId }),
|
||||
coderMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<PlannerMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => plannerPrompt({ threadId: start.meta.threadId }),
|
||||
async (ctx: ThreadContext) => plannerPrompt({ threadId: ctx.threadId }),
|
||||
plannerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<TesterMeta> {
|
||||
return createRole(
|
||||
adapter,
|
||||
async (start: StartStep) => testerPrompt({ threadId: start.meta.threadId, nerveRoot }),
|
||||
async (ctx: ThreadContext) => testerPrompt({ threadId: ctx.threadId, nerveRoot }),
|
||||
testerMetaSchema,
|
||||
extract,
|
||||
);
|
||||
|
||||
@@ -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<M> = {
|
||||
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<M>) => 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");
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> & { 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<TestMeta> = async () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("withDryRun", () => {
|
||||
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true } });
|
||||
const dec = withDryRun<TestMeta>({ 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<TestMeta>({ 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<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
|
||||
onFail<TestMeta>({ 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<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
|
||||
onFail<TestMeta>({ 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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<string>);
|
||||
type PromptInput = string | ((ctx: ThreadContext) => Promise<string>);
|
||||
|
||||
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<M extends Record<string, unknown>>(
|
||||
meta: z.ZodType<M>,
|
||||
extract: LlmExtractorConfig,
|
||||
): Role<M> {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
|
||||
@@ -27,14 +27,14 @@ function pick<T>(opts: Record<string, unknown>, key: string, fallback: T): T {
|
||||
export function createCursorRole<T>(
|
||||
options: CursorRoleRequired<T> & Partial<CursorRoleDefaults>,
|
||||
): Role<T> {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
return async (ctx) => {
|
||||
const d = CURSOR_DEFAULTS;
|
||||
const mode = pick<CursorAgentMode>(options as Record<string, unknown>, "mode", d.mode);
|
||||
const model = pick<string>(options as Record<string, unknown>, "model", d.model);
|
||||
const env = pick<SpawnEnv>(options as Record<string, unknown>, "env", d.env);
|
||||
const timeoutMs = pick<number>(options as Record<string, unknown>, "timeoutMs", d.timeoutMs);
|
||||
const prompt = await options.prompt(start.meta.threadId);
|
||||
const dry = pick<boolean>(options as Record<string, unknown>, "dryRun", d.dryRun);
|
||||
const prompt = await options.prompt(ctx);
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode,
|
||||
|
||||
@@ -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<M> = {
|
||||
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<M extends Record<string, unknown>>(
|
||||
opts: WithDryRunOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
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<M> = {
|
||||
export function onFail<M extends Record<string, unknown>>(
|
||||
opts: OnFailOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
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 {
|
||||
|
||||
@@ -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<T>(
|
||||
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
|
||||
): Role<T> {
|
||||
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<T>(
|
||||
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<T>(
|
||||
text,
|
||||
schema: options.extract.schema,
|
||||
provider: options.extract.provider,
|
||||
dryRun: dry,
|
||||
dryRun: h.dryRun,
|
||||
});
|
||||
if (!metaR.ok) {
|
||||
throw new Error(`llmExtract: ${formatLlmError(metaR.error)}`);
|
||||
|
||||
@@ -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<T>(options: LlmRoleRequired<T>): Role<T> {
|
||||
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<T>(options: LlmRoleRequired<T> & Partial<LlmRoleDefaults>): Role<T> {
|
||||
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)}`);
|
||||
|
||||
@@ -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<T>(
|
||||
options: ReActRoleRequired<T> & Partial<ReActRoleDefaults>,
|
||||
): Role<T> {
|
||||
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,
|
||||
|
||||
@@ -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<string>;
|
||||
export type CliPromptFn = (ctx: ThreadContext) => Promise<string>;
|
||||
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
|
||||
export type LlmPromptFn = (threadId: string) => Promise<LlmMessage[]>;
|
||||
export type LlmPromptFn = (ctx: ThreadContext) => Promise<LlmMessage[]>;
|
||||
|
||||
export type MetaExtractConfig<T> = {
|
||||
provider: LlmProvider;
|
||||
@@ -37,6 +40,7 @@ export type CursorRoleDefaults = {
|
||||
model: string;
|
||||
env: SpawnEnv;
|
||||
timeoutMs: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export type HermesRoleRequired<T> = {
|
||||
@@ -52,6 +56,7 @@ export type HermesRoleDefaults = {
|
||||
maxTurns: number;
|
||||
env: SpawnEnv;
|
||||
timeoutMs: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export type LlmRoleRequired<T> = {
|
||||
@@ -60,6 +65,10 @@ export type LlmRoleRequired<T> = {
|
||||
extract: MetaExtractConfig<T>;
|
||||
};
|
||||
|
||||
export type LlmRoleDefaults = {
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export type ReActRoleRequired<T> = {
|
||||
provider: LlmProvider;
|
||||
tools: ReActTool[];
|
||||
@@ -69,4 +78,5 @@ export type ReActRoleRequired<T> = {
|
||||
|
||||
export type ReActRoleDefaults = {
|
||||
maxIterations: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
@@ -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<T>(
|
||||
options: HermesRoleRequired<T> & Partial<HermesRoleDefaults>,
|
||||
): 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<HermesRoleDefaults>;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user