Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cea09ac0 | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a |
@@ -0,0 +1,197 @@
|
||||
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
当 `workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
|
||||
|
||||
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
|
||||
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
|
||||
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
|
||||
|
||||
## Proposal
|
||||
|
||||
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
|
||||
|
||||
### 新增字段
|
||||
|
||||
#### StartNodePayload(子 → 父)
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
|
||||
};
|
||||
```
|
||||
|
||||
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
|
||||
|
||||
#### StateNodePayload(父 → 子)
|
||||
|
||||
```typescript
|
||||
type StateNodePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
start: string;
|
||||
content: string;
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
|
||||
};
|
||||
```
|
||||
|
||||
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
|
||||
|
||||
### refs 同步
|
||||
|
||||
新增的 hash 也必须放进 `refs[]`:
|
||||
|
||||
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
|
||||
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
|
||||
|
||||
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
|
||||
|
||||
### 具体 DAG 结构
|
||||
|
||||
以 `solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
|
||||
|
||||
```
|
||||
父 thread: solve-issue
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
content("fix #191")
|
||||
hash: ABCD1234
|
||||
|
||||
start(solve-issue)
|
||||
hash: START001
|
||||
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
|
||||
refs: [ABCD1234]
|
||||
|
||||
state(preparer)
|
||||
hash: STATE_P1
|
||||
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
|
||||
refs: [PREP_CONTENT]
|
||||
|
||||
state(developer) ──────── 父→子 ────────
|
||||
hash: STATE_D1 │
|
||||
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
|
||||
refs: [DEV_CONTENT, ★CSTATE_END] │
|
||||
│
|
||||
state(submitter) │
|
||||
hash: STATE_S1 │
|
||||
payload: { role: "submitter", ..., childThread: null } │
|
||||
│
|
||||
│
|
||||
子 thread: develop │
|
||||
═══════════════════════════════════════════════════════════ │
|
||||
│
|
||||
content("fix #191") (CAS 去重,可能同 ABCD1234) │
|
||||
hash: CPROMPT1 │
|
||||
──────── 子→父 ──────── │
|
||||
start(develop) │ │
|
||||
hash: CHILD_START │ │
|
||||
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
|
||||
parentState: ★STATE_P1 } │ │
|
||||
refs: [CPROMPT1, ★STATE_P1] │ │
|
||||
│ │
|
||||
state(planner) │ │
|
||||
hash: CSTATE_1 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(coder) │ │
|
||||
hash: CSTATE_2 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(reviewer) → state(tester) → state(committer) │
|
||||
│ │
|
||||
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
|
||||
```
|
||||
|
||||
### 遍历路径
|
||||
|
||||
**子 thread agent 获取父上下文(上行):**
|
||||
```
|
||||
当前 step → start(CHILD_START)
|
||||
→ refs[1] = STATE_P1(父 preparer 的 state)
|
||||
→ payload.meta.repoPath = "/home/.../workflow"
|
||||
→ refs → PREP_CONTENT(完整 preparer 输出)
|
||||
→ payload.start = START001(父的 start node)
|
||||
→ refs[0] = ABCD1234(原始 prompt)
|
||||
```
|
||||
|
||||
**从父 thread 追踪子 thread 执行(下行):**
|
||||
```
|
||||
STATE_D1(父 developer state)
|
||||
→ payload.childThread = CSTATE_END
|
||||
→ 子 thread 最终 state
|
||||
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
|
||||
→ payload.start = CHILD_START(子 thread 入口)
|
||||
```
|
||||
|
||||
**完整调用栈还原:**
|
||||
```
|
||||
任意节点 → 沿 start 找到所属 thread 的 StartNode
|
||||
→ parentState 非 null?沿 parentState 进入父 thread
|
||||
→ 递归直到 parentState = null(顶层 workflow)
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Protocol + CAS 层
|
||||
|
||||
1. `workflow-protocol/src/cas-types.ts` — `StartNodePayload` 加 `parentState: string | null`,`StateNodePayload` 加 `childThread: string | null`
|
||||
2. `workflow-cas/src/nodes.ts` — `putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
|
||||
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
|
||||
|
||||
### Phase 2: Engine 层
|
||||
|
||||
4. `workflow-execute/src/engine/engine.ts` — `executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
|
||||
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
|
||||
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
|
||||
|
||||
### Phase 3: Agent 可观测性
|
||||
|
||||
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
|
||||
8. CLI `thread show` — 显示 parentState / childThread 链接关系
|
||||
|
||||
### Phase 4: 验证
|
||||
|
||||
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
|
||||
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 为什么 childThread 指向 end 而不是 start?
|
||||
|
||||
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
|
||||
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
|
||||
|
||||
### 为什么 parentState 指向 state 而不是 start?
|
||||
|
||||
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
|
||||
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
|
||||
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
|
||||
|
||||
### 为什么同时放字段和 refs?
|
||||
|
||||
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
|
||||
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
|
||||
- 不改 GC 逻辑,只加字段,GC 自然正确
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
|
||||
- 不影响已有 thread 的遍历和 GC
|
||||
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
|
||||
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
|
||||
3. **CLI 展示** — `thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
|
||||
@@ -0,0 +1,224 @@
|
||||
# Dashboard Workflow Graph Visualization
|
||||
|
||||
**Issue**: #198
|
||||
**Status**: In Progress
|
||||
**Author**: xingyue
|
||||
|
||||
## Overview
|
||||
|
||||
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
|
||||
|
||||
## 数据层(✅ 已完成 — PR #201)
|
||||
|
||||
### WorkflowGraph 类型
|
||||
|
||||
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
|
||||
|
||||
```ts
|
||||
type WorkflowGraphEdge = {
|
||||
from: string; // source role 或 "__start__"
|
||||
to: string; // target role 或 "__end__"
|
||||
condition: string; // condition.name 或 "FALLBACK"
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph; // 必填,新 bundle 自动生成
|
||||
};
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
ModeratorTable (WorkflowDefinition.table)
|
||||
→ buildDescriptor() 自动提取 graph
|
||||
→ descriptor.yaml 持久化(hash.yaml)
|
||||
→ CLI serve /workflows/:name API 返回 descriptor
|
||||
→ Dashboard 前端拿到 graph
|
||||
```
|
||||
|
||||
### 剩余数据层工作
|
||||
|
||||
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
|
||||
|
||||
方案:在 `routes-workflow.ts` 的 `GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
|
||||
|
||||
## 前端渲染
|
||||
|
||||
### 库选型:React Flow + dagre
|
||||
|
||||
| 库 | 优势 | 劣势 |
|
||||
|---|---|---|
|
||||
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
|
||||
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
|
||||
| D3 | 完全控制 | 太底层,手撸成本高 |
|
||||
| Cytoscape | 图论强 | React 集成差 |
|
||||
|
||||
**依赖新增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"@xyflow/react": "^12",
|
||||
"@dagrejs/dagre": "^1"
|
||||
}
|
||||
```
|
||||
|
||||
### 图结构映射
|
||||
|
||||
```
|
||||
WorkflowGraph.edges → React Flow nodes + edges
|
||||
|
||||
节点(自动从 edges 推导):
|
||||
- __start__ → 圆形小节点(入口)
|
||||
- role → 圆角矩形,显示 role name + description
|
||||
- __end__ → 圆形小节点(终止)
|
||||
|
||||
边:
|
||||
- FALLBACK → 虚线(dashed),无 label
|
||||
- condition → 实线,label = condition
|
||||
hover tooltip = conditionDescription
|
||||
```
|
||||
|
||||
### 布局
|
||||
|
||||
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
|
||||
|
||||
```ts
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
function layoutGraph(nodes, edges) {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
}
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
return nodes.map((node) => {
|
||||
const pos = g.node(node.id);
|
||||
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时高亮
|
||||
|
||||
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
|
||||
|
||||
高亮逻辑:
|
||||
|
||||
```ts
|
||||
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
|
||||
const states = new Map<string, "completed" | "active">();
|
||||
const roleRecords = records.filter((r) => r.type === "role");
|
||||
|
||||
for (let i = 0; i < roleRecords.length; i++) {
|
||||
const role = roleRecords[i].role;
|
||||
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
|
||||
}
|
||||
|
||||
// 如果有 workflow-result,最后一个 role 也是 completed
|
||||
if (records.some((r) => r.type === "workflow-result")) {
|
||||
for (const [k] of states) {
|
||||
states.set(k, "completed");
|
||||
}
|
||||
states.set("__end__", "completed");
|
||||
}
|
||||
|
||||
states.set("__start__", "completed");
|
||||
return states;
|
||||
}
|
||||
```
|
||||
|
||||
节点样式:
|
||||
|
||||
| 状态 | 样式 |
|
||||
|------|------|
|
||||
| default | `border: var(--color-border)`, 暗色背景 |
|
||||
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
|
||||
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
|
||||
|
||||
边高亮:当 source 和 target 都至少 completed 时,边变绿。
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
workflow-dashboard/src/
|
||||
components/
|
||||
workflow-graph/
|
||||
types.ts — NodeState 等前端类型
|
||||
index.ts — export { WorkflowGraph }
|
||||
workflow-graph.tsx — 主组件,React Flow canvas
|
||||
role-node.tsx — 自定义 role 节点
|
||||
terminal-node.tsx — START/END 圆形节点
|
||||
condition-edge.tsx — 自定义边(虚线/实线 + label)
|
||||
use-layout.ts — dagre 布局 hook
|
||||
```
|
||||
|
||||
### 集成到 ThreadDetail
|
||||
|
||||
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
|
||||
|
||||
```tsx
|
||||
// thread-detail.tsx
|
||||
{graph && (
|
||||
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
|
||||
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
|
||||
|
||||
## 实施计划
|
||||
|
||||
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
|
||||
|
||||
- [x] `WorkflowDefinition.moderator` → `table` (ModeratorTable)
|
||||
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
|
||||
- [x] `buildDescriptor` 自动提取 graph
|
||||
- [x] `validateWorkflowDescriptor` 校验 graph
|
||||
|
||||
### Phase 1: API + 静态图渲染
|
||||
|
||||
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
|
||||
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
|
||||
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
|
||||
4. 实现 `workflow-graph/` 组件集
|
||||
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
|
||||
|
||||
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
|
||||
|
||||
### Phase 2: 运行时高亮
|
||||
|
||||
1. ThreadDetail 根据 records 计算 nodeStates
|
||||
2. 节点/边样式响应状态变化
|
||||
3. SSE live 模式下实时更新高亮
|
||||
|
||||
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
|
||||
|
||||
### Phase 3: 交互增强
|
||||
|
||||
1. 点击节点滚动到对应 role 的 RecordCard
|
||||
2. 边 hover 显示 conditionDescription tooltip
|
||||
3. 节点 hover 显示 role description + schema summary
|
||||
|
||||
**产出**:图和记录列表联动。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
|
||||
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
|
||||
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
|
||||
- **不提交 pnpm-lock.yaml**
|
||||
@@ -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: {} };
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
@@ -153,6 +153,7 @@ 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,6 +24,7 @@ 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,6 +64,7 @@ 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",
|
||||
"Moderator",
|
||||
"ModeratorTable",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
|
||||
@@ -36,6 +36,7 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -186,6 +187,14 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
|
||||
@@ -57,17 +57,13 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -75,7 +71,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
@@ -87,14 +83,14 @@ export {
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
table: helloTemplateTable,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 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\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
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)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
async function readParentStateFromStartNode(
|
||||
cas: { get(hash: string): Promise<string | null> },
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.parentState;
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
|
||||
const parentState = await readParentStateFromStartNode(cas, resolved.start);
|
||||
|
||||
const steps: Array<{
|
||||
role: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
childThread: string | null;
|
||||
}> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||
childThread: fr.payload.childThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
parentState,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -189,25 +189,28 @@ export const run: WorkflowRun;
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Defines the workflow's metadata and role sequence:
|
||||
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
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"]
|
||||
description: string;
|
||||
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
|
||||
The main function that creates and returns a moderator:
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
\`\`\`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.
|
||||
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -226,7 +229,7 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + descriptor)
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
|
||||
@@ -58,13 +58,13 @@ export async function extractWorkspacePath(
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
logger("V3KM8QWP", `workspace extraction failed: ${result.error}`);
|
||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspace = result.value.workspace.trim();
|
||||
if (!workspace.startsWith("/")) {
|
||||
logger("V3KM8QWP", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ function makeCtx(userContent: string): AgentContext {
|
||||
content: userContent,
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState: null,
|
||||
},
|
||||
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,11 +493,13 @@ export async function executeThread(
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
depth: options.depth,
|
||||
bundleHash: io.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
|
||||
@@ -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,30 +52,30 @@ 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);
|
||||
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
||||
return { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
|
||||
}
|
||||
|
||||
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
return `ERROR: ${bundleExportsResult.error}`;
|
||||
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
|
||||
}
|
||||
|
||||
const input = {
|
||||
@@ -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,10 +117,11 @@ 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}`;
|
||||
return { output: `ERROR: ${message}`, childThread: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
|
||||
return {
|
||||
threadId: "test-thread",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: "test",
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
} as StartStep,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
".": {
|
||||
"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,12 +13,12 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -37,6 +37,8 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
@@ -50,7 +52,3 @@ export { END, START } from "./types.js";
|
||||
// ── Constructor functions ──────────────────────────────────────────
|
||||
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
// ── Moderator Table ────────────────────────────────────────────────
|
||||
|
||||
export { tableToModerator } from "./moderator-table.js";
|
||||
|
||||
@@ -27,9 +27,22 @@ 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 ──────────────────────────────────────────────────
|
||||
@@ -41,6 +54,7 @@ export type RoleOutput = {
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
export type StartStep = {
|
||||
@@ -48,6 +62,7 @@ export type StartStep = {
|
||||
content: string;
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
@@ -63,6 +78,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>[];
|
||||
};
|
||||
@@ -127,7 +143,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;
|
||||
@@ -160,7 +178,7 @@ export type Moderator<M extends RoleMeta> = (
|
||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
table: ModeratorTable<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
RoleMeta,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
import type { 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 {
|
||||
@@ -20,5 +43,9 @@ export function buildDescriptor<M extends RoleMeta>(
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return { description: def.description, roles };
|
||||
return {
|
||||
description: def.description,
|
||||
roles,
|
||||
graph: graphFromTable(def.table),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,13 +37,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
spec === "@uncaged/workflow" ||
|
||||
spec === "@uncaged/workflow-runtime" ||
|
||||
spec === "@uncaged/workflow-protocol" ||
|
||||
spec === "@uncaged/workflow-cas" ||
|
||||
spec === "@uncaged/workflow-util"
|
||||
) {
|
||||
if (spec.startsWith("@uncaged/workflow")) {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
@@ -114,7 +108,8 @@ function bindingInitializerIsCallable(init: Node): boolean {
|
||||
return (
|
||||
init.type === "FunctionExpression" ||
|
||||
init.type === "ArrowFunctionExpression" ||
|
||||
init.type === "CallExpression"
|
||||
init.type === "CallExpression" ||
|
||||
init.type === "Identifier"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,7 +399,7 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
|
||||
|
||||
if (!descriptorExportExists(program)) {
|
||||
return err(
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles, graph }")',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
|
||||
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
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 });
|
||||
}
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -36,5 +94,10 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
};
|
||||
}
|
||||
|
||||
return ok({ description, roles });
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
if (!graphResult.ok) {
|
||||
return graphResult;
|
||||
}
|
||||
|
||||
return ok({ description, roles, graph: graphResult.value });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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,11 +54,13 @@ async function threadFromStartHead<M extends RoleMeta>(
|
||||
return {
|
||||
threadId: "",
|
||||
depth: p.depth,
|
||||
bundleHash: p.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: p.parentState,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
@@ -113,11 +115,13 @@ async function threadFromStateHead<M extends RoleMeta>(
|
||||
return {
|
||||
threadId: "",
|
||||
depth: sp.depth,
|
||||
bundleHash: sp.hash,
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: firstTs,
|
||||
parentState: sp.parentState,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import {
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
type AgentFnResult,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
@@ -49,6 +51,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 =
|
||||
@@ -57,7 +69,9 @@ function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
}
|
||||
|
||||
async function advanceOneRound<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
def: Pick<WorkflowDefinition<M>, "roles"> & {
|
||||
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
},
|
||||
binding: AgentBinding,
|
||||
params: {
|
||||
thread: ModeratorContext<M>;
|
||||
@@ -67,7 +81,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const { thread, runtime } = params;
|
||||
const modCtx: ModeratorContext<M> = thread;
|
||||
|
||||
const next = def.moderator(modCtx);
|
||||
const next = def.pickNext(modCtx);
|
||||
if (!isRoleNext(next)) {
|
||||
return {
|
||||
kind: "complete",
|
||||
@@ -86,9 +100,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>>,
|
||||
@@ -122,22 +136,26 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
childThread: agentResult.childThread,
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
* Binds pure role definitions + moderator table 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" | "moderator">,
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
|
||||
binding: AgentBinding,
|
||||
): WorkflowFn {
|
||||
const pickNext = tableToModerator(def.table);
|
||||
const loopDef = { roles: def.roles, pickNext };
|
||||
|
||||
return async function* workflowLoop(
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
@@ -148,7 +166,7 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
let currentThread = thread as ModeratorContext<M>;
|
||||
|
||||
while (true) {
|
||||
const outcome = await advanceOneRound(def, binding, {
|
||||
const outcome = await advanceOneRound(loopDef, binding, {
|
||||
thread: currentThread,
|
||||
runtime,
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -26,9 +26,11 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START, tableToModerator } from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
|
||||
@@ -7,12 +7,12 @@ export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
@@ -30,10 +30,12 @@ export type {
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { developModerator } from "../src/index.js";
|
||||
import { developTable } from "../src/moderator.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",
|
||||
@@ -19,6 +22,7 @@ function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
||||
content: "Implement the feature",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +30,7 @@ function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContex
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
@@ -232,6 +237,7 @@ 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,5 +15,8 @@
|
||||
"@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 { developModerator } from "./moderator.js";
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
export function buildDevelopDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
moderator: developModerator,
|
||||
table: developTable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { developTable } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||
export { developModerator } from "./moderator.js";
|
||||
export { developTable } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
type CommitterMeta,
|
||||
@@ -33,5 +33,5 @@ export {
|
||||
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||
roles: developRoles,
|
||||
moderator: developModerator,
|
||||
table: developTable,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
tableToModerator,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
@@ -88,4 +87,4 @@ const table: ModeratorTable<DevelopMeta> = {
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const developModerator = tableToModerator(table);
|
||||
export { table as developTable };
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -14,10 +15,12 @@ import {
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
import { solveIssueTable, 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,
|
||||
@@ -104,6 +107,7 @@ function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
content: "Fix the flaky login test",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +117,7 @@ function makeCtx(
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
@@ -178,11 +183,13 @@ function makeThread(prompt: string) {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
@@ -388,6 +395,7 @@ 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,6 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*"
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { solveIssueTable } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
moderator: solveIssueModerator,
|
||||
table: solveIssueTable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { solveIssueTable } 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 { solveIssueModerator } from "./moderator.js";
|
||||
export { solveIssueTable } from "./moderator.js";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
@@ -28,5 +28,5 @@ export {
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
moderator: solveIssueModerator,
|
||||
table: solveIssueTable,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
|
||||
import { END, type ModeratorTable, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
@@ -9,4 +9,4 @@ const table: ModeratorTable<SolveIssueMeta> = {
|
||||
submitter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const solveIssueModerator = tableToModerator(table);
|
||||
export { table as solveIssueTable };
|
||||
|
||||
@@ -3,12 +3,13 @@ import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { buildAgentPrompt } from "../src/index.js";
|
||||
|
||||
function startTask(content: string): AgentContext["start"] {
|
||||
function startTask(content: string, parentState: string | null = null): AgentContext["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content,
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +18,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 +35,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 +64,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: [
|
||||
@@ -92,6 +96,35 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("parentState null omits Parent Context section", async () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("top-level task"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).not.toContain("## Parent Context");
|
||||
});
|
||||
|
||||
test("parentState non-null includes Parent Context section with hash", async () => {
|
||||
const parentHash = "01PARENTSTATE0000000000001";
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("child task", parentHash),
|
||||
depth: 1,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain("## Parent Context");
|
||||
expect(text).toContain(parentHash);
|
||||
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
||||
});
|
||||
|
||||
test("middle steps show meta summary only and latest shows hash", async () => {
|
||||
const ha = "01HASHA00000000000000000001";
|
||||
const hb = "01HASHB00000000000000000001";
|
||||
@@ -99,6 +132,7 @@ describe("buildAgentPrompt", () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("start"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "S" },
|
||||
steps: [
|
||||
|
||||
@@ -5,6 +5,19 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
|
||||
if (ctx.start.parentState !== null) {
|
||||
lines.push("## Parent Context");
|
||||
lines.push(
|
||||
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
|
||||
ctx.start.parentState,
|
||||
);
|
||||
lines.push(
|
||||
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user