feat(execute): create @uncaged/workflow-execute + CLI migration

Phase 7: Engine + extract + workflow-as-agent merged into execute package.
All CLI imports migrated from @uncaged/workflow to specific packages.
105 CLI tests pass, 0 failures.

Changes:
- New @uncaged/workflow-execute package (engine/, extract/, workflow-as-agent)
- CLI src/ and __tests__/ rewritten to import from split packages
- bundle-validator updated to allow @uncaged/workflow-cas imports
- ensure-uncaged-workflow-symlink creates symlinks for all new packages

Ref: #143, closes #150
This commit is contained in:
2026-05-09 11:35:03 +08:00
parent b07f8cf166
commit 9bbdfc41bd
63 changed files with 2175 additions and 107 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"yaml": "^2.7.1"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,8 @@
/**
* Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`.
*
* The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop
* and delegates structured meta extraction to `WorkflowRuntime.extract`, which the engine
* supplies (resolved from the `extract` scene in workflow.yaml).
*/
export { createWorkflow } from "@uncaged/workflow-runtime";
@@ -0,0 +1,415 @@
import { appendFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import type {
LlmProvider,
RoleOutput,
ThreadContext,
WorkflowCompletion,
WorkflowFn,
WorkflowResult,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { START } from "@uncaged/workflow-runtime";
import {
type CasStore,
getContentMerklePayload,
putStepMerkleNode,
putThreadMerkleNode,
} from "@uncaged/workflow-cas";
import { resolveModel } from "@uncaged/workflow-register";
import { createExtract } from "../extract/index.js";
import { readWorkflowRegistry, type WorkflowConfig } from "@uncaged/workflow-register";
import { err, type LogFn, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
import { runSupervisor } from "./supervisor.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
async function resolveEngineRegistryRuntime(
storageRoot: string,
cas: CasStore,
): Promise<
Result<
{
extract: ReturnType<typeof createExtract>;
workflowConfig: WorkflowConfig;
},
string
>
> {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return err(reg.error.message);
}
const cfg = reg.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const resolved = resolveModel(cfg, "extract");
if (!resolved.ok) {
return resolved;
}
const ex = resolved.value;
const llmProvider: LlmProvider = {
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
};
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
}
async function appendDataLine(path: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
await appendFile(path, line, "utf8");
}
async function finalizeThreadResult(params: {
cas: CasStore;
workflowName: string;
threadId: string;
stepMerkleHashes: readonly string[];
completion: WorkflowCompletion;
}): Promise<WorkflowResult> {
const rootHash = await putThreadMerkleNode(
params.cas,
{
workflow: params.workflowName,
threadId: params.threadId,
result: {
returnCode: params.completion.returnCode,
summary: params.completion.summary,
},
},
params.stepMerkleHashes,
);
return {
returnCode: params.completion.returnCode,
summary: params.completion.summary,
rootHash,
};
}
async function finalizeAbortedThread(params: {
cas: CasStore;
workflowName: string;
threadId: string;
stepMerkleHashes: string[];
logger: LogFn;
abortLogTag: string;
}): Promise<WorkflowResult> {
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
return finalizeThreadResult({
cas: params.cas,
workflowName: params.workflowName,
threadId: params.threadId,
stepMerkleHashes: params.stepMerkleHashes,
completion: { returnCode: 130, summary: "thread aborted" },
});
}
async function maybeSupervisorHaltsThread(params: {
workflowConfig: WorkflowConfig;
thread: ThreadContext;
written: number;
recentSupervisorSteps: readonly { role: string; summary: string }[];
logger: LogFn;
threadId: string;
cas: CasStore;
workflowName: string;
stepMerkleHashes: string[];
}): Promise<WorkflowResult | null> {
const interval = params.workflowConfig.supervisorInterval;
if (interval <= 0 || params.written % interval !== 0) {
return null;
}
const sup = await runSupervisor({
config: params.workflowConfig,
prompt: params.thread.start.content,
recentSteps: params.recentSupervisorSteps,
logger: params.logger,
});
if (!sup.ok) {
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
return null;
}
if (sup.value !== "stop") {
return null;
}
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
return finalizeThreadResult({
cas: params.cas,
workflowName: params.workflowName,
threadId: params.threadId,
stepMerkleHashes: params.stepMerkleHashes,
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
});
}
async function driveWorkflowGenerator(params: {
fn: WorkflowFn;
workflowName: string;
workflowConfig: WorkflowConfig;
thread: ThreadContext;
runtime: WorkflowRuntime;
executeOptions: ExecuteThreadOptions;
dataJsonlPath: string;
threadId: string;
logger: LogFn;
cas: CasStore;
stepMerkleHashes: string[];
}): Promise<WorkflowResult> {
const {
fn,
workflowName,
workflowConfig,
thread,
runtime,
executeOptions,
dataJsonlPath,
threadId,
logger,
cas,
stepMerkleHashes,
} = params;
const gen = fn(thread, runtime);
let written = 0;
const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({
role: s.role,
summary: JSON.stringify(s.meta),
}));
while (true) {
if (executeOptions.signal.aborted) {
return await finalizeAbortedThread({
cas,
workflowName,
threadId,
stepMerkleHashes,
logger,
abortLogTag: "V8JX4NP2",
});
}
if (written >= executeOptions.maxRounds) {
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
return await finalizeThreadResult({
cas,
workflowName,
threadId,
stepMerkleHashes,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
},
});
}
const iterResult = await gen.next();
if (iterResult.done) {
logger("F3HN8QKP", `thread ${threadId} generator finished`);
const completion = iterResult.value;
return await finalizeThreadResult({
cas,
workflowName,
threadId,
stepMerkleHashes,
completion,
});
}
written++;
const step = iterResult.value;
const resolved = await getContentMerklePayload(cas, step.contentHash);
if (resolved === null) {
throw new Error(
`role step ${step.role}: CAS blob missing for contentHash ${step.contentHash}`,
);
}
const ts = Date.now();
await appendDataLine(dataJsonlPath, {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: normalizeRefsField(step.refs),
timestamp: ts,
});
const stepNodeHash = await putStepMerkleNode(
cas,
{ role: step.role, meta: step.meta },
step.contentHash,
);
stepMerkleHashes.push(stepNodeHash);
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
recentSupervisorSteps.push({
role: step.role,
summary: JSON.stringify(step.meta),
});
await Promise.race([
executeOptions.awaitAfterEachYield(),
new Promise<void>((resolve) => {
if (executeOptions.signal.aborted) {
resolve();
return;
}
executeOptions.signal.addEventListener("abort", () => resolve(), { once: true });
}),
]);
if (executeOptions.signal.aborted) {
return await finalizeAbortedThread({
cas,
workflowName,
threadId,
stepMerkleHashes,
logger,
abortLogTag: "V8JX4NP4",
});
}
const supervised = await maybeSupervisorHaltsThread({
workflowConfig,
thread,
written,
recentSupervisorSteps,
logger,
threadId,
cas,
workflowName,
stepMerkleHashes,
});
if (supervised !== null) {
return supervised;
}
}
}
/**
* Execute a workflow thread: drive the bundle's AsyncGenerator, RFC-001 `.data.jsonl` records,
* debug lines via `logger` to `.info.jsonl`.
*/
export async function executeThread(
fn: WorkflowFn,
workflowName: string,
input: { prompt: string; steps: RoleOutput[] },
options: ExecuteThreadOptions,
io: ExecuteThreadIo,
logger: LogFn,
): Promise<WorkflowResult> {
await mkdir(dirname(io.dataJsonlPath), { recursive: true });
await mkdir(dirname(io.infoJsonlPath), { recursive: true });
const prefilled = options.prefilledDiskSteps;
if (prefilled !== null && prefilled.length !== input.steps.length) {
throw new Error(
`prefilledDiskSteps length (${prefilled.length}) must match input.steps length (${input.steps.length})`,
);
}
const nowMs = Date.now();
const startRecord: Record<string, unknown> = {
name: workflowName,
hash: io.hash,
threadId: io.threadId,
parameters: {
prompt: input.prompt,
options: {
maxRounds: options.maxRounds,
depth: options.depth,
},
},
timestamp: nowMs,
};
if (options.forkSourceThreadId !== null) {
startRecord.forkFrom = { threadId: options.forkSourceThreadId };
}
await appendDataLine(io.dataJsonlPath, startRecord);
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`);
const stepMerkleHashes: string[] = [];
if (prefilled !== null) {
for (const row of prefilled) {
const prefilledPayload = await getContentMerklePayload(io.cas, row.contentHash);
if (prefilledPayload === null) {
throw new Error(
`prefilled step ${row.role}: CAS blob missing for contentHash ${row.contentHash}`,
);
}
await appendDataLine(io.dataJsonlPath, {
role: row.role,
contentHash: row.contentHash,
meta: row.meta,
refs: normalizeRefsField(row.refs),
timestamp: row.timestamp,
});
const stepNodeHash = await putStepMerkleNode(
io.cas,
{ role: row.role, meta: row.meta },
row.contentHash,
);
stepMerkleHashes.push(stepNodeHash);
}
}
if (options.maxRounds <= 0) {
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
return await finalizeThreadResult({
cas: io.cas,
workflowName,
threadId: io.threadId,
stepMerkleHashes,
completion: {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
},
});
}
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
if (!registryRuntime.ok) {
throw new Error(registryRuntime.error);
}
const thread: ThreadContext = {
threadId: io.threadId,
depth: options.depth,
start: {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
},
steps: input.steps.map((out, i) => ({
role: out.role,
contentHash: out.contentHash,
meta: out.meta,
refs: out.refs,
timestamp: prefilled?.[i]?.timestamp ?? nowMs + i,
})),
};
const runtime: WorkflowRuntime = {
cas: io.cas,
extract: registryRuntime.value.extract,
};
return await driveWorkflowGenerator({
fn,
workflowName,
workflowConfig: registryRuntime.value.workflowConfig,
thread,
runtime,
executeOptions: options,
dataJsonlPath: io.dataJsonlPath,
threadId: io.threadId,
logger,
cas: io.cas,
stepMerkleHashes,
});
}
@@ -0,0 +1,244 @@
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { err, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
/** Recognizes a persisted workflow completion line (no `role`; has numeric `returnCode` and string `summary`). Omits `rootHash` when absent. */
export function tryParseWorkflowResultRecord(
obj: Record<string, unknown>,
): WorkflowCompletion | null {
if (obj.role !== undefined) {
return null;
}
const returnCode = obj.returnCode;
const summary = obj.summary;
if (typeof returnCode !== "number" || typeof summary !== "string") {
return null;
}
return { returnCode, summary };
}
export function tryParseRoleStepRecord(obj: Record<string, unknown>): ForkHistoricalStep | null {
const role = obj.role;
const contentHash = obj.contentHash;
const meta = obj.meta;
const timestamp = obj.timestamp;
if (typeof role !== "string") {
return null;
}
if (typeof contentHash !== "string") {
return null;
}
if (meta === null || typeof meta !== "object") {
return null;
}
if (typeof timestamp !== "number") {
return null;
}
return {
role,
contentHash,
meta: meta as Record<string, unknown>,
refs: normalizeRefsField(obj.refs),
timestamp,
};
}
function parseRoleLine(
obj: Record<string, unknown>,
lineIndex: number,
): Result<ForkHistoricalStep, string> {
const parsed = tryParseRoleStepRecord(obj);
if (parsed === null) {
return err(`invalid role record at line ${lineIndex}`);
}
return ok(parsed);
}
function parseStartRecordLine(firstLine: string): Result<ParsedThreadStartRecord, string> {
let startParsed: unknown;
try {
startParsed = JSON.parse(firstLine) as unknown;
} catch {
return err("invalid JSON on line 1 (start record)");
}
if (startParsed === null || typeof startParsed !== "object") {
return err("invalid start record shape");
}
const startRec = startParsed as Record<string, unknown>;
const name = startRec.name;
const hash = startRec.hash;
const threadId = startRec.threadId;
const parameters = startRec.parameters;
if (typeof name !== "string" || typeof hash !== "string" || typeof threadId !== "string") {
return err("start record missing name, hash, or threadId");
}
if (parameters === null || typeof parameters !== "object") {
return err("start record missing parameters");
}
const paramsRec = parameters as Record<string, unknown>;
const prompt = paramsRec.prompt;
const options = paramsRec.options;
if (typeof prompt !== "string") {
return err("start record missing parameters.prompt");
}
if (options === null || typeof options !== "object") {
return err("start record missing parameters.options");
}
const optRec = options as Record<string, unknown>;
const maxRounds = optRec.maxRounds;
if (typeof maxRounds !== "number") {
return err("start record missing parameters.options.maxRounds");
}
const depthRaw = optRec.depth;
const depth =
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
return ok({
workflowName: name,
hash,
threadId,
prompt,
maxRounds,
depth,
});
}
function parseFollowingRoleLines(lines: string[]): Result<ForkHistoricalStep[], string> {
const roleSteps: ForkHistoricalStep[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) {
break;
}
let rec: unknown;
try {
rec = JSON.parse(line) as unknown;
} catch {
return err(`invalid JSON at line ${i + 1}`);
}
if (rec === null || typeof rec !== "object") {
return err(`invalid record at line ${i + 1}`);
}
const recObj = rec as Record<string, unknown>;
const wf = tryParseWorkflowResultRecord(recObj);
if (wf !== null) {
if (i !== lines.length - 1) {
return err("WorkflowResult record must be the final line in `.data.jsonl`");
}
break;
}
const parsed = parseRoleLine(recObj, i + 1);
if (!parsed.ok) {
return parsed;
}
roleSteps.push(parsed.value);
}
return ok(roleSteps);
}
/**
* Parse RFC-001 `.data.jsonl`: line 1 start record, line 2+ role outputs.
*/
export function parseThreadDataJsonl(text: string): Result<
{
start: ParsedThreadStartRecord;
roleSteps: ForkHistoricalStep[];
},
string
> {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "");
if (lines.length === 0) {
return err("thread data is empty");
}
const firstLine = lines[0];
if (firstLine === undefined) {
return err("thread data is empty");
}
const start = parseStartRecordLine(firstLine);
if (!start.ok) {
return start;
}
const roleSteps = parseFollowingRoleLines(lines);
if (!roleSteps.ok) {
return roleSteps;
}
return ok({
start: start.value,
roleSteps: roleSteps.value,
});
}
function orderedUniqueRoles(roleSteps: ForkHistoricalStep[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const s of roleSteps) {
if (!seen.has(s.role)) {
seen.add(s.role);
out.push(s.role);
}
}
return out;
}
/**
* Select historical steps for a fork:
* - `fromRole === null`: drop the last step (retry the last role).
* - `fromRole !== null`: keep steps through the first occurrence of that role (inclusive).
*/
export function selectForkHistoricalSteps(
roleSteps: ForkHistoricalStep[],
fromRole: string | null,
): Result<ForkHistoricalStep[], string> {
if (roleSteps.length === 0) {
return err("thread has no completed role steps to fork from");
}
if (fromRole === null) {
if (roleSteps.length === 1) {
return ok([]);
}
return ok(roleSteps.slice(0, -1));
}
const idx = roleSteps.findIndex((s) => s.role === fromRole);
if (idx < 0) {
const available = orderedUniqueRoles(roleSteps);
return err(`role not found in thread: ${fromRole} (available: ${available.join(", ")})`);
}
return ok(roleSteps.slice(0, idx + 1));
}
/**
* Read `.data.jsonl` text and compute fork payload for the worker `run` command.
*/
export function buildForkPlan(
dataJsonlText: string,
fromRole: string | null,
): Result<ForkPlan, string> {
const parsed = parseThreadDataJsonl(dataJsonlText);
if (!parsed.ok) {
return parsed;
}
const selected = selectForkHistoricalSteps(parsed.value.roleSteps, fromRole);
if (!selected.ok) {
return selected;
}
const { start } = parsed.value;
return ok({
workflowName: start.workflowName,
hash: start.hash,
sourceThreadId: start.threadId,
prompt: start.prompt,
runOptions: { maxRounds: start.maxRounds, depth: start.depth },
historicalSteps: selected.value,
});
}
+123
View File
@@ -0,0 +1,123 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { type CasStore, createCasStore } from "@uncaged/workflow-cas";
import { err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow-util";
import { parseThreadDataJsonl } from "./fork-thread.js";
import type { GcResult } from "./types.js";
async function listThreadDataJsonlPaths(storageRoot: string): Promise<Result<string[], string>> {
const logsRoot = join(storageRoot, "logs");
const paths: string[] = [];
let hashes: string[];
try {
hashes = await readdir(logsRoot);
} catch (e) {
const errObj = e as NodeJS.ErrnoException;
if (errObj.code === "ENOENT") {
return ok([]);
}
return err(`failed to read logs directory: ${String(e)}`);
}
for (const hash of hashes) {
const dir = join(logsRoot, hash);
let entries: string[];
try {
entries = await readdir(dir);
} catch {
continue;
}
for (const fileName of entries) {
if (fileName.endsWith(".data.jsonl")) {
paths.push(join(dir, fileName));
}
}
}
paths.sort();
return ok(paths);
}
async function collectActiveRefsFromDataPaths(
dataPaths: string[],
): Promise<Result<Set<string>, string>> {
const activeRefs = new Set<string>();
for (const dataPath of dataPaths) {
let text: string;
try {
text = await readFile(dataPath, "utf8");
} catch (e) {
return err(`failed to read ${dataPath}: ${String(e)}`);
}
const parsed = parseThreadDataJsonl(text);
if (!parsed.ok) {
return err(`${dataPath}: ${parsed.error}`);
}
for (const step of parsed.value.roleSteps) {
for (const ref of step.refs) {
activeRefs.add(ref);
}
}
}
return ok(activeRefs);
}
async function deleteCasNotInSet(
cas: CasStore,
activeRefs: Set<string>,
): Promise<Result<string[], string>> {
let listed: string[];
try {
listed = await cas.list();
} catch (e) {
return err(`failed to list cas entries: ${String(e)}`);
}
const deletedHashes: string[] = [];
for (const hash of listed) {
if (activeRefs.has(hash)) {
continue;
}
try {
await cas.delete(hash);
} catch (e) {
return err(`failed to delete cas ${hash}: ${String(e)}`);
}
deletedHashes.push(hash);
}
deletedHashes.sort();
return ok(deletedHashes);
}
/**
* Mark-and-sweep CAS GC: collect `refs` from all thread `.data.jsonl` files under `storageRoot`,
* then delete CAS blobs not referenced by any surviving thread data.
*/
export async function garbageCollectCas(storageRoot: string): Promise<Result<GcResult, string>> {
const pathsResult = await listThreadDataJsonlPaths(storageRoot);
if (!pathsResult.ok) {
return pathsResult;
}
const paths = pathsResult.value;
const refsResult = await collectActiveRefsFromDataPaths(paths);
if (!refsResult.ok) {
return refsResult;
}
const activeRefs = refsResult.value;
const cas = createCasStore(getGlobalCasDir(storageRoot));
const deletedResult = await deleteCasNotInSet(cas, activeRefs);
if (!deletedResult.ok) {
return deletedResult;
}
const deletedHashes = deletedResult.value;
return ok({
scannedThreads: paths.length,
activeRefs: activeRefs.size,
deletedEntries: deletedHashes.length,
deletedHashes,
});
}
@@ -0,0 +1,23 @@
export { createWorkflow } from "./create-workflow.js";
export { executeThread } from "./engine.js";
export {
buildForkPlan,
parseThreadDataJsonl,
selectForkHistoricalSteps,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "./fork-thread.js";
export { garbageCollectCas } from "./gc.js";
export { createThreadPauseGate } from "./thread-pause-gate.js";
export type {
ExecuteThreadIo,
ExecuteThreadOptions,
ForkHistoricalStep,
ForkPlan,
GcResult,
ParsedThreadStartRecord,
PrefilledDiskStep,
SupervisorDecision,
ThreadPauseGate,
} from "./types.js";
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
@@ -0,0 +1,85 @@
import * as z from "zod/v4";
import { resolveModel } from "@uncaged/workflow-register";
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
import type { SupervisorDecision } from "./types.js";
const SUPERVISOR_RECENT_STEP_LIMIT = 12;
const SUPERVISOR_MAX_REACT_ROUNDS = 4;
const supervisorDecisionSchema = z
.object({
decision: z.enum(["continue", "stop"]),
})
.meta({
title: "supervisor_decision",
description:
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
});
type SupervisorThreadContext = Record<string, never>;
type RunSupervisorArgs = {
config: WorkflowConfig;
prompt: string;
recentSteps: readonly { role: string; summary: string }[];
logger: LogFn;
};
function buildSupervisorInput(args: RunSupervisorArgs): string {
const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT);
const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n");
return `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`;
}
/** Calls the `supervisor` scene via {@link createThreadReactor}; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */
export async function runSupervisor(
args: RunSupervisorArgs,
): Promise<Result<SupervisorDecision, string>> {
const resolved = resolveModel(args.config, "supervisor");
if (!resolved.ok) {
return ok("continue");
}
const reactor = createThreadReactor<SupervisorThreadContext>({
llm: createLlmFn(resolved.value),
maxRounds: SUPERVISOR_MAX_REACT_ROUNDS,
staticTools: [],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
});
const result = await reactor({
thread: {} as SupervisorThreadContext,
input: buildSupervisorInput(args),
schema: supervisorDecisionSchema,
});
if (!result.ok) {
args.logger("R9CW4PLM", `supervisor failed: ${result.error}`);
return err(`supervisor: ${result.error}`);
}
const decision: SupervisorDecision = result.value.decision;
args.logger("Z8KM5QWT", `supervisor says ${decision}`);
return ok(decision);
}
@@ -0,0 +1,49 @@
import { err, ok, type Result } from "@uncaged/workflow-util";
import type { ThreadPauseGate } from "./types.js";
/**
* Pause/resume gate for workflow threads: after each generator yield the engine awaits
* {@link ThreadPauseGate.awaitAfterYield}. Calling {@link ThreadPauseGate.pause} makes the next
* await block until {@link ThreadPauseGate.resume}.
*/
export function createThreadPauseGate(): ThreadPauseGate {
let resumeResolver: (() => void) | null = null;
let chain: Promise<void> = Promise.resolve();
let paused = false;
function awaitAfterYield(): Promise<void> {
return chain;
}
function pause(): Result<void, string> {
if (paused) {
return err("thread already paused");
}
paused = true;
chain = new Promise<void>((resolve) => {
resumeResolver = resolve;
});
return ok(undefined);
}
function resume(): Result<void, string> {
if (!paused) {
return err("thread not paused");
}
paused = false;
const resolveFn = resumeResolver;
resumeResolver = null;
if (resolveFn !== null) {
resolveFn();
}
chain = Promise.resolve();
return ok(undefined);
}
function isPaused(): boolean {
return paused;
}
return { awaitAfterYield, pause, resume, isPaused };
}
@@ -0,0 +1,75 @@
import type { RoleOutput } from "@uncaged/workflow-runtime";
import type { CasStore } from "@uncaged/workflow-cas";
import type { Result } from "@uncaged/workflow-util";
export type SupervisorDecision = "continue" | "stop";
export type ExecuteThreadIo = {
threadId: string;
hash: string;
dataJsonlPath: string;
infoJsonlPath: string;
cas: CasStore;
};
/** One persisted role line in `.data.jsonl` (engine adds these for fork replay before running the generator). */
export type PrefilledDiskStep = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
timestamp: number;
};
export type ExecuteThreadOptions = {
maxRounds: number;
/** Passed to the bundle thread context as `ThreadContext.depth`. */
depth: number;
signal: AbortSignal;
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
awaitAfterEachYield: () => Promise<void>;
/** When non-null, written into the start record so tooling can trace lineage. */
forkSourceThreadId: string | null;
/**
* Written to `.data.jsonl` immediately after the start record, before the generator runs.
* Must match `input.steps` length and order when present.
*/
prefilledDiskSteps: PrefilledDiskStep[] | null;
/** Workspace root containing `workflow.yaml`; used to resolve the `extract` scene for meta extraction. */
storageRoot: string;
};
/** Role steps replayed from `.data.jsonl`, including persisted timestamps. */
export type ForkHistoricalStep = RoleOutput & { timestamp: number };
export type ParsedThreadStartRecord = {
workflowName: string;
hash: string;
threadId: string;
prompt: string;
maxRounds: number;
depth: number;
};
export type ForkPlan = {
workflowName: string;
hash: string;
sourceThreadId: string;
prompt: string;
runOptions: { maxRounds: number; depth: number };
historicalSteps: ForkHistoricalStep[];
};
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export type ThreadPauseGate = {
awaitAfterYield: () => Promise<void>;
pause: () => Result<void, string>;
resume: () => Result<void, string>;
isPaused: () => boolean;
};
@@ -0,0 +1,6 @@
import { fileURLToPath } from "node:url";
/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */
export function getWorkerHostScriptPath(): string {
return fileURLToPath(new URL("./worker.ts", import.meta.url));
}
@@ -0,0 +1,488 @@
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "@uncaged/workflow-register";
import { createCasStore } from "@uncaged/workflow-cas";
import {
createLogger,
err,
getGlobalCasDir,
normalizeRefsField,
ok,
type Result,
} from "@uncaged/workflow-util";
import { executeThread } from "./engine.js";
import { createThreadPauseGate } from "./thread-pause-gate.js";
import type { ExecuteThreadIo, PrefilledDiskStep, ThreadPauseGate } from "./types.js";
const bootLog = createLogger({ sink: { kind: "stderr" } });
type RunCommand = {
type: "run";
threadId: string;
workflowName: string;
prompt: string;
options: { maxRounds: number; depth: number };
steps: RoleOutput[];
/** Timestamps aligned with `steps` for `.data.jsonl` replay; length must match `steps` when non-null. */
stepTimestamps: number[] | null;
forkSourceThreadId: string | null;
};
type KillCommand = {
type: "kill";
threadId: string;
};
type PauseCommand = {
type: "pause";
threadId: string;
};
type ResumeCommand = {
type: "resume";
threadId: string;
};
type ControlCommand = RunCommand | KillCommand | PauseCommand | ResumeCommand;
type ThreadHandle = {
abortController: AbortController;
pauseGate: ThreadPauseGate;
};
function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null {
const role = obj.role;
const contentHash = obj.contentHash;
const meta = obj.meta;
if (typeof role !== "string" || typeof contentHash !== "string") {
return null;
}
if (meta === null || typeof meta !== "object") {
return null;
}
return {
role,
contentHash,
meta: meta as Record<string, unknown>,
refs: normalizeRefsField(obj.refs),
};
}
function parseRunStepsPayload(rec: Record<string, unknown>): {
steps: RoleOutput[];
stepTimestamps: number[] | null;
} | null {
const raw = rec.steps;
if (raw === undefined || raw === null) {
return { steps: [], stepTimestamps: null };
}
if (!Array.isArray(raw)) {
return null;
}
const steps: RoleOutput[] = [];
const timestamps: number[] = [];
let anyTimestamp = false;
for (const item of raw) {
if (item === null || typeof item !== "object") {
return null;
}
const o = item as Record<string, unknown>;
const out = parseRoleOutputRecord(o);
if (out === null) {
return null;
}
steps.push(out);
const ts = o.timestamp;
if (ts === undefined) {
timestamps.push(0);
} else if (typeof ts === "number") {
timestamps.push(ts);
anyTimestamp = true;
} else {
return null;
}
}
return {
steps,
stepTimestamps: anyTimestamp ? timestamps : null,
};
}
function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null {
const threadId = rec.threadId;
const workflowName = rec.workflowName;
const prompt = rec.prompt;
const options = rec.options;
if (
typeof threadId !== "string" ||
typeof workflowName !== "string" ||
typeof prompt !== "string"
) {
return null;
}
if (options === null || typeof options !== "object") {
return null;
}
const optRec = options as Record<string, unknown>;
const maxRounds = optRec.maxRounds;
if (typeof maxRounds !== "number") {
return null;
}
const depthRaw = optRec.depth;
const depth =
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
const parsedSteps = parseRunStepsPayload(rec);
if (parsedSteps === null) {
return null;
}
const rawFork = rec.forkSourceThreadId;
let forkSourceThreadId: string | null = null;
if (rawFork !== undefined && rawFork !== null) {
if (typeof rawFork !== "string" || rawFork === "") {
return null;
}
forkSourceThreadId = rawFork;
}
return {
type: "run",
threadId,
workflowName,
prompt,
options: { maxRounds, depth },
steps: parsedSteps.steps,
stepTimestamps: parsedSteps.stepTimestamps,
forkSourceThreadId,
};
}
function parseLifecycleThreadPayload(
rec: Record<string, unknown>,
): KillCommand | PauseCommand | ResumeCommand | null {
const type = rec.type;
const threadId = rec.threadId;
if (typeof threadId !== "string") {
return null;
}
if (type === "kill") {
return { type: "kill", threadId };
}
if (type === "pause") {
return { type: "pause", threadId };
}
if (type === "resume") {
return { type: "resume", threadId };
}
return null;
}
function parseControlPayload(payload: unknown): ControlCommand | null {
if (payload === null || typeof payload !== "object") {
return null;
}
const rec = payload as Record<string, unknown>;
const lifecycle = parseLifecycleThreadPayload(rec);
if (lifecycle !== null) {
return lifecycle;
}
if (rec.type === "run") {
return parseRunControlPayload(rec);
}
return null;
}
function parseCommandLine(line: string): ControlCommand | null {
const trimmed = line.trim();
if (trimmed === "") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
bootLog("S8KQ3WJP", "worker received invalid JSON control line");
return null;
}
return parseControlPayload(parsed);
}
function isWorkflowFnLike(value: unknown): value is WorkflowFn {
return typeof value === "function";
}
function writeTcpResponse(socket: Socket | null, result: Result<void, string>): void {
if (socket === null) {
return;
}
const body = result.ok ? { ok: true as const } : { ok: false as const, error: result.error };
socket.end(`${JSON.stringify(body)}\n`);
}
function dispatchThreadLifecycleCommand(
threads: Map<string, ThreadHandle>,
socket: Socket | null,
cmd: KillCommand | PauseCommand | ResumeCommand,
): void {
const handle = threads.get(cmd.threadId);
if (handle === undefined) {
writeTcpResponse(socket, err(`thread not found: ${cmd.threadId}`));
return;
}
switch (cmd.type) {
case "kill":
handle.abortController.abort();
bootLog("P9XK2WNQ", `kill requested for thread ${cmd.threadId}`);
writeTcpResponse(socket, ok(undefined));
return;
case "pause": {
const paused = handle.pauseGate.pause();
if (!paused.ok) {
writeTcpResponse(socket, paused);
return;
}
bootLog("K7WQ2NXP", `pause requested for thread ${cmd.threadId}`);
writeTcpResponse(socket, ok(undefined));
return;
}
case "resume": {
const resumed = handle.pauseGate.resume();
if (!resumed.ok) {
writeTcpResponse(socket, resumed);
return;
}
bootLog("M4YT8HKR", `resume requested for thread ${cmd.threadId}`);
writeTcpResponse(socket, ok(undefined));
return;
}
}
}
async function readLineFromSocket(socket: Socket): Promise<string | null> {
return await new Promise((resolve) => {
let buf = "";
function onData(chunk: Buffer): void {
buf += chunk.toString("utf8");
const nl = buf.indexOf("\n");
if (nl >= 0) {
cleanup();
resolve(buf.slice(0, nl));
}
}
function onEnd(): void {
cleanup();
resolve(buf === "" ? null : buf);
}
function onError(): void {
cleanup();
resolve(null);
}
function cleanup(): void {
socket.off("data", onData);
socket.off("end", onEnd);
socket.off("error", onError);
}
socket.on("data", onData);
socket.on("end", onEnd);
socket.on("error", onError);
});
}
async function main(): Promise<void> {
const bundlePath = process.argv[2];
const storageRoot = process.argv[3];
const hash = process.argv[4];
if (
bundlePath === undefined ||
storageRoot === undefined ||
hash === undefined ||
bundlePath === "" ||
storageRoot === "" ||
hash === ""
) {
bootLog("H7XN4MKQ", "worker usage: worker <bundlePath> <storageRoot> <hash>");
process.exit(2);
return;
}
await ensureUncagedWorkflowSymlink(storageRoot);
// Dynamic import required: user bundle path resolved at runtime
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
const modRec = modUnknown as Record<string, unknown>;
const runExport = modRec.run;
if (!isWorkflowFnLike(runExport)) {
bootLog("T4BW9YJX", "workflow bundle must export run as a function (AsyncGenerator workflow)");
process.exit(2);
return;
}
const workflowFn = runExport;
const threads = new Map<string, ThreadHandle>();
let activeThreads = 0;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
const cas = createCasStore(getGlobalCasDir(storageRoot));
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
function cancelShutdownTimer(): void {
if (shutdownTimer !== null) {
clearTimeout(shutdownTimer);
shutdownTimer = null;
}
}
function scheduleShutdown(): void {
cancelShutdownTimer();
shutdownTimer = setTimeout(() => {
void unlink(workerCtlPath).catch(() => {});
process.exit(0);
}, 150);
}
function bumpStart(): void {
cancelShutdownTimer();
activeThreads++;
}
function bumpDone(): void {
activeThreads--;
if (activeThreads <= 0) {
activeThreads = 0;
scheduleShutdown();
}
}
async function dispatchCommand(cmd: ControlCommand, socket: Socket | null): Promise<void> {
if (cmd.type !== "run") {
dispatchThreadLifecycleCommand(threads, socket, cmd);
return;
}
bumpStart();
const threadId = cmd.threadId;
const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`);
const dataJsonlPath = join(storageRoot, "logs", hash, `${threadId}.data.jsonl`);
const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`);
const io: ExecuteThreadIo = {
threadId,
hash,
dataJsonlPath,
infoJsonlPath,
cas,
};
const existing = threads.get(threadId);
if (existing !== undefined) {
existing.abortController.abort();
threads.delete(threadId);
}
const pauseGate = createThreadPauseGate();
const ac = new AbortController();
threads.set(threadId, { abortController: ac, pauseGate });
try {
await mkdir(dirname(runningPath), { recursive: true });
await mkdir(dirname(dataJsonlPath), { recursive: true });
await writeFile(runningPath, "", "utf8");
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const baseTs = Date.now();
let prefilledDiskSteps: PrefilledDiskStep[] | null = null;
if (cmd.steps.length > 0) {
prefilledDiskSteps = cmd.steps.map((step, i) => {
const ts = cmd.stepTimestamps?.[i];
return {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: normalizeRefsField(step.refs),
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
};
});
}
const runResult = await executeThread(
workflowFn,
cmd.workflowName,
{ prompt: cmd.prompt, steps: cmd.steps },
{
...cmd.options,
signal: ac.signal,
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
forkSourceThreadId: cmd.forkSourceThreadId,
prefilledDiskSteps,
storageRoot,
},
io,
logger,
);
await appendFile(dataJsonlPath, `${JSON.stringify(runResult)}\n`, "utf8");
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
const failure: WorkflowResult = { returnCode: 1, summary: message, rootHash: "" };
await appendFile(dataJsonlPath, `${JSON.stringify(failure)}\n`, "utf8").catch(() => {});
} finally {
threads.delete(threadId);
await unlink(runningPath).catch(() => {});
bumpDone();
socket?.end();
}
}
if (typeof process.send === "function") {
process.on("message", (msg: unknown) => {
const cmd = parseControlPayload(msg);
if (cmd === null) {
return;
}
void dispatchCommand(cmd, null);
});
}
const server = createServer((socket: Socket) => {
void (async () => {
const line = await readLineFromSocket(socket);
if (line === null) {
socket.end();
return;
}
const cmd = parseCommandLine(line);
if (cmd === null) {
socket.end();
return;
}
await dispatchCommand(cmd, socket);
})();
});
server.on("error", (errObj: Error) => {
bootLog("W8YK4NPX", `worker server error: ${errObj.message}`);
process.exit(1);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve();
});
});
const addr = server.address();
if (addr === null || typeof addr === "string") {
bootLog("R9XK4MNW", "worker failed to bind TCP address");
process.exit(1);
return;
}
process.stdout.write(`READY ${addr.port}\n`);
await new Promise<void>(() => {});
}
void main();
@@ -0,0 +1,136 @@
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
export type ExtractDeps = {
cas: CasStore;
};
const MAX_REACT_ROUNDS = 10;
const CAS_GET_TOOL_DEFINITION = {
type: "function" as const,
function: {
name: "cas_get",
description:
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
parameters: {
type: "object",
properties: {
hash: { type: "string", description: "The CAS hash to retrieve" },
},
required: ["hash"],
},
},
};
export type ExtractThreadContext = {
cas: CasStore;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
ctx: ExtractContext,
prompt: string,
deps: ExtractDeps,
): Promise<string> {
const lines: string[] = [];
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
lines.push("");
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(deps.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
lines.push(`### ${step.role}`);
lines.push(body);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
return lines.join("\n");
}
/**
* Create an ExtractFn backed by an LLM provider.
*
* Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the
* Merkle DAG and a schema-shaped extract tool); the loop also accepts a plain-JSON
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
*/
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
const llm = createLlmFn(provider);
const reactor = createThreadReactor<ExtractThreadContext>({
llm,
maxRounds: MAX_REACT_ROUNDS,
staticTools: [CAS_GET_TOOL_DEFINITION],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
toolHandler: async (call, thread) => {
if (call.function.name !== "cas_get") {
return `Unexpected tool routed to handler: ${call.function.name}`;
}
let hash: string;
try {
const ta = JSON.parse(call.function.arguments) as unknown;
if (!isRecord(ta) || typeof ta.hash !== "string") {
return 'cas_get requires a JSON object with a string "hash" field.';
}
hash = ta.hash;
} catch {
return 'cas_get arguments were not valid JSON. Provide {"hash": "<cas-hash>"}.';
}
const blob = await thread.cas.get(hash);
return blob === null ? "null" : blob;
},
});
return async <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
): Promise<T> => {
const text = await buildExtractUserContent(ctx, prompt, deps);
const result = await reactor({
thread: { cas: deps.cas },
input: text,
schema,
});
if (!result.ok) {
throw new Error(`extract failed: ${result.error}`);
}
return result.value;
};
}
@@ -0,0 +1,11 @@
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
} from "./extract-fn.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
} from "./llm-extract.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./types.js";
@@ -0,0 +1,194 @@
import * as z from "zod/v4";
import { err, ok, type Result } from "@uncaged/workflow-util";
import type { LlmError, LlmExtractArgs } from "./types.js";
function chatCompletionsUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
const { $schema: _drop, ...rest } = json;
return rest;
}
function readToolName(parametersSchema: Record<string, unknown>): string {
const title = parametersSchema.title;
if (typeof title === "string" && title.trim().length > 0) {
return title.trim();
}
return "extract";
}
function readToolDescription(parametersSchema: Record<string, unknown>): string {
const d = parametersSchema.description;
if (typeof d === "string" && d.trim().length > 0) {
return d.trim();
}
return "Extract structured data from the input text.";
}
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
name: string;
description: string;
parameters: Record<string, unknown>;
} {
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
return {
name: readToolName(parameters),
description: readToolDescription(parameters),
parameters,
};
}
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
if (!isRecord(parsed)) {
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const first = choices[0];
if (!isRecord(first)) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const messageObj = first.message;
if (!isRecord(messageObj)) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const toolCalls = messageObj.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const call0 = toolCalls[0];
if (!isRecord(call0)) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const fn = call0.function;
if (!isRecord(fn)) {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
const argsRaw = fn.arguments;
if (typeof argsRaw !== "string") {
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
}
return ok(argsRaw);
}
export function llmErrorToCause(error: LlmError): Error {
switch (error.kind) {
case "http_error":
return new Error(`HTTP ${error.status}: ${error.body.slice(0, 500)}`);
case "invalid_response_json":
return new Error(error.message);
case "no_tool_call":
return new Error(`No tool call in response: ${error.preview}`);
case "tool_arguments_invalid_json":
return new Error(error.message);
case "schema_validation_failed":
return new Error(error.message);
case "network_error":
return new Error(error.message);
}
}
async function performLlmExtract<T>(
options: LlmExtractArgs<T> & { userContent: string },
): Promise<Result<T, LlmError>> {
const extractTool = extractFunctionToolFromZodSchema(options.schema);
const body = {
model: options.provider.model,
messages: [
{
role: "system" as const,
content: "Extract the requested information from the provided text. Be precise.",
},
{ role: "user" as const, content: options.userContent },
],
tools: [
{
type: "function" as const,
function: {
name: extractTool.name,
description: extractTool.description,
parameters: extractTool.parameters,
},
},
],
tool_choice: { type: "function" as const, function: { name: extractTool.name } },
};
let response: Response;
try {
response = await fetch(chatCompletionsUrl(options.provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${options.provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err({ kind: "network_error", message });
}
const responseText = await response.text();
if (!response.ok) {
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err({ kind: "invalid_response_json", message });
}
const argsJson = readToolArgumentsJson(parsed, responseText);
if (!argsJson.ok) {
return argsJson;
}
let argsParsed: unknown;
try {
argsParsed = JSON.parse(argsJson.value) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err({ kind: "tool_arguments_invalid_json", message });
}
const validated = options.schema.safeParse(argsParsed);
if (!validated.success) {
return err({
kind: "schema_validation_failed",
message: validated.error.message,
});
}
return ok(validated.data);
}
/** Single LLM extract attempt over OpenAI-compatible chat completions with forced tool call. */
export async function llmExtract<T>(options: LlmExtractArgs<T>): Promise<Result<T, LlmError>> {
return performLlmExtract({ ...options, userContent: options.text });
}
@@ -0,0 +1,18 @@
import type { LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type { ExtractFn } from "@uncaged/workflow-runtime";
export type LlmExtractArgs<T> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
};
export type LlmError =
| { kind: "http_error"; status: number; body: string }
| { kind: "invalid_response_json"; message: string }
| { kind: "no_tool_call"; preview: string }
| { kind: "tool_arguments_invalid_json"; message: string }
| { kind: "schema_validation_failed"; message: string }
| { kind: "network_error"; message: string };
+35
View File
@@ -0,0 +1,35 @@
export { createWorkflow } from "./engine/create-workflow.js";
export { executeThread } from "./engine/engine.js";
export {
buildForkPlan,
parseThreadDataJsonl,
selectForkHistoricalSteps,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "./engine/fork-thread.js";
export { garbageCollectCas } from "./engine/gc.js";
export { createThreadPauseGate } from "./engine/thread-pause-gate.js";
export type {
ExecuteThreadIo,
ExecuteThreadOptions,
ForkHistoricalStep,
ForkPlan,
GcResult,
ParsedThreadStartRecord,
PrefilledDiskStep,
SupervisorDecision,
ThreadPauseGate,
} from "./engine/types.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
} from "./extract/index.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
} from "./extract/index.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export { workflowAsAgent, type WorkflowAsAgentOptions } from "./workflow-as-agent.js";
@@ -0,0 +1,114 @@
import { join } from "node:path";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import { extractBundleExports } from "@uncaged/workflow-register";
import { createCasStore } from "@uncaged/workflow-cas";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
};
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
if (options !== null && options.storageRoot !== null) {
return options.storageRoot;
}
return getDefaultWorkflowStorageRoot();
}
/**
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
*/
export function workflowAsAgent(
workflowName: string,
options: WorkflowAsAgentOptions | null = null,
): AgentFn {
return async (ctx: AgentContext): Promise<string> => {
const nextDepth = ctx.depth + 1;
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
}
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
return `ERROR: ${bundleExportsResult.error}`;
}
const input = {
prompt: ctx.start.content,
steps: [],
};
const childThreadId = generateUlid(Date.now());
const dataJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.data.jsonl`);
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
const io: ExecuteThreadIo = {
threadId: childThreadId,
hash: entry.hash,
dataJsonlPath,
infoJsonlPath,
cas: createCasStore(getGlobalCasDir(storageRoot)),
};
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const signalNever = new AbortController();
try {
const result = await executeThread(
bundleExportsResult.value.run,
workflowName,
input,
{
maxRounds: ctx.start.meta.maxRounds,
depth: nextDepth,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null,
storageRoot,
},
io,
logger,
);
return result.rootHash;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return `ERROR: ${message}`;
}
};
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}