diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index d6185e0..29797f0 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -193,15 +193,14 @@ export type PartitionedMessage = { * Extract display fields from a WorkflowMessage-shaped object. * `role` and `content` are used for header/body; `meta` is serialized as YAML frontmatter. */ -export function partitionWorkflowMessage(msg: Record): PartitionedMessage { - const roleStr = typeof msg.role === "string" ? msg.role : "?"; - const contentRaw = msg.content; - const contentBody = - contentRaw === undefined || contentRaw === null - ? "" - : typeof contentRaw === "string" - ? contentRaw - : JSON.stringify(contentRaw); +export function partitionWorkflowMessage(msg: { + role: string; + content: string; + meta: unknown; + timestamp: number; +}): PartitionedMessage { + const roleStr = msg.role; + const contentBody = msg.content; const meta: Record = msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object" ? (msg.meta as Record) @@ -213,9 +212,7 @@ export function partitionWorkflowMessage(msg: Record): Partitio * One role round as plain text: header line, YAML frontmatter (meta only), body (content). */ export function formatThreadRoundBlock(row: ThreadRoundRow): string { - const { roleStr, contentBody, meta } = partitionWorkflowMessage( - row.message as unknown as Record, - ); + const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message); const yamlBlock = Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`; return ( @@ -257,9 +254,7 @@ export function buildThreadCommandOutput( continue; } if (picked.length === 0) { - const { roleStr, contentBody, meta } = partitionWorkflowMessage( - row.message as unknown as Record, - ); + const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message); const yamlBlock = Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`; const header = diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 7eddeac..e8321a6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,8 +3,7 @@ import { parse } from "yaml"; import type { Result } from "./result.js"; import { err, ok } from "./result.js"; import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js"; - -const DEFAULT_ENGINE_MAX_ROUNDS = 100; +import { DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js"; const DURATION_RE = /^(\d+)([smh])$/; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36ea1db..f212605 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,17 +18,10 @@ export type { WorkflowDefinition, SenseResult, } from "./types.js"; -export { START, END } from "./types.js"; +export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js"; export type { Result } from "./result.js"; export { ok, err } from "./result.js"; export { parseNerveConfig } from "./config.js"; -export function parseWorkflowField(field: string): { name: string; maxRounds: number; prompt: string } { - const [name, rounds, ...rest] = field.split("|"); - const prompt = rest.join("|"); - const maxRounds = parseInt(rounds, 10); - return { name: name ?? "", maxRounds, prompt }; -} - export type { ParsedSenseWorkflowDirective, SenseComputeRoute } from "./sense-workflow-directive.js"; export { parseSenseWorkflowDirective, routeSenseComputeOutput } from "./sense-workflow-directive.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 63482d4..d3768ec 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -61,6 +61,9 @@ export const END = "__end__" as const; export type START = typeof START; export type END = typeof END; +/** Engine-wide fallback for max moderator rounds when not specified in config. */ +export const DEFAULT_ENGINE_MAX_ROUNDS = 100; + /** A single message in the workflow conversation chain (runtime, type-erased). */ export type WorkflowMessage = { role: string; diff --git a/packages/daemon/src/log-store.ts b/packages/daemon/src/log-store.ts index 000e599..35c7370 100644 --- a/packages/daemon/src/log-store.ts +++ b/packages/daemon/src/log-store.ts @@ -147,14 +147,14 @@ export type LogStore = { runId: string, ) => Array<{ role: string; content: string; meta: unknown; timestamp: number }>; /** - * Count role command events for a run (excludes `thread_start` and invalid payloads). + * Count role command events for a run (excludes `thread_start`/`__start__` messages and invalid payloads). * Round indices for {@link getThreadRounds} are 1..count in chronological order. */ getThreadRoundCount: (runId: string) => number; /** - * Role rounds for agent-oriented retrieval: each row is one `thread_command_event` - * whose JSON `type` is not `thread_start`, with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). - * No schema migration — numbering is computed in SQL. + * Role rounds for agent-oriented retrieval: each row is one `thread_command_event` or + * `thread_workflow_message` whose JSON `type` is not `thread_start` and `role` is not `__start__`, + * with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). No schema migration — numbering is computed in SQL. */ getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[]; /** @@ -324,7 +324,8 @@ export function createLogStore(dbPath: string): LogStore { `SELECT COUNT(*) AS c FROM logs WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = ? AND payload IS NOT NULL AND json_valid(payload) = 1 - AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start'`, + AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start' + AND COALESCE(json_extract(payload, '$.role'), '') != '__start__'`, ); const getThreadRoundsStmt = sqlite.prepare( @@ -335,6 +336,7 @@ export function createLogStore(dbPath: string): LogStore { WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = @runId AND payload IS NOT NULL AND json_valid(payload) = 1 AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start' + AND COALESCE(json_extract(payload, '$.role'), '') != '__start__' ) SELECT id, ts, payload, rn FROM numbered WHERE (@before = 0 OR rn < @before) diff --git a/packages/daemon/src/workflow-worker.ts b/packages/daemon/src/workflow-worker.ts index 902966b..cbcfd47 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/daemon/src/workflow-worker.ts @@ -130,6 +130,15 @@ async function runThread( return; } + if (typeof result.content !== "string") { + sendWorkflowError(runId, `Role "${nextRole}" returned non-string content`); + return; + } + if (result.meta === null || typeof result.meta !== "object" || Array.isArray(result.meta)) { + sendWorkflowError(runId, `Role "${nextRole}" returned invalid meta (must be a plain object)`); + return; + } + const message: WorkflowMessage = { role: nextRole, content: result.content,