Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju 3ce9e3a846 refactor(core): restructure ModeratorContext to { start, steps }
- ModeratorContext: discriminated union → { start: StartStep; steps: RoleStep<M>[] }
- Moderator signature: (context, round, maxRounds) → (context)
- round derivable from steps.length, maxRounds from start.meta.maxRounds
- workflow-worker.ts: build steps array, pass full context to moderator
- Remove unused ModeratorContext import from workflow-worker
- Update README.md

Refs #110
2026-04-25 02:48:28 +00:00
xiaoju 0fff8ef954 Merge pull request 'refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep' (#116) from refactor/109-role-step into main 2026-04-25 02:37:03 +00:00
xiaoju beada2ae09 refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep
- RoleStep now includes content and timestamp fields (aligned with StartStep)
- ModeratorContext.signal → ModeratorContext.step
- workflow-utils: start-signal.ts → start-step.ts, isDryRun updated

Fixes #109
2026-04-25 02:34:33 +00:00
xiaoju 47d23bc1a7 Merge pull request 'refactor(store): rename LogEntry.ts → LogEntry.timestamp' (#114) from refactor/113-logentry-timestamp into main 2026-04-25 02:28:38 +00:00
7 changed files with 59 additions and 50 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
- **Sense → workflow routing** — `parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
- **Daemon IPC protocol** — request/response types (`DaemonIpcRequest`, `DaemonIpcResponse`, …) and `parseDaemonIpcRequest` for newline-delimited JSON on the CLI ↔ daemon socket
- **Workflow automaton types** — `START` / `END` sentinel constants, `WorkflowMessage`, `StartSignal`, `RoleSignal`, `Moderator`, `WorkflowDefinition`, `Role`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Workflow automaton types** — `START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths)
## Usage
+2 -2
View File
@@ -12,8 +12,8 @@ export type {
RoleResult,
Role,
RoleMeta,
StartSignal,
RoleSignal,
StartStep,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
+15 -14
View File
@@ -82,7 +82,7 @@ export type RoleResult<Meta> = { content: string; meta: Meta };
* script, HTTP request, etc.
*/
export type Role<Meta> = (
start: StartSignal,
start: StartStep,
messages: WorkflowMessage[],
) => Promise<RoleResult<Meta>>;
@@ -90,34 +90,35 @@ export type Role<Meta> = (
export type RoleMeta = Record<string, Record<string, unknown>>;
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
export type StartSignal = {
export type StartStep = {
role: START;
content: string;
meta: { maxRounds: number; dryRun: boolean };
timestamp: number;
};
/** A discriminated union of signals from each role, derived from the meta map. */
export type RoleSignal<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K] };
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
}[keyof M & string];
/**
* Moderator input: either the initial start frame or a role signal after a step.
* Lets implementations branch on `context.kind` with full typing for each arm.
* 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`.
*/
export type ModeratorContext<M extends RoleMeta> =
| { kind: "start"; start: StartSignal }
| { kind: "step"; signal: RoleSignal<M> };
export type ModeratorContext<M extends RoleMeta> = {
start: StartStep;
steps: RoleStep<M>[];
};
/**
* The moderator — a pure routing function. Receives start vs step context,
* current round, and maxRounds. Returns the next role name or END.
* The moderator — a pure routing function. Receives the full workflow context
* (start frame + all prior steps). Returns the next role name or END.
*/
export type Moderator<M extends RoleMeta> = (
context: ModeratorContext<M>,
round: number,
maxRounds: number,
) => (keyof M & string) | END;
/** The complete definition of a workflow, as authored by users. */
+34 -26
View File
@@ -12,13 +12,7 @@
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type {
ModeratorContext,
RoleMeta,
StartSignal,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
@@ -85,13 +79,13 @@ function validateRoleResult(
return true;
}
function isStartMeta(meta: unknown): meta is StartSignal["meta"] {
function isStartMeta(meta: unknown): meta is StartStep["meta"] {
return (
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
);
}
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartSignal["meta"] {
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartStep["meta"] {
if (!isPlainRecord(meta)) {
return { maxRounds: maxRoundsFallback, dryRun: false };
}
@@ -100,10 +94,7 @@ function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartSign
return { maxRounds, dryRun };
}
function startSignalFromWorkflowMessage(
msg: WorkflowMessage,
maxRoundsFallback: number,
): StartSignal {
function startStepFromWorkflowMessage(msg: WorkflowMessage, maxRoundsFallback: number): StartStep {
if (msg.role !== START) {
return {
role: START,
@@ -122,7 +113,7 @@ function startSignalFromWorkflowMessage(
}
type ThreadMessagesState = {
start: StartSignal;
start: StartStep;
/** Role outputs only; never includes the `__start__` frame. */
messages: WorkflowMessage[];
};
@@ -138,7 +129,7 @@ function initThreadMessages(
const [first, ...rest] = resumeMessages;
if (first.role === START) {
return {
start: startSignalFromWorkflowMessage(first, maxRounds),
start: startStepFromWorkflowMessage(first, maxRounds),
messages: [...rest],
};
}
@@ -154,7 +145,7 @@ function initThreadMessages(
};
}
const prompt = freshPrompt ?? "";
const start: StartSignal = {
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds, dryRun },
@@ -172,7 +163,7 @@ function initThreadMessages(
async function executeRole(
def: WorkflowDefinition<RoleMeta>,
nextRole: string,
start: StartSignal,
start: StartStep,
messages: WorkflowMessage[],
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
@@ -211,15 +202,31 @@ async function runThread(
dryRun,
);
let roleRound = roleMessages.length;
let nextRole = def.moderator({ kind: "start", start }, roleRound, maxRounds);
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,
});
}
let nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
return;
}
while (roleRound < maxRounds) {
while (steps.length < maxRounds) {
const result = await executeRole(def, nextRole, start, roleMessages, runId);
if (result === null) return;
@@ -232,13 +239,14 @@ async function runThread(
roleMessages.push(message);
sendWorkflowMessage(runId, message);
roleRound += 1;
steps.push({
role: nextRole,
meta: result.meta,
content: result.content,
timestamp: message.timestamp,
});
const stepContext: ModeratorContext<RoleMeta> = {
kind: "step",
signal: { role: nextRole, meta: result.meta },
};
nextRole = def.moderator(stepContext, roleRound, maxRounds);
nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
+1 -1
View File
@@ -19,4 +19,4 @@ export {
type SpawnResult,
type SpawnSafeOptions,
} from "./spawn-safe.js";
export { isDryRun } from "./start-signal.js";
export { isDryRun } from "./start-step.js";
@@ -1,6 +0,0 @@
import type { StartSignal } from "@uncaged/nerve-core";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartSignal): boolean {
return start.meta.dryRun;
}
@@ -0,0 +1,6 @@
import type { StartStep } from "@uncaged/nerve-core";
/** Returns the thread-level dry-run flag from the workflow start frame. */
export function isDryRun(start: StartStep): boolean {
return start.meta.dryRun;
}