Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 145a747433 |
+56
-16
@@ -5,33 +5,54 @@
|
||||
## Workspace Lifecycle
|
||||
|
||||
```bash
|
||||
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init # scaffold workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init --from <git-url> # clone existing workspace from git
|
||||
nerve init --force # reinitialize (preserves data/)
|
||||
nerve validate # validate nerve.yaml config
|
||||
nerve dev # run kernel foreground (development, Ctrl+C to stop)
|
||||
nerve dev --port 3000 # with HTTP API on specific port
|
||||
nerve start # start daemon (background)
|
||||
nerve start --port 3000 # with HTTP API port
|
||||
nerve stop # stop daemon
|
||||
nerve status # check daemon health (uptime, senses, workflows)
|
||||
nerve daemon # restart daemon (stop + start)
|
||||
nerve daemon restart # stop + start
|
||||
nerve daemon logs # alias for nerve logs
|
||||
```
|
||||
|
||||
## Sense Management
|
||||
|
||||
```bash
|
||||
nerve create sense <name> # scaffold a new sense (compute.ts + schema.ts)
|
||||
nerve sense list # list configured senses
|
||||
nerve create sense <name> --force # overwrite existing
|
||||
nerve sense list # list configured senses and status
|
||||
nerve sense trigger <name> # manually trigger a sense compute
|
||||
nerve sense schema <name> # show sense Drizzle schema
|
||||
nerve sense query <name> # inspect sense SQLite database
|
||||
nerve sense query <name> --sql "SELECT * FROM samples LIMIT 5"
|
||||
nerve sense schema <name> # print CREATE TABLE statements from sense SQLite
|
||||
nerve sense schema <name> --json # as JSON array
|
||||
nerve sense query <name> # inspect sense SQLite database (preview rows)
|
||||
nerve sense query <name> --json # rows as JSON
|
||||
```
|
||||
|
||||
## Workflow Management
|
||||
|
||||
```bash
|
||||
nerve create workflow <name> # scaffold a new workflow
|
||||
nerve create workflow <name> --force # overwrite existing
|
||||
nerve workflow list # list workflow definitions from nerve.yaml
|
||||
nerve workflow status # show live status (concurrency, active, queued)
|
||||
nerve workflow trigger <name> --prompt "..." [--max-rounds N] [--dry-run]
|
||||
nerve workflow list # list configured workflows
|
||||
nerve thread # list active (queued/started) workflow threads
|
||||
```
|
||||
|
||||
## Thread Management
|
||||
|
||||
```bash
|
||||
nerve thread list # list active (queued/started) workflow runs
|
||||
nerve thread list --all # include completed/failed/crashed
|
||||
nerve thread list --workflow <name> # filter by workflow name
|
||||
nerve thread show <runId> # print role rounds for a run (agent-oriented)
|
||||
nerve thread show <runId> --before N # limit rounds (pagination)
|
||||
nerve thread inspect <runId> # show details and thread events
|
||||
nerve thread inspect <runId> --offset N --limit N # paginate events
|
||||
nerve thread kill <runId> # kill a running or queued thread
|
||||
```
|
||||
|
||||
## Knowledge
|
||||
@@ -41,21 +62,30 @@ nerve knowledge sync # chunk files per knowledge.yaml, compute embeddin
|
||||
nerve knowledge query "text" # search indexed knowledge (cosine similarity)
|
||||
nerve knowledge query -g "text" # global search across all indexed repos
|
||||
nerve knowledge query --repo /path "text" # search specific repo
|
||||
nerve knowledge query "text" --limit 20 # max hits (default 10)
|
||||
```
|
||||
|
||||
## Logs & Store
|
||||
|
||||
```bash
|
||||
nerve logs # view daemon logs (last 50 lines)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve logs # show daemon log output (last 50 lines)
|
||||
nerve logs -n 200 # last N lines
|
||||
nerve store archive # archive old log entries to JSONL
|
||||
nerve logs --offset 100 # start from line N (pagination)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve store archive # archive logs older than 30 days to JSONL
|
||||
nerve store archive --vacuum # also run SQLite VACUUM after archiving
|
||||
```
|
||||
|
||||
## Remote
|
||||
## Remote Management
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <url> # add a remote daemon endpoint
|
||||
nerve remote add <name> <host:port> [--token <token>] # add remote daemon
|
||||
nerve remote list # list all remotes
|
||||
nerve remote show <name> # show remote details
|
||||
nerve remote set-url <name> <host:port> # update remote host
|
||||
nerve remote set-token <name> <token> # update remote token
|
||||
nerve remote remove <name> # remove a remote
|
||||
nerve remote default [<name>] # set or show default remote
|
||||
nerve status --remote <name> # check remote daemon health
|
||||
```
|
||||
|
||||
@@ -67,12 +97,22 @@ my-agent/
|
||||
knowledge.yaml # knowledge index config (optional)
|
||||
senses/
|
||||
cpu-usage/
|
||||
compute.ts # sense implementation
|
||||
schema.ts # Drizzle schema
|
||||
migrations/ # auto-generated
|
||||
src/
|
||||
index.ts # sense compute implementation
|
||||
schema.ts # Drizzle schema (single source of truth)
|
||||
migrations/ # auto-generated by drizzle-kit
|
||||
package.json # with esbuild build script
|
||||
index.js # bundled output (generated by pnpm build)
|
||||
workflows/
|
||||
cleanup/
|
||||
src/index.ts # workflow definition
|
||||
build.ts # factory function
|
||||
moderator.ts # moderator + meta types
|
||||
roles/ # one file per role
|
||||
package.json
|
||||
data/
|
||||
senses/ # per-sense SQLite databases
|
||||
archive/ # archived logs (JSONL)
|
||||
knowledge.db # generated by nerve knowledge sync
|
||||
.knowledge/ # curated knowledge cards
|
||||
```
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# RFC-004: Package Architecture — Shareable Workflows, Roles & Senses
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Status:** Draft
|
||||
**Created:** 2026-04-29
|
||||
|
||||
## Summary
|
||||
|
||||
Make workflows, roles, and senses publishable as lightweight npm packages. Workspaces become pure configuration — selecting packages, wiring adapters, and providing credentials. No builtin workflows in the nerve core.
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently, workflows like `develop-sense` and `develop-workflow` live inside the workspace (`~/.uncaged-nerve/workflows/`). This creates problems:
|
||||
|
||||
1. **No sharing** — every workspace duplicates the same workflow code
|
||||
2. **No versioning** — upgrading a workflow means manual file edits
|
||||
3. **Builtin is a trap** — if we bake workflows into nerve core, they require adapters and LLM providers that may not be installed. A fresh `nerve` install on a bare machine would fail to load builtins.
|
||||
4. **Roles are already shared** — `_shared/workspace-committer.ts` proves the pattern works; we just need to formalize it as packages
|
||||
|
||||
The adapter pattern (`@uncaged/nerve-adapter-hermes`, `@uncaged/nerve-adapter-cursor`) already established the precedent: infrastructure as packages, workspace as wiring.
|
||||
|
||||
## Design
|
||||
|
||||
### Package Taxonomy
|
||||
|
||||
```
|
||||
@uncaged/nerve-core # types, engine
|
||||
@uncaged/nerve-daemon # runtime
|
||||
@uncaged/nerve-workflow-utils # createRole, decorateRole, withDryRun, onFail, etc.
|
||||
|
||||
# Adapters (existing)
|
||||
@uncaged/nerve-adapter-hermes
|
||||
@uncaged/nerve-adapter-cursor
|
||||
|
||||
# Workflows (new)
|
||||
@uncaged/nerve-workflow-solve-issue
|
||||
@uncaged/nerve-workflow-develop-sense
|
||||
@uncaged/nerve-workflow-develop-workflow
|
||||
|
||||
# Shared Roles (new)
|
||||
@uncaged/nerve-role-committer # workspace committer (branch, commit, push)
|
||||
@uncaged/nerve-role-reviewer # code review role
|
||||
@uncaged/nerve-role-publisher # PR creation role
|
||||
|
||||
# Senses (existing pattern, formalized)
|
||||
@uncaged/nerve-sense-cpu-usage
|
||||
@uncaged/nerve-sense-disk-usage
|
||||
```
|
||||
|
||||
### Package Contract
|
||||
|
||||
Each package type exports a factory function:
|
||||
|
||||
#### Workflow Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-workflow-develop-sense
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type SenseMeta = { /* ... */ };
|
||||
|
||||
export type CreateDevelopSenseDeps = {
|
||||
defaultAdapter: AgentFn;
|
||||
adapters?: Partial<Record<keyof SenseMeta, AgentFn>>;
|
||||
extract: LlmExtractorConfig;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta>;
|
||||
```
|
||||
|
||||
Key design decisions:
|
||||
- `defaultAdapter` + optional `adapters` override per role — via `Partial<Record<keyof Meta, AgentFn>>`
|
||||
- Adapter keys are derived from `Meta` type — adding/removing a role automatically updates the adapter map
|
||||
- Roles that don't need an agent simply don't appear in `adapters` (the `Partial` allows this)
|
||||
|
||||
#### Role Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-role-committer
|
||||
import type { AgentFn, Role } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type CommitterMeta = { committed: boolean };
|
||||
|
||||
export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CommitterMeta> {
|
||||
const inner = createRole(adapter, prompt, committerMetaSchema, extract);
|
||||
return decorateRole(inner, [
|
||||
withDryRun({ label: "committer", meta: { committed: true } }),
|
||||
onFail({ label: "committer", meta: { committed: false } }),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Roles compose with the decorator chain from `workflow-utils`:
|
||||
- `withDryRun` — skip execution in dry-run mode
|
||||
- `onFail` — catch errors into structured failure results
|
||||
- `decorateRole(role, [...])` — apply decorators left-to-right
|
||||
- Custom `RoleDecorator<M>` can be created for project-specific needs
|
||||
|
||||
#### Sense Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-sense-cpu-usage
|
||||
export const senseName = "cpu-usage";
|
||||
export const schema = { /* drizzle schema */ };
|
||||
export async function compute(ctx: SenseContext): Promise<SenseResult>;
|
||||
```
|
||||
|
||||
### Workspace as Configuration
|
||||
|
||||
The workspace becomes a thin wiring layer:
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/
|
||||
nerve.yaml # senses, extract config
|
||||
package.json # depends on workflow/role/adapter packages
|
||||
workflows/
|
||||
develop-sense/
|
||||
index.ts # ~10 lines: import package, wire adapters, export
|
||||
solve-issue/
|
||||
index.ts # same pattern
|
||||
```
|
||||
|
||||
A typical `index.ts`:
|
||||
|
||||
```ts
|
||||
import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-develop-sense";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
|
||||
export default createDevelopSenseWorkflow({
|
||||
defaultAdapter: hermesAdapter,
|
||||
adapters: { planner: cursorAdapter, coder: cursorAdapter },
|
||||
extract: { provider: { apiKey, baseUrl, model } },
|
||||
cwd: nerveRoot,
|
||||
});
|
||||
```
|
||||
|
||||
### What Stays in Workspace
|
||||
|
||||
- **Custom workflows** — project-specific workflows that aren't general enough to share
|
||||
- **Custom senses** — project-specific metrics
|
||||
- **Configuration** — adapter selection, credentials, `nerve.yaml`
|
||||
- **Overrides** — a workspace can always write its own role/workflow instead of using a package
|
||||
|
||||
### Dependency Rules
|
||||
|
||||
```
|
||||
nerve-core ← no deps on other nerve packages
|
||||
nerve-workflow-utils ← depends on nerve-core
|
||||
nerve-adapter-* ← depends on nerve-core
|
||||
nerve-role-* ← depends on nerve-core, nerve-workflow-utils
|
||||
nerve-workflow-* ← depends on nerve-core, nerve-workflow-utils, may depend on nerve-role-*
|
||||
nerve-sense-* ← depends on nerve-core
|
||||
nerve-daemon ← depends on nerve-core, nerve-store
|
||||
```
|
||||
|
||||
Workflow packages depend on role packages (not adapters). Adapters are injected at the workspace level.
|
||||
|
||||
### Migration Path
|
||||
|
||||
1. **Phase 1: Extract role packages** — Start with `@uncaged/nerve-role-committer` (already `_shared/workspace-committer.ts`). Publish, update workspace to import from package.
|
||||
2. **Phase 2: Extract workflow packages** — Move `develop-sense` and `develop-workflow` to packages. Workspace `index.ts` becomes pure wiring.
|
||||
3. **Phase 3: Sense packages** — Formalize sense packaging (lower priority, senses are already self-contained directories).
|
||||
4. **Phase 4: Community** — Document the package contract so others can publish workflows/roles/senses.
|
||||
|
||||
### Not in Scope
|
||||
|
||||
- **No builtin workflows** — nerve core ships zero workflows. All workflows are packages installed by the workspace.
|
||||
- **No workflow marketplace/registry** — just npm packages. `pnpm add @uncaged/nerve-workflow-solve-issue`.
|
||||
- **No nerve.yaml workflow declaration** — workflows are still TypeScript entry points. The daemon discovers them the same way it does today.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Monorepo vs separate repos?** — Should workflow/role packages live in the nerve monorepo or separate repos? Monorepo is easier for coordinated releases; separate repos allow independent versioning.
|
||||
2. **Sense package format** — Senses currently bundle with esbuild. Should sense packages ship pre-bundled or as TypeScript source?
|
||||
3. **Version coupling** — How tightly should workflow packages pin `nerve-core`? Peer deps with semver range?
|
||||
|
||||
## Prior Art
|
||||
|
||||
- Adapter packages (`@uncaged/nerve-adapter-*`) — established the factory + injection pattern
|
||||
- `_shared/workspace-committer.ts` — proved roles can be shared across workflows
|
||||
- `createRole` / `decorateRole` / `withDryRun` / `onFail` in `workflow-utils` — building blocks that role packages compose
|
||||
- `defaultAdapter` + `Partial<Record<keyof Meta, AgentFn>>` pattern — adapter injection without coupling
|
||||
@@ -84,22 +84,16 @@ function throwCursorSpawnError(error: SpawnError): never {
|
||||
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
|
||||
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
|
||||
|
||||
export type CursorAdapterConfig = AgentConfig & {
|
||||
/** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */
|
||||
mode?: CursorAgentMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
|
||||
*/
|
||||
export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
|
||||
export function createCursorAdapter(config: AgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
const mode = config.mode ?? "default";
|
||||
|
||||
return async (prompt: string, context: WorkflowContext): Promise<string> => {
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode,
|
||||
mode: "default",
|
||||
model: config.model,
|
||||
cwd: context.workdir,
|
||||
env: null,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* RFC-003 Phase 5: nerve validate — workflow adapter usage and extract.
|
||||
* RFC-003 Phase 5: nerve validate — WorkflowSpec adapter usage and extract.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -38,8 +38,9 @@ describe("validateAgentConfigurationLayer", () => {
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
|
||||
`
|
||||
import type { WorkflowSpec } from "@uncaged/nerve-core";
|
||||
const adapter = async () => "";
|
||||
const spec = {
|
||||
const spec: WorkflowSpec<{ r: { x: number } }> = {
|
||||
name: "demo",
|
||||
roles: {
|
||||
r: { adapter: adapter, prompt: "p", meta: {} as never },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Detects `adapter:` usage in workflow TypeScript sources (e.g. createRole wiring).
|
||||
* Detects RoleSpec `adapter:` usage in workflow TypeScript sources.
|
||||
* NOTE: This regex can match occurrences inside comments.
|
||||
*/
|
||||
const WORKFLOW_SPEC_ADAPTER_PATTERN = /adapter:\s*[a-zA-Z_$]/;
|
||||
@@ -26,7 +26,7 @@ function collectTsSourceFiles(dir: string, acc: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when any workflow `src` tree appears to use roles with adapters.
|
||||
* Returns true when any workflow `src` tree appears to use WorkflowSpec roles with adapters.
|
||||
*/
|
||||
export function workflowSourcesDeclareAdapterRoles(nerveRoot: string): boolean {
|
||||
const workflowsRoot = join(nerveRoot, "workflows");
|
||||
@@ -66,7 +66,7 @@ export function validateAgentConfigurationLayer(
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"extract: required when workflow roles use adapters (configure extract.provider and extract.model)",
|
||||
"extract: required when WorkflowSpec roles use adapters (configure extract.provider and extract.model)",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export type {
|
||||
WorkflowDefinition,
|
||||
} from "./workflow.js";
|
||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
export type { PromptInput, RoleSpec, WorkflowSpec } from "./workflow-spec.js";
|
||||
export { parseDurationStringToMs } from "./duration.js";
|
||||
export type { Schema, ExtractFn } from "./extract-layer.js";
|
||||
export { ExtractError } from "./extract-layer.js";
|
||||
|
||||
@@ -330,7 +330,7 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
|
||||
if (Object.hasOwn(obj, "agents")) {
|
||||
return err(
|
||||
new Error(
|
||||
"agents: key is no longer supported — declare adapters on workflow roles (RFC-003)",
|
||||
"agents: key is no longer supported — declare adapters on WorkflowSpec roles (RFC-003)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Schema } from "./extract-layer.js";
|
||||
import type { AgentFn, Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js";
|
||||
|
||||
/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */
|
||||
export type PromptInput =
|
||||
| string
|
||||
| ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
|
||||
|
||||
/**
|
||||
* Authoring-time role: adapter function, prompt, extract schema (RFC-003).
|
||||
* Compiles to runtime `Role<Meta>` via `compileWorkflowSpec`.
|
||||
*/
|
||||
export type RoleSpec<Meta extends Record<string, unknown>> = {
|
||||
adapter: AgentFn;
|
||||
prompt: PromptInput;
|
||||
meta: Schema<Meta>;
|
||||
};
|
||||
|
||||
/** User-facing workflow authoring shape; compiles to `WorkflowDefinition`. */
|
||||
export type WorkflowSpec<M extends RoleMeta> = {
|
||||
name: string;
|
||||
roles: { [K in keyof M]: RoleSpec<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
AgentFn,
|
||||
ModeratorContext,
|
||||
RoleMeta,
|
||||
Schema,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
WorkflowSpec,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START } from "@uncaged/nerve-core";
|
||||
|
||||
import { compileWorkflowSpec } from "../compile-workflow-spec.js";
|
||||
|
||||
type DemoMeta = { n: number };
|
||||
|
||||
function echoAdapter(): AgentFn {
|
||||
return async (prompt: string, _ctx: WorkflowContext) => prompt;
|
||||
}
|
||||
|
||||
function makeStart(threadId = "t1"): StartStep {
|
||||
return {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: false, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(start: StartStep, messages: WorkflowMessage[]): WorkflowContext {
|
||||
return {
|
||||
start,
|
||||
messages,
|
||||
workdir: "/tmp/repo",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compileWorkflowSpec", () => {
|
||||
it("compiles WorkflowSpec to WorkflowDefinition shape", () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "demo",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: echoAdapter(),
|
||||
prompt: "hello",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
expect(def.name).toBe("demo");
|
||||
expect(typeof def.roles.main).toBe("function");
|
||||
expect(def.moderator).toBe(spec.moderator);
|
||||
});
|
||||
|
||||
it("runs AgentFn then ExtractFn in order", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const order: string[] = [];
|
||||
|
||||
const baseEcho = echoAdapter();
|
||||
const spyAgent: AgentFn = async (prompt, ctx) => {
|
||||
order.push("agent");
|
||||
return baseEcho(prompt, ctx);
|
||||
};
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "order-test",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: spyAgent,
|
||||
prompt: "ping",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _sch: Schema<T>) => {
|
||||
order.push("extract");
|
||||
return { n: raw.length } as T;
|
||||
},
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
const start = makeStart();
|
||||
await def.roles.main(start, []);
|
||||
|
||||
expect(order).toEqual(["agent", "extract"]);
|
||||
});
|
||||
|
||||
it("passes WorkflowContext from createContext to AgentFn (adapter owns timeout)", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const seenCtx: WorkflowContext[] = [];
|
||||
|
||||
const adapter: AgentFn = async (_prompt, ctx) => {
|
||||
seenCtx.push(ctx);
|
||||
return "x";
|
||||
};
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "ctx",
|
||||
roles: {
|
||||
main: {
|
||||
adapter,
|
||||
prompt: "x",
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
await compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(_raw: string, _s: Schema<T>) => ({ n: 0 }) as T,
|
||||
createContext: makeContext,
|
||||
}).roles.main(makeStart(), []);
|
||||
|
||||
expect(seenCtx).toHaveLength(1);
|
||||
expect(seenCtx[0].workdir).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
it("resolves dynamic prompt functions before AgentFn", async () => {
|
||||
const witness: DemoMeta | null = null;
|
||||
const schema: Schema<DemoMeta> = { witness };
|
||||
|
||||
const spec: WorkflowSpec<{ main: DemoMeta }> = {
|
||||
name: "dyn",
|
||||
roles: {
|
||||
main: {
|
||||
adapter: echoAdapter(),
|
||||
prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
|
||||
meta: schema,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
|
||||
const def = compileWorkflowSpec(spec, {
|
||||
extractFn: async <T>(raw: string, _s: Schema<T>) => ({ n: raw.length }) as T,
|
||||
createContext: makeContext,
|
||||
});
|
||||
|
||||
const start = makeStart("thread-x");
|
||||
const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }];
|
||||
const out = await def.roles.main(start, msgs);
|
||||
expect(out.content).toBe("tid=thread-x n=1");
|
||||
expect(out.meta.n).toBe(out.content.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
|
||||
type M = RoleMeta & { legacy: { id: string } };
|
||||
|
||||
const manual: WorkflowDefinition<M> = {
|
||||
name: "legacy",
|
||||
roles: {
|
||||
legacy: async (_start, _messages) => ({
|
||||
content: "hi",
|
||||
meta: { id: "a" },
|
||||
}),
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<M>) => END,
|
||||
};
|
||||
|
||||
const start = makeStart();
|
||||
const out = await manual.roles.legacy(start, []);
|
||||
expect(out.content).toBe("hi");
|
||||
expect(out.meta.id).toBe("a");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
Role,
|
||||
RoleMeta,
|
||||
RoleSpec,
|
||||
Schema,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
WorkflowSpec,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
export type CompileWorkflowSpecDeps = {
|
||||
/**
|
||||
* Typed extraction for agent raw output (global/role merge applied before compile).
|
||||
*/
|
||||
extractFn: <T>(raw: string, schema: Schema<T>) => Promise<T>;
|
||||
/** Builds thread context for each role invocation (workdir, cancellation, etc.). */
|
||||
createContext: (start: StartStep, messages: WorkflowMessage[]) => WorkflowContext;
|
||||
};
|
||||
|
||||
function compileRoleForSpec<Meta extends Record<string, unknown>>(
|
||||
roleSpec: RoleSpec<Meta>,
|
||||
deps: CompileWorkflowSpecDeps,
|
||||
): Role<Meta> {
|
||||
return async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
const ctx = deps.createContext(start, messages);
|
||||
|
||||
const promptText =
|
||||
typeof roleSpec.prompt === "string"
|
||||
? roleSpec.prompt
|
||||
: await roleSpec.prompt(start, messages);
|
||||
|
||||
const raw = await roleSpec.adapter(promptText, ctx);
|
||||
const meta = await deps.extractFn(raw, roleSpec.meta);
|
||||
return { content: raw, meta };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns RFC-003 `WorkflowSpec` into engine `WorkflowDefinition`: wires adapters and extract per role.
|
||||
*/
|
||||
export function compileWorkflowSpec<M extends RoleMeta>(
|
||||
spec: WorkflowSpec<M>,
|
||||
deps: CompileWorkflowSpecDeps,
|
||||
): WorkflowDefinition<M> {
|
||||
const roleKeys = Object.keys(spec.roles) as Array<keyof M & string>;
|
||||
const roles = {} as WorkflowDefinition<M>["roles"];
|
||||
|
||||
for (const key of roleKeys) {
|
||||
roles[key] = compileRoleForSpec(spec.roles[key], deps);
|
||||
}
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
roles,
|
||||
moderator: spec.moderator,
|
||||
};
|
||||
}
|
||||
@@ -58,4 +58,6 @@ export type {
|
||||
export { createWorkflowManager } from "./workflow-manager.js";
|
||||
export type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export { compileWorkflowSpec } from "./compile-workflow-spec.js";
|
||||
export type { CompileWorkflowSpecDeps } from "./compile-workflow-spec.js";
|
||||
export { createEchoAgent } from "./agent-adapters/echo.js";
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import type {
|
||||
AgentFn,
|
||||
ModeratorContext,
|
||||
RoleMeta,
|
||||
WorkflowContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START } from "@uncaged/nerve-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createRole } from "../create-role.js";
|
||||
import * as extractFn from "../shared/extract-fn.js";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
};
|
||||
|
||||
function toolCallResponse(argsJson: string): {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: () => Promise<string>;
|
||||
} {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: argsJson,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStart(threadId: string): {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number; dryRun: boolean; threadId: string };
|
||||
timestamp: number;
|
||||
} {
|
||||
return {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10, dryRun: false, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRole", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs AgentFn then structured extract", async () => {
|
||||
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 out = await role(makeStart("t1"), []);
|
||||
expect(out.content).toBe("hello");
|
||||
expect(out.meta).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
it("passes WorkflowContext with workdir defaulting to process.cwd()", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(toolCallResponse(JSON.stringify({ n: 0 }))));
|
||||
|
||||
const seen: WorkflowContext[] = [];
|
||||
const adapter: AgentFn = async (_prompt, ctx) => {
|
||||
seen.push(ctx);
|
||||
return "x";
|
||||
};
|
||||
const role = createRole(adapter, "p", z.object({ n: z.number() }), { provider });
|
||||
await role(makeStart("t1"), []);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].workdir).toBe(process.cwd());
|
||||
});
|
||||
|
||||
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 role = createRole(
|
||||
adapter,
|
||||
async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`,
|
||||
schema,
|
||||
{ provider },
|
||||
);
|
||||
|
||||
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");
|
||||
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 () => {
|
||||
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,
|
||||
});
|
||||
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({ dryRun: false }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowDefinition compatibility", () => {
|
||||
it("hand-written Role-based WorkflowDefinition remains valid", async () => {
|
||||
type M = RoleMeta & { legacy: { id: string } };
|
||||
|
||||
const manual: WorkflowDefinition<M> = {
|
||||
name: "legacy",
|
||||
roles: {
|
||||
legacy: async (_start, _messages) => ({
|
||||
content: "hi",
|
||||
meta: { id: "a" },
|
||||
}),
|
||||
},
|
||||
moderator: (_ctx: ModeratorContext<M>) => END,
|
||||
};
|
||||
|
||||
const start = makeStart("t1");
|
||||
const out = await manual.roles.legacy(start, []);
|
||||
expect(out.content).toBe("hi");
|
||||
expect(out.meta.id).toBe("a");
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
Role,
|
||||
RoleResult,
|
||||
StartStep,
|
||||
WorkflowMessage,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
|
||||
|
||||
type TestMeta = { ok: boolean };
|
||||
|
||||
function fakeStart(dryRun: boolean): StartStep {
|
||||
return {
|
||||
role: "test",
|
||||
meta: {
|
||||
threadId: "t1",
|
||||
dryRun,
|
||||
startedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const successRole: Role<TestMeta> = async () => ({
|
||||
content: "done",
|
||||
meta: { ok: true },
|
||||
});
|
||||
|
||||
const failRole: Role<TestMeta> = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
const failNonErrorRole: Role<TestMeta> = async () => {
|
||||
throw "string error";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withDryRun
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("withDryRun", () => {
|
||||
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true } });
|
||||
|
||||
it("short-circuits on dry-run", async () => {
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeStart(true), []);
|
||||
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), []);
|
||||
expect(result.content).toBe("done");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onFail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("onFail", () => {
|
||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
||||
|
||||
it("passes through on success", async () => {
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeStart(false), []);
|
||||
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), []);
|
||||
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), []);
|
||||
expect(result.content).toBe("test failed: string error");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decorateRole
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 } }),
|
||||
]);
|
||||
// Not dry-run, so withDryRun passes through → failRole throws → onFail catches
|
||||
const result = await role(fakeStart(false), []);
|
||||
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 } }),
|
||||
]);
|
||||
const result = await role(fakeStart(true), []);
|
||||
expect(result.content).toBe("[dry-run] x skipped");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type {
|
||||
AgentFn,
|
||||
Role,
|
||||
StartStep,
|
||||
WorkflowContext,
|
||||
WorkflowMessage,
|
||||
} 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>);
|
||||
|
||||
export type LlmExtractorConfig = {
|
||||
provider: LlmProvider;
|
||||
/** When omitted, uses `start.meta.dryRun` at runtime. */
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Builds a Role from an AgentFn, prompt, Zod meta schema, and LLM extract config. */
|
||||
export function createRole<M extends Record<string, unknown>>(
|
||||
adapter: AgentFn,
|
||||
prompt: PromptInput,
|
||||
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);
|
||||
const result = await extractMetaOrThrow(raw, meta, {
|
||||
provider: extract.provider,
|
||||
dryRun: resolveDryRun(extract, start),
|
||||
});
|
||||
return { content: raw, meta: result };
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Primary API — role factory templates
|
||||
export { createRole, type LlmExtractorConfig } from "./create-role.js";
|
||||
export { createCursorRole } from "./role-cursor.js";
|
||||
export { createHermesRole } from "./role-hermes.js";
|
||||
export { createLlmRole } from "./role-llm.js";
|
||||
@@ -10,7 +9,6 @@ export {
|
||||
assertZodMetaSchemas,
|
||||
createLlmExtractFn,
|
||||
extractMetaOrThrow,
|
||||
zodMeta,
|
||||
type ZodMetaSchema,
|
||||
} from "./shared/extract-fn.js";
|
||||
export {
|
||||
@@ -20,14 +18,6 @@ export {
|
||||
type ReadNerveYamlOptions,
|
||||
} from "./shared/context.js";
|
||||
export { isDryRun } from "./role-types.js";
|
||||
export {
|
||||
decorateRole,
|
||||
withDryRun,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type WithDryRunOptions,
|
||||
type OnFailOptions,
|
||||
} from "./role-decorators.js";
|
||||
export {
|
||||
nerveCommandEnv,
|
||||
spawnSafe,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { Role, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
|
||||
import { isDryRun } from "./role-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorator types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A role decorator: takes a role, returns an enhanced role. */
|
||||
export type RoleDecorator<M extends Record<string, unknown>> = (
|
||||
role: Role<M>,
|
||||
) => Role<M>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decorateRole — compose a chain of decorators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply an ordered list of decorators to a role.
|
||||
* Decorators are applied left-to-right (first in list wraps innermost).
|
||||
*
|
||||
* ```ts
|
||||
* decorateRole(role, [withDryRun(opts), onFail(opts)]);
|
||||
* // equivalent to: onFail(opts)(withDryRun(opts)(role))
|
||||
* ```
|
||||
*/
|
||||
export function decorateRole<M extends Record<string, unknown>>(
|
||||
role: Role<M>,
|
||||
decorators: RoleDecorator<M>[],
|
||||
): Role<M> {
|
||||
return decorators.reduce((r, dec) => dec(r), role);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withDryRun — skip execution when dry-run is active
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WithDryRunOptions<M> = {
|
||||
/** Used in skip message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when dry-run skips execution. */
|
||||
meta: M;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a decorator that short-circuits with a stable result when
|
||||
* `start.meta.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 {
|
||||
content: `[dry-run] ${opts.label} skipped`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
return role(start, messages);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onFail — catch errors and return a structured failure result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OnFailOptions<M> = {
|
||||
/** Used in failure message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when the inner role throws. */
|
||||
meta: M;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a decorator that catches thrown errors and converts them into
|
||||
* a structured RoleResult instead of propagating.
|
||||
*/
|
||||
export function onFail<M extends Record<string, unknown>>(
|
||||
opts: OnFailOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
return (role) =>
|
||||
async (start: StartStep, messages: WorkflowMessage[]) => {
|
||||
try {
|
||||
return await role(start, messages);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `${opts.label} failed: ${msg}`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,11 +10,6 @@ import { llmErrorToCause, llmExtractWithRetry } from "./llm-extract.js";
|
||||
*/
|
||||
export type ZodMetaSchema<T> = Schema<T> & { readonly zod: z.ZodType<T> };
|
||||
|
||||
/** Builds a core `Schema<T>` plus Zod parser for `createRole` meta / `createLlmExtractFn`. */
|
||||
export function zodMeta<T>(zod: z.ZodType<T>): ZodMetaSchema<T> {
|
||||
return { witness: null, zod };
|
||||
}
|
||||
|
||||
export async function extractMetaOrThrow<T>(
|
||||
raw: string,
|
||||
zodSchema: z.ZodType<T>,
|
||||
@@ -50,8 +45,8 @@ export function createLlmExtractFn<T>(deps: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that all schemas are ZodMetaSchema before any role is invoked.
|
||||
* Call this once at daemon startup / hot-reload when wiring roles manually.
|
||||
* Validate that all schemas in a WorkflowSpec are ZodMetaSchema at compile time,
|
||||
* before any role is ever invoked. Call this once at daemon startup / hot-reload.
|
||||
*/
|
||||
export function assertZodMetaSchemas(schemas: Record<string, Schema<unknown>>): void {
|
||||
for (const [roleName, schema] of Object.entries(schemas)) {
|
||||
|
||||
Reference in New Issue
Block a user