Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e9900bed3 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| e37dbc3f35 |
@@ -17,7 +17,7 @@ import {
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
@@ -153,7 +153,6 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
schema: { type: "object", properties: { greeting: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
|
||||
@@ -24,7 +24,6 @@ export const descriptor = {
|
||||
coder: { description: "coder", schema: {} },
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
|
||||
@@ -45,8 +45,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -100,8 +100,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -135,8 +135,8 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("init template", () => {
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
expect(moder).toContain("ModeratorTable");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"ModeratorTable",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
|
||||
@@ -36,7 +36,6 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
@@ -57,13 +57,17 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -71,7 +75,7 @@ export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
@@ -83,14 +87,14 @@ export {
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
table: helloTemplateTable,
|
||||
moderator: helloTemplateModerator,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ function agentsMd(): string {
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
@@ -94,19 +94,19 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
@@ -153,7 +153,7 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -189,28 +189,25 @@ export const run: WorkflowRun;
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
|
||||
Defines the workflow's metadata and role sequence:
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
}>;
|
||||
};
|
||||
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||
description: string; // one-line summary
|
||||
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
The main function that creates and returns a moderator:
|
||||
|
||||
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
|
||||
\`\`\`typescript
|
||||
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||
\`\`\`
|
||||
|
||||
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -229,7 +226,7 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + ModeratorTable + descriptor)
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
|
||||
@@ -12,6 +12,7 @@ function makeCtx(userContent: string): AgentContext {
|
||||
timestamp: 1,
|
||||
},
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
|
||||
@@ -14,6 +14,7 @@ function payload(
|
||||
ancestors: partial.ancestors ?? [],
|
||||
compact: partial.compact ?? null,
|
||||
timestamp: partial.timestamp ?? 0,
|
||||
childThread: partial.childThread ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,4 +63,32 @@ describe("collectRefs", () => {
|
||||
);
|
||||
expect(refs).toEqual(["S2", "C2"]);
|
||||
});
|
||||
|
||||
test("includes childThread hash when childThread is non-null", () => {
|
||||
const refs = collectRefs(
|
||||
payload({
|
||||
role: "developer",
|
||||
start: "S3",
|
||||
content: "C3",
|
||||
ancestors: ["A3"],
|
||||
compact: null,
|
||||
childThread: "CHILDEND000000000000001",
|
||||
}),
|
||||
);
|
||||
expect(refs).toEqual(["S3", "C3", "A3", "CHILDEND000000000000001"]);
|
||||
});
|
||||
|
||||
test("does not include childThread when childThread is null", () => {
|
||||
const refs = collectRefs(
|
||||
payload({
|
||||
role: "developer",
|
||||
start: "S4",
|
||||
content: "C4",
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
childThread: null,
|
||||
}),
|
||||
);
|
||||
expect(refs).toEqual(["S4", "C4"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { parseCasThreadNode, putStartNode, putStateNode } from "../src/nodes.js";
|
||||
|
||||
describe("putStartNode — parentState in refs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refs contains only promptHash when parentState is null", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const promptHash = await cas.put("hello");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const blob = await cas.get(startHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
|
||||
expect(parsed.node.refs).toEqual([promptHash]);
|
||||
expect(parsed.node.payload.parentState).toBeNull();
|
||||
});
|
||||
|
||||
test("refs contains [promptHash, parentStateHash] when parentState is set", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const parentStateHash = await cas.put("fake-parent-state");
|
||||
const promptHash = await cas.put("child-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "develop", hash: "BUNDLEBBBBBBBBB", depth: 1, parentState: parentStateHash },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
const blob = await cas.get(startHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
|
||||
expect(parsed.node.refs).toEqual([promptHash, parentStateHash]);
|
||||
expect(parsed.node.payload.parentState).toBe(parentStateHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putStateNode — childThread in refs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "wf-cas-nodes-state-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refs does not include childThread when childThread is null", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const startHash = await cas.put("start");
|
||||
const contentHash = await cas.put("content");
|
||||
const stateHash = await putStateNode(cas, {
|
||||
role: "planner",
|
||||
meta: {},
|
||||
start: startHash,
|
||||
content: contentHash,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const blob = await cas.get(stateHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
|
||||
expect(parsed.node.refs).not.toContain("anything-else");
|
||||
expect(parsed.node.refs).toEqual([startHash, contentHash]);
|
||||
expect(parsed.node.payload.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("refs includes childThread hash when childThread is set", async () => {
|
||||
const cas = createCasStore(join(dir, "cas"));
|
||||
const startHash = await cas.put("start");
|
||||
const contentHash = await cas.put("content");
|
||||
const childEndHash = await cas.put("child-end-state");
|
||||
const stateHash = await putStateNode(cas, {
|
||||
role: "developer",
|
||||
meta: { pr: 42 },
|
||||
start: startHash,
|
||||
content: contentHash,
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 2000,
|
||||
childThread: childEndHash,
|
||||
});
|
||||
|
||||
const blob = await cas.get(stateHash);
|
||||
expect(blob).not.toBeNull();
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
|
||||
expect(parsed.node.refs).toContain(childEndHash);
|
||||
expect(parsed.node.payload.childThread).toBe(childEndHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCasThreadNode — legacy node compatibility", () => {
|
||||
test("start node without parentState field defaults to null", () => {
|
||||
const yaml = stringify({
|
||||
type: "start",
|
||||
payload: { name: "demo", hash: "BUNDLEAAAAAAAAA", depth: 0 },
|
||||
refs: ["PROMPTHASH00001"],
|
||||
});
|
||||
const parsed = parseCasThreadNode(yaml);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("start");
|
||||
if (parsed?.kind !== "start") return;
|
||||
expect(parsed.node.payload.parentState).toBeNull();
|
||||
});
|
||||
|
||||
test("state node without childThread field defaults to null", () => {
|
||||
const yaml = stringify({
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "planner",
|
||||
meta: {},
|
||||
start: "STARTHASH00001",
|
||||
content: "CONTENTHASH0001",
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
},
|
||||
refs: ["STARTHASH00001", "CONTENTHASH0001"],
|
||||
});
|
||||
const parsed = parseCasThreadNode(yaml);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.kind).toBe("state");
|
||||
if (parsed?.kind !== "state") return;
|
||||
expect(parsed.node.payload.childThread).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,5 +9,8 @@ export function collectRefs(payload: StateNode["payload"]): string[] {
|
||||
if (payload.compact !== null) {
|
||||
out.push(payload.compact);
|
||||
}
|
||||
if (payload.childThread !== null) {
|
||||
out.push(payload.childThread);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const parentState = value.parentState;
|
||||
if (parentState !== undefined && parentState !== null && typeof parentState !== "string") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.hash === "string" &&
|
||||
@@ -25,6 +29,16 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a raw start payload, defaulting `parentState` to `null` for legacy nodes. */
|
||||
function normalizeStartPayload(raw: StartNodePayload): StartNodePayload {
|
||||
return {
|
||||
name: raw.name,
|
||||
hash: raw.hash,
|
||||
depth: raw.depth,
|
||||
parentState: raw.parentState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
@@ -41,6 +55,10 @@ function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
if (!isRecord(meta)) {
|
||||
return false;
|
||||
}
|
||||
const childThread = value.childThread;
|
||||
if (childThread !== undefined && childThread !== null && typeof childThread !== "string") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.start === "string" &&
|
||||
@@ -49,6 +67,20 @@ function isStatePayload(value: unknown): value is StateNodePayload {
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a raw state payload, defaulting `childThread` to `null` for legacy nodes. */
|
||||
function normalizeStatePayload(raw: StateNodePayload): StateNodePayload {
|
||||
return {
|
||||
role: raw.role,
|
||||
meta: raw.meta,
|
||||
start: raw.start,
|
||||
content: raw.content,
|
||||
ancestors: raw.ancestors,
|
||||
compact: raw.compact,
|
||||
timestamp: raw.timestamp,
|
||||
childThread: raw.childThread ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses a YAML CAS blob into a typed RFC v3 thread node (or legacy content layout with `children`). */
|
||||
export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null {
|
||||
let raw: unknown;
|
||||
@@ -86,14 +118,22 @@ export function parseCasThreadNode(yamlText: string): ParsedCasThreadNode | null
|
||||
if (!isStartPayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StartNode = { type: "start", payload: raw.payload, refs: [...refs] };
|
||||
const node: StartNode = {
|
||||
type: "start",
|
||||
payload: normalizeStartPayload(raw.payload),
|
||||
refs: [...refs],
|
||||
};
|
||||
return { kind: "start", node };
|
||||
}
|
||||
|
||||
if (!isStatePayload(raw.payload)) {
|
||||
return null;
|
||||
}
|
||||
const node: StateNode = { type: "state", payload: raw.payload, refs: [...refs] };
|
||||
const node: StateNode = {
|
||||
type: "state",
|
||||
payload: normalizeStatePayload(raw.payload),
|
||||
refs: [...refs],
|
||||
};
|
||||
return { kind: "state", node };
|
||||
}
|
||||
|
||||
@@ -143,10 +183,14 @@ export async function putStartNode(
|
||||
payload: StartNode["payload"],
|
||||
promptHash: string,
|
||||
): Promise<string> {
|
||||
const refs = [promptHash];
|
||||
if (payload.parentState !== null) {
|
||||
refs.push(payload.parentState);
|
||||
}
|
||||
const node: StartNode = {
|
||||
type: "start",
|
||||
payload,
|
||||
refs: [promptHash],
|
||||
refs,
|
||||
};
|
||||
return store.put(serializeCasNode(node));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function noLogger(): (tag: string, content: string) => void {
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
depth: 0,
|
||||
parentStateHash: null,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
@@ -144,9 +145,9 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h1 = await runtime.cas.put("plan-text");
|
||||
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1] };
|
||||
yield { role: "planner", contentHash: h1, meta: { plan: 1 }, refs: [h1], childThread: null };
|
||||
const h2 = await runtime.cas.put("code-text");
|
||||
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2] };
|
||||
yield { role: "coder", contentHash: h2, meta: { diff: "y" }, refs: [h2], childThread: null };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
@@ -210,7 +211,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("only-step");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
|
||||
return { returnCode: 0, summary: "completed" };
|
||||
};
|
||||
|
||||
@@ -261,7 +262,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("step");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h], childThread: null };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -59,6 +60,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const c2 = await putContentNodeWithRefs(cas, "c1", []);
|
||||
@@ -70,6 +72,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 2,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
const ec = await putContentNodeWithRefs(cas, "", []);
|
||||
@@ -81,6 +84,7 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
ancestors: [h1],
|
||||
compact: null,
|
||||
timestamp: 3,
|
||||
childThread: null,
|
||||
} satisfies StateNodePayload);
|
||||
|
||||
await upsertThreadEntry(bundleDir, "THREAD_AAAAAAA", {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import type { StartNode, StateNode } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
RoleOutput,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
|
||||
const TEST_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 0
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
function noLogger(): (tag: string, content: string) => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
depth: 0,
|
||||
parentStateHash: null,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot: "/tmp/never",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupStorage(): Promise<{
|
||||
storageRoot: string;
|
||||
casDir: string;
|
||||
}> {
|
||||
const storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-merkle-"));
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_REGISTRY_YAML, "utf8");
|
||||
const casDir = join(storageRoot, "cas");
|
||||
await mkdir(casDir, { recursive: true });
|
||||
return { storageRoot, casDir };
|
||||
}
|
||||
|
||||
async function loadStartNode(cas: CasStore, endHash: string): Promise<StartNode> {
|
||||
const endBlob = await cas.get(endHash);
|
||||
const endParsed = parseCasThreadNode(endBlob ?? "");
|
||||
if (endParsed?.kind !== "state") throw new Error("expected state node");
|
||||
const startBlob = await cas.get(endParsed.node.payload.start);
|
||||
const startParsed = parseCasThreadNode(startBlob ?? "");
|
||||
if (startParsed?.kind !== "start") throw new Error("expected start node");
|
||||
return startParsed.node;
|
||||
}
|
||||
|
||||
async function loadStateNode(cas: CasStore, hash: string): Promise<StateNode> {
|
||||
const blob = await cas.get(hash);
|
||||
const parsed = parseCasThreadNode(blob ?? "");
|
||||
if (parsed?.kind !== "state") throw new Error("expected state node");
|
||||
return parsed.node;
|
||||
}
|
||||
|
||||
describe("Merkle call stack — cross-thread DAG linking (Phase 2)", () => {
|
||||
let storageRoot: string;
|
||||
let casDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupStorage();
|
||||
storageRoot = setup.storageRoot;
|
||||
casDir = setup.casDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("parentStateHash is written into child start node's parentState and refs", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
_runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "parent done" };
|
||||
};
|
||||
|
||||
const parentResult = await executeThread(
|
||||
parentWf,
|
||||
"parent-wf",
|
||||
{ prompt: "parent task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "P_THREAD_01",
|
||||
hash: "PARENTHASH0001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "PARENTHASH0001", "P1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const childWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
_runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "child done" };
|
||||
};
|
||||
|
||||
const childResult = await executeThread(
|
||||
childWf,
|
||||
"child-wf",
|
||||
{ prompt: "child task", steps: [] },
|
||||
makeOptions({ storageRoot, depth: 1, parentStateHash: parentResult.rootHash }),
|
||||
{
|
||||
threadId: "C_THREAD_01",
|
||||
hash: "CHILDHASH00001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "CHILDHASH00001", "C1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const childStart = await loadStartNode(cas, childResult.rootHash);
|
||||
expect(childStart.payload.parentState).toBe(parentResult.rootHash);
|
||||
expect(childStart.refs).toContain(parentResult.rootHash);
|
||||
});
|
||||
|
||||
test("childThread on parent state node points to child's final state and is in refs", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const childFinalHash = "CHILD_FINAL_001";
|
||||
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("developer output");
|
||||
yield {
|
||||
role: "developer",
|
||||
contentHash: h,
|
||||
meta: { action: "delegate" },
|
||||
refs: [h],
|
||||
childThread: childFinalHash,
|
||||
};
|
||||
return { returnCode: 0, summary: "parent complete" };
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
parentWf,
|
||||
"parent-wf",
|
||||
{ prompt: "parent task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "P_THREAD_02",
|
||||
hash: "CTHREAD_TEST01",
|
||||
infoJsonlPath: join(storageRoot, "logs", "CTHREAD_TEST01", "P2.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const endNode = await loadStateNode(cas, result.rootHash);
|
||||
const devStateHash = endNode.payload.ancestors[0] ?? "";
|
||||
const devNode = await loadStateNode(cas, devStateHash);
|
||||
|
||||
expect(devNode.payload.role).toBe("developer");
|
||||
expect(devNode.payload.childThread).toBe(childFinalHash);
|
||||
expect(devNode.refs).toContain(childFinalHash);
|
||||
});
|
||||
|
||||
test("parent state with no child has childThread: null", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const wf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h = await runtime.cas.put("prep output");
|
||||
yield { role: "preparer", contentHash: h, meta: {}, refs: [h], childThread: null };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
const result = await executeThread(
|
||||
wf,
|
||||
"test-wf",
|
||||
{ prompt: "task", steps: [] },
|
||||
makeOptions({ storageRoot }),
|
||||
{
|
||||
threadId: "NULL_CT_01",
|
||||
hash: "NULLCT_TEST001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "NULLCT_TEST001", "N1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
const endNode = await loadStateNode(cas, result.rootHash);
|
||||
const prepHash = endNode.payload.ancestors[0] ?? "";
|
||||
const prepNode = await loadStateNode(cas, prepHash);
|
||||
|
||||
expect(prepNode.payload.childThread).toBeNull();
|
||||
expect(prepNode.refs).not.toContain(null);
|
||||
});
|
||||
|
||||
test("full bidirectional: child parentState is traversable to parent's context", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const parentHash = "BIDIR_PARENT01";
|
||||
|
||||
const parentWf: WorkflowFn = async function* (
|
||||
_thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const h1 = await runtime.cas.put("preparation output");
|
||||
yield {
|
||||
role: "preparer",
|
||||
contentHash: h1,
|
||||
meta: { repoPath: "/test" },
|
||||
refs: [h1],
|
||||
childThread: null,
|
||||
};
|
||||
const h2 = await runtime.cas.put("developer output");
|
||||
yield {
|
||||
role: "developer",
|
||||
contentHash: h2,
|
||||
meta: { action: "code" },
|
||||
refs: [h2],
|
||||
childThread: "CHILD_END_HASH1",
|
||||
};
|
||||
return { returnCode: 0, summary: "all done" };
|
||||
};
|
||||
|
||||
const observedHeads: string[] = [];
|
||||
const opts = makeOptions({
|
||||
storageRoot,
|
||||
awaitAfterEachYield: async () => {
|
||||
const bundleDir = join(storageRoot, "bundles", parentHash);
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, { head: string }>;
|
||||
const head = parsed.BIDIR_T_001?.head ?? null;
|
||||
if (head !== null) observedHeads.push(head);
|
||||
},
|
||||
});
|
||||
|
||||
await executeThread(
|
||||
parentWf,
|
||||
"bidir-wf",
|
||||
{ prompt: "bidir test", steps: [] },
|
||||
opts,
|
||||
{
|
||||
threadId: "BIDIR_T_001",
|
||||
hash: parentHash,
|
||||
infoJsonlPath: join(storageRoot, "logs", parentHash, "BD1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
expect(observedHeads.length).toBe(2);
|
||||
const preparerStateHash = observedHeads[0] ?? "";
|
||||
|
||||
// Execute child with parentState pointing to parent's preparer state
|
||||
// biome-ignore lint/correctness/useYield: testing start-only path
|
||||
const childWf: WorkflowFn = async function* (
|
||||
_t: ThreadContext,
|
||||
_r: WorkflowRuntime,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
return { returnCode: 0, summary: "child ok" };
|
||||
};
|
||||
|
||||
const childResult = await executeThread(
|
||||
childWf,
|
||||
"bidir-child",
|
||||
{ prompt: "child bidir", steps: [] },
|
||||
makeOptions({ storageRoot, depth: 1, parentStateHash: preparerStateHash }),
|
||||
{
|
||||
threadId: "BIDIR_C_001",
|
||||
hash: "BIDIR_CHILD001",
|
||||
infoJsonlPath: join(storageRoot, "logs", "BIDIR_CHILD001", "BC1.info.jsonl"),
|
||||
cas,
|
||||
},
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
// Upward traversal: child start → parentState → preparer state → meta.repoPath
|
||||
const childStart = await loadStartNode(cas, childResult.rootHash);
|
||||
expect(childStart.payload.parentState).toBe(preparerStateHash);
|
||||
|
||||
const parentPrep = await loadStateNode(cas, preparerStateHash);
|
||||
expect(parentPrep.payload.meta.repoPath).toBe("/test");
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,7 @@ async function appendStateForStep(params: {
|
||||
meta: Record<string, unknown>;
|
||||
refs: readonly string[];
|
||||
timestamp: number;
|
||||
childThread: string | null;
|
||||
}): Promise<{ stateHash: string; chain: ChainState }> {
|
||||
const text = await getContentMerklePayload(params.cas, params.contentHash);
|
||||
if (text === null) {
|
||||
@@ -112,6 +113,7 @@ async function appendStateForStep(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: params.childThread,
|
||||
};
|
||||
const stateHash = await putStateNode(params.cas, payload);
|
||||
return {
|
||||
@@ -137,6 +139,7 @@ async function appendEndState(params: {
|
||||
ancestors,
|
||||
compact: null,
|
||||
timestamp: params.timestamp,
|
||||
childThread: null,
|
||||
};
|
||||
return putStateNode(params.cas, payload);
|
||||
}
|
||||
@@ -329,6 +332,7 @@ async function driveWorkflowGenerator(params: {
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
timestamp: ts,
|
||||
childThread: step.childThread ?? null,
|
||||
});
|
||||
chain = written_.chain;
|
||||
await publishHead({ bundleDir, threadId, startHash, headHash: written_.stateHash });
|
||||
@@ -439,6 +443,7 @@ export async function executeThread(
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
depth: options.depth,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
@@ -466,6 +471,7 @@ export async function executeThread(
|
||||
meta: row.meta,
|
||||
refs: row.refs,
|
||||
timestamp: row.timestamp,
|
||||
childThread: null,
|
||||
});
|
||||
chain = written.chain;
|
||||
await publishHead({
|
||||
@@ -487,6 +493,7 @@ export async function executeThread(
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
depth: options.depth,
|
||||
bundleHash: io.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
|
||||
@@ -144,6 +144,7 @@ async function payloadToRoleOutput(cas: CasStore, payload: StateNodePayload): Pr
|
||||
contentHash: payload.content,
|
||||
meta: payload.meta,
|
||||
refs,
|
||||
childThread: payload.childThread,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +241,7 @@ async function buildForkContinuation(params: {
|
||||
ancestors: ancestorsMarker,
|
||||
compact: null,
|
||||
timestamp: Date.now(),
|
||||
childThread: null,
|
||||
};
|
||||
const markerHash = await putStateNode(cas, markerPayload);
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export type PrefilledDiskStep = {
|
||||
export type ExecuteThreadOptions = {
|
||||
/** Passed to the bundle thread context as `ThreadContext.depth`. */
|
||||
depth: number;
|
||||
/** Parent thread's head state hash at spawn time; `null` for top-level threads. */
|
||||
parentStateHash: string | null;
|
||||
signal: AbortSignal;
|
||||
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
|
||||
awaitAfterEachYield: () => Promise<void>;
|
||||
|
||||
@@ -72,11 +72,13 @@ function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
return null;
|
||||
}
|
||||
const childThread = obj.childThread;
|
||||
return {
|
||||
role,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
childThread: typeof childThread === "string" ? childThread : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -497,6 +499,7 @@ async function main(): Promise<void> {
|
||||
{ prompt: cmd.prompt, steps: cmd.steps },
|
||||
{
|
||||
...cmd.options,
|
||||
parentStateHash: null,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
|
||||
forkSourceThreadId: cmd.forkSourceThreadId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
@@ -37,6 +37,13 @@ function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | nul
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -45,7 +52,7 @@ export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options: WorkflowAsAgentOptions | null = null,
|
||||
): AgentFn {
|
||||
return async (ctx: AgentContext): Promise<string> => {
|
||||
return async (ctx: AgentContext): Promise<AgentFnResult> => {
|
||||
const nextDepth = ctx.depth + 1;
|
||||
|
||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||
@@ -89,6 +96,8 @@ export function workflowAsAgent(
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const signalNever = new AbortController();
|
||||
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
try {
|
||||
const result = await executeThread(
|
||||
bundleExportsResult.value.run,
|
||||
@@ -96,6 +105,7 @@ export function workflowAsAgent(
|
||||
input,
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
@@ -107,7 +117,8 @@ export function workflowAsAgent(
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
return { output: summary, childThread: result.rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
|
||||
return {
|
||||
threadId: "test-thread",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: "test",
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./moderator-table.js": {
|
||||
"types": "./dist/moderator-table.d.ts",
|
||||
"import": "./src/moderator-table.ts"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -4,6 +4,8 @@ export type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
/** Parent thread's head state hash at spawn time. `null` for top-level workflows. */
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type StartNode = {
|
||||
@@ -20,6 +22,8 @@ export type StateNodePayload = {
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
/** Child thread's final state hash (workflow-as-agent). `null` when no child spawned. */
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StateNode = {
|
||||
|
||||
@@ -13,11 +13,13 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -36,8 +38,6 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
@@ -51,3 +51,7 @@ export { END, START } from "./types.js";
|
||||
// ── Constructor functions ──────────────────────────────────────────
|
||||
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
// ── Moderator Table ────────────────────────────────────────────────
|
||||
|
||||
export { tableToModerator } from "./moderator-table.js";
|
||||
|
||||
@@ -27,22 +27,9 @@ export type WorkflowRoleDescriptor = {
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Serializable routing edges derived from a moderator transition table. */
|
||||
export type WorkflowGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph;
|
||||
};
|
||||
|
||||
// ── Role & Thread ──────────────────────────────────────────────────
|
||||
@@ -54,6 +41,7 @@ export type RoleOutput = {
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StartStep = {
|
||||
@@ -76,6 +64,7 @@ export type RoleStep<M extends RoleMeta> = {
|
||||
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
depth: number;
|
||||
bundleHash: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
@@ -140,7 +129,9 @@ export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
export type AgentFnResult = string | { output: string; childThread: string | null };
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
|
||||
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
@@ -173,7 +164,7 @@ export type Moderator<M extends RoleMeta> = (
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
table: ModeratorTable<M>;
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
@@ -1,35 +1,12 @@
|
||||
import type {
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
RoleMeta,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-protocol";
|
||||
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
import type { WorkflowRoleSchema } from "./types.js";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest as WorkflowRoleSchema;
|
||||
}
|
||||
|
||||
function graphFromTable<M extends RoleMeta>(table: ModeratorTable<M>): WorkflowGraph {
|
||||
const edges: WorkflowGraphEdge[] = [];
|
||||
const entries = Object.entries(table) as Array<[string, ModeratorTransition<M>[]]>;
|
||||
for (const [from, transitions] of entries) {
|
||||
for (const t of transitions) {
|
||||
const conditionName = t.condition === "FALLBACK" ? "FALLBACK" : t.condition.name;
|
||||
const conditionDescription = t.condition === "FALLBACK" ? null : t.condition.description;
|
||||
const to = t.role === END ? END : t.role;
|
||||
edges.push({ from, to, condition: conditionName, conditionDescription });
|
||||
}
|
||||
}
|
||||
return { edges };
|
||||
}
|
||||
|
||||
export function buildDescriptor<M extends RoleMeta>(
|
||||
def: WorkflowDefinition<M>,
|
||||
): WorkflowDescriptor {
|
||||
@@ -43,9 +20,5 @@ export function buildDescriptor<M extends RoleMeta>(
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return {
|
||||
description: def.description,
|
||||
roles,
|
||||
graph: graphFromTable(def.table),
|
||||
};
|
||||
return { description: def.description, roles };
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
|
||||
|
||||
if (!descriptorExportExists(program)) {
|
||||
return err(
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles, graph }")',
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -1,64 +1,6 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
|
||||
function validateDescriptorGraphEdge(
|
||||
item: unknown,
|
||||
index: number,
|
||||
): Result<WorkflowGraphEdge, string> {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
return err(`descriptor.graph.edges[${index}] must be a non-array object`);
|
||||
}
|
||||
const e = item as Record<string, unknown>;
|
||||
if (typeof e.from !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].from must be a string`);
|
||||
}
|
||||
if (typeof e.to !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].to must be a string`);
|
||||
}
|
||||
if (typeof e.condition !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].condition must be a string`);
|
||||
}
|
||||
const cdRaw = e.conditionDescription;
|
||||
if (cdRaw !== null && cdRaw !== undefined && typeof cdRaw !== "string") {
|
||||
return err(`descriptor.graph.edges[${index}].conditionDescription must be a string or null`);
|
||||
}
|
||||
const conditionDescription: string | null = cdRaw === undefined || cdRaw === null ? null : cdRaw;
|
||||
return ok({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
condition: e.condition,
|
||||
conditionDescription,
|
||||
});
|
||||
}
|
||||
|
||||
function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, string> {
|
||||
if (graphRaw === null || typeof graphRaw !== "object" || Array.isArray(graphRaw)) {
|
||||
return err("descriptor.graph must be a non-array object");
|
||||
}
|
||||
const graphRecord = graphRaw as Record<string, unknown>;
|
||||
const edgesRaw = graphRecord.edges;
|
||||
if (!Array.isArray(edgesRaw)) {
|
||||
return err("descriptor.graph.edges must be an array");
|
||||
}
|
||||
|
||||
const edges: WorkflowGraphEdge[] = [];
|
||||
for (let i = 0; i < edgesRaw.length; i++) {
|
||||
const edgeResult = validateDescriptorGraphEdge(edgesRaw[i], i);
|
||||
if (!edgeResult.ok) {
|
||||
return edgeResult;
|
||||
}
|
||||
edges.push(edgeResult.value);
|
||||
}
|
||||
|
||||
return ok({ edges });
|
||||
}
|
||||
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -94,10 +36,5 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
};
|
||||
}
|
||||
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
if (!graphResult.ok) {
|
||||
return graphResult;
|
||||
}
|
||||
|
||||
return ok({ description, roles, graph: graphResult.value });
|
||||
return ok({ description, roles });
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHAAAAAAAAAAA";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, depth: 2 },
|
||||
{ name: "demo", hash: bundleHash, depth: 2, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 1000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const chCode = await putContentNodeWithRefs(cas, "code body", []);
|
||||
@@ -52,6 +53,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [statePlan],
|
||||
compact: null,
|
||||
timestamp: 2000,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(stateCode, cas);
|
||||
@@ -71,7 +73,7 @@ describe("buildThreadContext", () => {
|
||||
const promptHash = await cas.put("only-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -87,7 +89,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHCCCCCCCCCCC";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, depth: 0 },
|
||||
{ name: "demo", hash: bundleHash, depth: 0, parentState: null },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -100,6 +102,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [],
|
||||
compact: null,
|
||||
timestamp: 500,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const endContent = await putContentNodeWithRefs(cas, "finished", []);
|
||||
@@ -111,6 +114,7 @@ describe("buildThreadContext", () => {
|
||||
ancestors: [state1],
|
||||
compact: null,
|
||||
timestamp: 600,
|
||||
childThread: null,
|
||||
});
|
||||
|
||||
const ctx = await buildThreadContext(endState, cas);
|
||||
|
||||
@@ -54,6 +54,7 @@ async function threadFromStartHead<M extends RoleMeta>(
|
||||
return {
|
||||
threadId: "",
|
||||
depth: p.depth,
|
||||
bundleHash: p.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
@@ -113,6 +114,7 @@ async function threadFromStateHead<M extends RoleMeta>(
|
||||
return {
|
||||
threadId: "",
|
||||
depth: sp.depth,
|
||||
bundleHash: sp.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import {
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
type AgentFnResult,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
@@ -50,6 +50,16 @@ function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[]
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeAgentResult(result: AgentFnResult): {
|
||||
output: string;
|
||||
childThread: string | null;
|
||||
} {
|
||||
if (typeof result === "string") {
|
||||
return { output: result, childThread: null };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
const overrides = binding.overrides;
|
||||
const overrideFn: AgentFn | undefined =
|
||||
@@ -58,9 +68,7 @@ function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
}
|
||||
|
||||
async function advanceOneRound<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles"> & {
|
||||
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
},
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
params: {
|
||||
thread: ModeratorContext<M>;
|
||||
@@ -70,7 +78,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const { thread, runtime } = params;
|
||||
const modCtx: ModeratorContext<M> = thread;
|
||||
|
||||
const next = def.pickNext(modCtx);
|
||||
const next = def.moderator(modCtx);
|
||||
if (!isRoleNext(next)) {
|
||||
return {
|
||||
kind: "complete",
|
||||
@@ -89,9 +97,9 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
};
|
||||
|
||||
const agent = agentForRole(binding, next);
|
||||
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||
const agentResult = normalizeAgentResult(await agent(agentCtx as unknown as AgentContext));
|
||||
|
||||
const agentContentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const agentContentHash = await putContentNodeWithRefs(runtime.cas, agentResult.output, []);
|
||||
|
||||
const extracted = await runtime.extract(
|
||||
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||
@@ -125,25 +133,23 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
childThread: agentResult.childThread,
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator table to runtime agents.
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
* Assign with `export const run = createWorkflow(def, binding)`.
|
||||
*
|
||||
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
|
||||
* engine resolves from the workflow registry's `extract` scene.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
): WorkflowFn {
|
||||
const pickNext = tableToModerator(def.table);
|
||||
const loopDef = { roles: def.roles, pickNext };
|
||||
|
||||
return async function* workflowLoop(
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
@@ -154,7 +160,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
let currentThread = thread as ModeratorContext<M>;
|
||||
|
||||
while (true) {
|
||||
const outcome = await advanceOneRound(loopDef, binding, {
|
||||
const outcome = await advanceOneRound(def, binding, {
|
||||
thread: currentThread,
|
||||
runtime,
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -25,11 +27,9 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
export { END, START, tableToModerator } from "./types.js";
|
||||
|
||||
@@ -7,11 +7,13 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -29,12 +31,10 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developTable } from "../src/moderator.js";
|
||||
import { developModerator } from "../src/index.js";
|
||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||
import type { DevelopMeta } from "../src/roles.js";
|
||||
|
||||
const developModerator = tableToModerator(developTable);
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
@@ -29,6 +26,7 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
@@ -235,7 +233,6 @@ describe("buildDevelopDescriptor", () => {
|
||||
"reviewer",
|
||||
"tester",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBeGreaterThan(0);
|
||||
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
|
||||
@@ -15,8 +15,5 @@
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
export function buildDevelopDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
moderator: developModerator,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { developTable } from "./moderator.js";
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developTable } from "./moderator.js";
|
||||
export { developModerator } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
type CommitterMeta,
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
table: developTable,
|
||||
moderator: developModerator,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
tableToModerator,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
@@ -87,4 +88,4 @@ const table: ModeratorTable<DevelopMeta> = {
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as developTable };
|
||||
export const developModerator = tableToModerator(table);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import {
|
||||
createWorkflow,
|
||||
@@ -15,12 +14,10 @@ import {
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||
import type { SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const solveIssueModerator = tableToModerator(solveIssueTable);
|
||||
|
||||
function jsonResponse(payload: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
@@ -116,6 +113,7 @@ function makeCtx(
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
@@ -181,6 +179,7 @@ function makeThread(prompt: string) {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
@@ -391,7 +390,6 @@ describe("buildSolveIssueDescriptor", () => {
|
||||
"preparer",
|
||||
"submitter",
|
||||
]);
|
||||
expect(validated.value.graph.edges.length).toBe(4);
|
||||
for (const key of ["preparer", "developer", "submitter"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
"@uncaged/workflow-execute": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
moderator: solveIssueModerator,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
@@ -9,7 +9,7 @@ export {
|
||||
developerMetaSchema,
|
||||
developerRole,
|
||||
} from "./developer.js";
|
||||
export { solveIssueTable } from "./moderator.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
@@ -28,5 +28,5 @@ export {
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
table: solveIssueTable,
|
||||
moderator: solveIssueModerator,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { END, type ModeratorTable, START } from "@uncaged/workflow-runtime";
|
||||
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
@@ -9,4 +9,4 @@ const table: ModeratorTable<SolveIssueMeta> = {
|
||||
submitter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export { table as solveIssueTable };
|
||||
export const solveIssueModerator = tableToModerator(table);
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("fix the bug"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
@@ -33,6 +34,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("user task"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||
steps: [
|
||||
@@ -61,6 +63,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("first message full: task content here"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "System." },
|
||||
steps: [
|
||||
@@ -99,6 +102,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("start"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "S" },
|
||||
steps: [
|
||||
|
||||
Reference in New Issue
Block a user