chore: remove docs/, extract current knowledge to .cards
CI / check (pull_request) Successful in 3m57s

Remove 13 docs files (7 fully outdated @uncaged/* era, 6 superseded).
Extract 3 verified architectural facts as new .cards:

- frontmatter-fast-path: no LLM extraction, pure parse + schema validate + agent self-retry
- agent-cli-protocol: adapter output JSON via stdout, agent-owned step persistence
- status-based-moderator: pure graph lookup + mustache rendering, zero LLM cost

All 3 cards cross-checked against current source code (run.ts, evaluate.ts, frontmatter.ts).
This commit is contained in:
2026-06-07 14:45:23 +00:00
parent 60fdb0a7ff
commit cb3a4acf4d
16 changed files with 71 additions and 4938 deletions
-492
View File
@@ -1,492 +0,0 @@
# Workflow Engine — Architecture
**Last updated:** 2026-05-19
---
## Overview
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in **5** active packages under `packages/`, plus two external CAS packages (`@ocas/core`, `@ocas/fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
## Package map
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@united-workforce/protocol``protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@ocas/fs`. |
| Shared infra | `@united-workforce/util``util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Agent framework | `@united-workforce/util-agent``util-agent` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@united-workforce/agent-hermes``agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@united-workforce/cli``cli` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. Includes status-based graph evaluator in `src/moderator/` (next role or `$END`). |
### External dependencies
| Package | Role |
|---------|------|
| `@ocas/core` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@ocas/fs` | Filesystem backend for `ocas`. |
| `mustache` | Template renderer for edge prompts (used by `cli` moderator). |
| `commander` | CLI argument parsing (used by `cli`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
## Dependency graph
```mermaid
flowchart BT
subgraph External
jcas["@ocas/core"]
jcasfs["@ocas/fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@united-workforce/protocol"]
end
subgraph L1["Layer 1 — shared"]
util["@united-workforce/util"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@united-workforce/util-agent"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@united-workforce/agent-hermes"]
end
subgraph L4["Layer 4 — CLI"]
cli["@united-workforce/cli"]
end
protocol --> jcasfs
util --> protocol
kit --> protocol
kit --> util
kit --> jcas
kit --> jcasfs
hermes --> kit
hermes --> jcas
cli --> protocol
cli --> util
cli --> kit
cli --> jcas
cli --> jcasfs
```
## Workflow definition
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
Example (`examples/solve-issue.yaml`):
```yaml
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
capabilities:
- issue-analysis
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. Implement the plan."
capabilities:
- file-edit
- shell
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
meta:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. Review the implementation."
capabilities:
- code-review
procedure: "Review the implementation against the plan."
output: "Approve or reject with detailed comments."
meta:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`graph`** — `Record<Role | "$START", Record<Status, Target>>` — status-based routing; each role maps statuses to targets
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@ocas/core`
## Three-phase engine loop
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli/src/commands/thread.ts` (`cmdThreadStep`).
```
┌─→ Phase 1: MODERATOR
│ Input: graph + lastRole + lastOutput
│ Engine: Status-based map lookup against lastOutput.status
│ Output: next role name | $END
│ Phase 2: AGENT
│ Input: thread-id + role (via argv)
│ Engine: agent-kit builds context from CAS chain, prepends
│ output format instruction to system prompt, spawns agent
│ Output: raw string (frontmatter markdown)
│ Phase 3: EXTRACT
│ Input: raw agent output + role's meta schema
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
│ Persist: StepNode { start, prev, role, output, detail, agent }
│ Update: threads.yaml head pointer
└─────────────────────────────────────────────────────────────────┘
```
### Context types
Defined in `packages/protocol/src/types.ts`:
```typescript
type StepContext = {
role: string;
output: unknown; // CAS node payload, expanded (not hash)
detail: CasRef;
agent: string;
};
type ModeratorContext = {
start: StartNodePayload; // { workflow: CasRef, prompt: string }
steps: StepContext[]; // chronological, oldest first
};
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
```
### Key properties
- **Moderator** — pure status-based map lookup; no LLM call, no I/O beyond CAS reads. Looks up `graph[lastRole][lastOutput.status]` to get the next target.
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
## Agent CLI protocol
Each agent is an external command invoked by `uwf thread step`:
```bash
<agent-cmd> <thread-id> <role>
```
Contract:
1. `uwf thread step` determines the next role via the moderator
2. Agent CLI is spawned with `(thread-id, role)` as positional args
3. `util-agent` (`createAgent`) handles the boilerplate:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
- Calls the agent's `run` function
- Runs two-layer extract on the raw output
- Writes `StepNode` to CAS (output + detail + prev link)
- Prints the new `StepNode` CAS hash to stdout
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
5. Exit 0 = success, non-zero = failure
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
## Agent output format: frontmatter markdown (RFC #351)
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
```markdown
---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/auth.ts
scope: role
---
## Implementation
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `util-agent`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
## Two-layer extract
Structured output extraction uses a two-layer strategy (`util-agent`):
### Layer 1: frontmatter fast path (`frontmatter.ts`)
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `ocas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
1. Resolve extract model alias from config (`modelOverrides.extract``models.extract``defaultModel`)
2. Call OpenAI-compatible chat completion with JSON mode
3. System prompt: "Extract structured data matching this JSON Schema: ..."
4. User message: the raw agent output
5. Parse response, `store.put()`, validate
6. Return `outputHash`
## Prompt injection
`util-agent` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
## CAS node types
### Workflow
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
```
### StartNode
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
### StepNode
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode
prev: "2MXBG6PN4A8JR" # ocas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # ocas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
### Chain structure
```
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → Workflow (CAS)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── prev ──→ StepNode (step 1)
│ │ └── prev: null
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(session turns)
└── agent: "uwf-hermes"
```
## Storage layout
```
~/.uwf/
├── cas/ # json-cas filesystem store (all CAS nodes)
├── config.yaml # Provider, model, agent configuration
├── threads.yaml # Active thread head pointers: threadId → CasRef
├── history.jsonl # Archived thread records
├── registry.yaml # Workflow name → CAS hash mapping
└── .env # API keys (loaded by dotenv)
```
### Mutable state
Only three files carry mutable state:
| File | Contents |
|------|----------|
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
| `registry.yaml` | Workflow name → current CAS hash |
Everything else is immutable CAS content.
### ID encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
### Config (`config.yaml`)
```yaml
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-..."
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
## CLI commands
Binary: `uwf`
### Thread commands
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
### Workflow commands
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
| `uwf workflow list` | List registered workflows. |
### CAS commands
Use the `ocas` CLI for direct CAS operations (`~/.ocas/` store, shared with `uwf`):
| Command | Description |
|---------|-------------|
| `ocas get <hash>` | Read a CAS node. |
| `ocas put <type-hash> <data>` | Store a node, print its hash. |
| `ocas has <hash>` | Check if a hash exists. |
| `ocas refs <hash>` | List direct CAS references. |
| `ocas walk <hash>` | Recursive traversal from a node. |
| `ocas reindex` | Rebuild type index from all nodes. |
| `ocas schema list` | List registered schemas. |
| `ocas schema get <hash>` | Show a schema by type hash. |
### Setup
| Command | Description |
|---------|-------------|
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
## Toolchain
| Tool | Purpose |
|------|---------|
| **pnpm** | Package manager |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format |
| **vitest** | Test runner |
## Design decisions
| Decision | Rationale |
|----------|-----------|
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| **Status-based moderator** | Status-based map routing — `graph[role][status]` lookup against last output. No LLM cost for routing decisions. |
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `ocas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
-779
View File
@@ -1,779 +0,0 @@
# Built-in Role Agent 调研
## 目标
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
---
## 关键问题
### Q1: Agent 接口协议
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
**调研要点:**
- `cli``spawnAgent` 的完整实现
- AgentConfig 类型定义
- agent 进程的 exit code 约定
- 环境变量传递(UWF_STORAGE_ROOT 等)
**答案:**
#### 调用链
`uwf thread step``cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig``spawnAgent`
#### AgentConfig 类型
```146:149:packages/protocol/src/types.ts
export type AgentConfig = {
command: string;
args: string[];
};
```
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
#### spawnAgent 行为
```627:653:packages/cli/src/commands/thread.ts
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
const argv = [...agent.args, threadId, role];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
// ... stderr 拼进 fail 消息
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
```
| 项目 | 约定 |
|------|------|
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
| **stdin** | 忽略 |
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
Agent 解析优先级(`resolveAgentConfig`):
1. CLI `--agent` override(整段 command + args 字符串)
2. `config.agentOverrides[workflow.name][role]`
3. `config.defaultAgent`
#### 环境变量:Storage Root
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`util-agent` / `cli` 一致):
```33:43:packages/util-agent/src/storage.ts
export function resolveStorageRoot(): string {
const internal = process.env.UWF_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
```
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uwf/.env`。
#### Agent 侧职责(设计文档 + 实现)
- 读 `threads.yaml` 链头,构建 context,执行 role
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
- stdout 打印 step hash
- **不**更新 `threads.yaml`
---
### Q2: createAgent 工厂
util-agent 的 `createAgent` 做了什么?它的完整生命周期是什么?
**调研要点:**
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
- `AgentRunResult` 的完整定义
- retry 逻辑(frontmatter 校验失败后的重试机制)
- `persistStep` 写入 CAS 的 StepNode 结构
**答案:**
#### 类型定义
```4:35:packages/util-agent/src/types.ts
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
export type AgentRunResult = {
output: string;
detailHash: CasRef;
sessionId: string;
};
export type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
continue: AgentContinueFn;
};
```
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
#### 生命周期(按执行顺序)
```101:152:packages/util-agent/src/run.ts
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const ctx = await buildContextWithMeta(threadId, role);
// 1. 校验 role 存在
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
let agentResult = await options.run(ctx);
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) { fail(...); }
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
process.stdout.write(`${stepHash}\n`);
};
}
```
| 阶段 | 行为 |
|------|------|
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
| Run | `options.run(ctx)` |
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
| Persist | `persistStep` → `writeStepNode` |
| 输出 | stdout 一行 step CAS hash |
#### StepNode 写入结构
```44:68:packages/util-agent/src/run.ts
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
startHash: CasRef;
prevHash: CasRef | null;
role: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
prev: options.prevHash,
role: options.role,
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
};
// store.put(stepNode schema) + validate
}
```
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
---
### Q3: Context Builder
`buildContextWithMeta` 构建了什么上下文给 agent?
**调研要点:**
- `AgentContext` 完整类型定义(所有字段)
- context 构建过程(CAS chain walk)
- `outputFormatInstruction` 怎么生成的
- role definition 怎么获取(从 workflow YAML)
**答案:**
#### AgentContext 字段
继承 `ModeratorContext`:
```60:68:packages/protocol/src/types.ts
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
```
```48:51:packages/protocol/src/types.ts
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
```
```61:63:packages/protocol/src/types.ts
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
```
`AgentContext` 额外字段:
| 字段 | 类型 | 含义 |
|------|------|------|
| `threadId` | `ThreadId` | 当前线程 |
| `role` | `string` | 本步要执行的角色名 |
| `store` | `Store` | CAS store(读写节点) |
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
`buildContextWithMeta` 还返回 `meta`:
```148:154:packages/util-agent/src/context.ts
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
```
#### CAS chain walk
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / moderator 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
#### outputFormatInstruction
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
```typescript
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
```
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
---
### Q4: Extract Pipeline
agent 输出怎么被处理成结构化数据?
**调研要点:**
- frontmatter fast-path 的完整逻辑
- LLM extract fallback 的实现(`extract.ts`)
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
- 校验失败时的 correction prompt 是什么
**答案:**
#### Schema 来源
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
```66:76:packages/cli/src/commands/workflow.ts
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
// 校验为 JSON Schema → putSchema → 返回 CasRef
}
```
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
#### Frontmatter fast-path(createAgent 实际使用的路径)
```148:195:packages/util-agent/src/frontmatter.ts
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null>
```
流程:
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
2. `validateFrontmatter` 失败 → `null`
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
**永不抛错**,失败返回 `null`。
#### LLM extract fallback(已实现但未接入 createAgent)
```135:181:packages/util-agent/src/extract.ts
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult>
```
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
- 校验通过后 `store.put(outputSchema, structured)`
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
#### Correction prompt(retry)
```125:128:packages/util-agent/src/run.ts
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
"Please output ONLY the corrected frontmatter block followed by your work.";
```
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
---
### Q5: Model 配置与 LLM 调用
workflow 怎么配置和使用 model?
**调研要点:**
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
- `resolveModel` 函数的实现
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
- 有没有 streaming 支持?tool calling 支持?
**答案:**
#### WorkflowConfig
```136:160:packages/protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
```
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
#### resolveModel
```32:50:packages/util-agent/src/extract.ts
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = providerEntry.apiKey;
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
Extract 专用别名解析:
```18:30:packages/util-agent/src/extract.ts
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
}
```
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
#### chatCompletionText
```87:124:packages/util-agent/src/extract.ts
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string>
```
| 能力 | 现状 |
|------|------|
| 协议 | OpenAI 兼容 `POST /chat/completions` |
| Streaming | **无**(一次性 `response.text()`) |
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
| 多模态 | **无**(仅 text `content`) |
| Extract 专用 | `response_format: { type: "json_object" }` |
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `agent-builtin` 或扩展 `util-agent` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
---
### Q6: Hermes Agent 参考实现
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
**调研要点:**
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
- hermes CLI 的调用参数
- session management(resume)
- 输出怎么捕获
**答案:**
#### Prompt 组装
```40:53:packages/agent-hermes/src/hermes.ts
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
```
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
#### Hermes CLI 参数
首次:
```88:97:packages/agent-hermes/src/hermes.ts
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
```
续聊:
```100:114:packages/agent-hermes/src/hermes.ts
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
```
#### Session
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
- 会话文件:`~/.hermes/sessions/session_<id>.json`
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
#### 与 createAgent 的衔接
```157:164:packages/agent-hermes/src/hermes.ts
export function createHermesAgent(): () => Promise<void> {
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
}
```
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
Claude Code 包(`agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
---
### Q7: Toolkit 需求分析
要实现一个自给自足的 agent,最少需要哪些 tool?
**调研要点:**
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
- hermes agent 在 workflow 场景下常用哪些 tool
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
**答案:**
#### solve-issue.yaml 角色能力
| Role | capabilities | 隐含需求 |
|------|----------------|----------|
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
#### Hermes 侧
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
#### Builtin 最小 toolkit 建议
| 优先级 | Tool | 用途 |
|--------|------|------|
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
| P0 | `write_file` / `edit_file` | developer 改代码 |
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
| P1 | `list_dir` / `glob` | 导航代码库 |
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + status-based moderator 负责。
#### Agent loop 必须能力
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
2. 将 tool 结果 append 回 messages
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
---
## 方案草案
(调研完成后基于以上答案撰写)
### 架构设计
```mermaid
flowchart TB
subgraph cli ["cli"]
Step["uwf thread step"]
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
Step --> Spawn
end
subgraph builtin_pkg ["@united-workforce/agent-builtin"]
Main["createBuiltinAgent() = createAgent({...})"]
Prompt["buildBuiltinPrompt(ctx)"]
Loop["runBuiltinLoop(provider, messages, tools)"]
Tools["Toolkit: read/write/exec/..."]
Detail["storeBuiltinDetail(turns)"]
Main --> Prompt
Main --> Loop
Loop --> Tools
Loop --> Detail
end
subgraph kit ["util-agent"]
Ctx["buildContextWithMeta"]
FM["tryFrontmatterFastPath"]
Persist["persistStep"]
Ctx --> Main
Main --> FM
FM --> Persist
end
subgraph cas ["CAS / config"]
Config["config.yaml models/providers"]
CAS["cas/ + threads.yaml"]
end
Spawn --> Main
Config --> Loop
CAS --> Ctx
Persist --> CAS
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/agent-builtin`,bin `uwf-builtin`,仅依赖 `util-agent`、`protocol`、`util`(可选 `@ocas/core` 写 detail schema)。
**分层**:
| 层 | 职责 |
|----|------|
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
| `builtin/agent.ts` | `run` / `continue` 实现 |
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
**配置集成**:
```yaml
agents:
builtin:
command: "uwf-builtin"
args: []
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
```
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
---
### Agent Run Loop
伪代码(单次 `run(ctx)`):
```
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
3. messages ← [{ role: "system", content: system }]
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
5. turns ← []
6. for turn in 1..MAX_TURNS:
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
record assistant message + tool_calls in turns
if response has no tool_calls:
finalText ← response.content
break
for each tool_call:
result ← executeTool(tool_call, { cwd: process.cwd() })
messages.push tool result
record in turns
7. if no finalText with valid frontmatter after loop:
optionally one-shot "finalize" message without tools
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
9. return { output: finalText, detailHash, sessionId }
```
**`continue(sessionId, message, store)`**:
- 从内存/磁盘恢复 `messages` + `turns`
- `messages.push({ role: "user", content: message })`(correction 或续聊)
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
- 返回新的 `AgentRunResult`
**与 frontmatter 的配合**:
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
**安全**:
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
- 路径:禁止 `..` 逃逸出 workspace root
---
### Toolkit 设计
统一注册表:
```typescript
type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema; // object type
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
type ToolContext = {
cwd: string;
storageRoot: string;
};
```
| Tool name | OpenAI function | 行为摘要 |
|-----------|-----------------|----------|
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
| `edit_file` | 可选 | search/replace 块,减少 token |
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
**LLM 请求形状**(扩展 extract 客户端):
```json
{
"model": "...",
"messages": [...],
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
"tool_choice": "auto"
}
```
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
---
### 与现有架构的集成
| 集成点 | 方式 |
|--------|------|
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
| 发布 | 新包 `@united-workforce/agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
**明确不做**:
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
- 首版不实现 streaming、不实现 MCP
**建议实现顺序**:
1. `llm.ts`:tool calling HTTP 客户端 + 单测
2. P0 tools + `runBuiltinLoop`
3. `createBuiltinAgent` + detail CAS
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
5. (可选)`createAgent` 接入 `extract()` fallback
@@ -1,73 +0,0 @@
# Issue #418: ACP session/resume 返回空文本
## 调研日期: 2026-05-23
## 根因
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
### 完整调用链
```
resume_session(sid)
→ update_cwd(sid)
→ get_session(sid) → _restore(sid)
→ _make_agent()
→ resolve_runtime_provider("custom") 失败(line 548-561)
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
→ except Exception 静默吞掉(line 482-484)→ return None
→ return None
→ state is None → fallback: create_session()(新 sid,无历史)
```
### 关键代码位置(acp_adapter/session.py)
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
### 实测行为
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
2. Phase 2: `session/resume` + `prompt`
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
### 验证脚本
```python
# 直接调用 _restore 验证
cd ~/.hermes/hermes-agent
python3 -c "
import sys; sys.path.insert(0, '.')
from acp_adapter.session import SessionManager
sm = SessionManager()
result = sm._restore('SESSION_ID_HERE')
print(result) # None — _make_agent 抛异常被吞掉
"
```
### 两个 bug
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url``api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
### Hermes 版本
- v0.10.0 (2026.4.16) — 初始测试
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
### v0.14.0 测试结果 (2026-05-23)
- `_restore` 仍因 `custom` provider 解析失败返回 None
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
### 上游 Issue
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
-27
View File
@@ -1,27 +0,0 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Exception (must include a comment explaining why)
1. **Bundle loader** — loads user-authored workflow bundles whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user bundle path resolved at runtime
const mod = await import(bundlePath);
```
## Test Files
Test files (`__tests__/**`) are exempt.
-317
View File
@@ -1,317 +0,0 @@
# Workflow-as-Agent Implementation Plan
> ⚠️ This plan references the pre-split package structure. File paths have changed.
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
---
## Phase 1: Global CAS Migration
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
**Objective:** Provide a single function that returns the global CAS directory path.
**Files:**
- Modify: `packages/workflow/src/storage-root.ts`
- Test: `packages/workflow/__tests__/storage-root.test.ts`
**Implementation:**
```typescript
// storage-root.ts — add export
export function getGlobalCasDir(storageRoot?: string): string {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
return join(root, "cas");
}
```
Export from `packages/workflow/src/index.ts`.
### Task 1.2: Update `cmd-cas.ts` to use global CAS
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
**Files:**
- Modify: `packages/cli/src/cmd-cas.ts`
**Changes:**
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
```typescript
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
export async function cmdCasGet(
storageRoot: string,
_threadId: string, // kept for CLI compat, not used for path
hash: string,
): Promise<Result<string, string>> {
const cas = createThreadCas(getGlobalCasDir(storageRoot));
const content = await cas.get(hash);
if (content === null) {
return err(`cas entry not found: ${hash}`);
}
return ok(content);
}
// ... same pattern for put/list/rm
```
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
**Files:**
- Modify: `packages/cli/src/cmd-thread.ts`
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
### Task 1.4: Rename `createThreadCas` → `createCasStore`
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
**Files:**
- Modify: `packages/workflow/src/cas.ts` — rename function
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
- Modify: all consumers (`cmd-cas.ts`)
### Task 1.5: Update tests
**Objective:** All CAS-related tests use global dir instead of thread-local.
**Files:**
- Modify: `packages/cli/__tests__/commands.test.ts`
- Verify: `bun test` passes
### Task 1.6: Clean up old thread-local `.cas/` references
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
**Files:**
- Search all `*.ts` for `.cas` path construction patterns
- Remove orphaned helpers
---
## Phase 2: RoleStep `refs` Tracking
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
**Files:**
- Modify: `packages/workflow/src/types.ts`
- Modify: `packages/workflow/src/engine.ts`
**Changes to `types.ts`:**
```typescript
export type RoleOutput = {
role: string;
content: string;
meta: Record<string, unknown>;
refs: string[]; // CAS hashes produced/consumed by this step
};
```
**Changes to `engine.ts`:**
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
### Task 2.2: Auto-populate refs from meta hashes
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
**Files:**
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
```typescript
// types.ts — RoleDefinition addition
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
};
// planner.ts
extractRefs: (meta) => meta.phases.map(p => p.hash),
// coder.ts
extractRefs: (meta) => [meta.completedPhase],
```
### Task 2.3: Update fork logic to preserve refs
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
**Files:**
- Modify: `packages/workflow/src/fork-thread.ts`
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
### Task 2.4: Tests for refs tracking
**Files:**
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
- Verify: refs appear in `.data.jsonl` output
---
## Phase 3: CAS Garbage Collection
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
**Files:**
- Create: `packages/workflow/src/gc.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
export type GcResult = {
scannedThreads: number;
activeRefs: number;
deletedEntries: number;
deletedHashes: string[];
};
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
// 1. Find all .data.jsonl files under storageRoot
// 2. Parse each, flatMap step.refs → Set<string>
// 3. List all CAS entries via createCasStore(globalCasDir).list()
// 4. Delete entries not in active set
// 5. Return stats
}
```
### Task 3.2: Add `uncaged-workflow gc` CLI command
**Files:**
- Create: `packages/cli/src/cmd-gc.ts`
- Modify: `packages/cli/src/cli-dispatch.ts` — add `gc` subcommand
### Task 3.3: Run GC on `thread rm`
**Files:**
- Modify: `packages/cli/src/cmd-thread.ts` — after deleting thread data, optionally run GC
### Task 3.4: Tests for GC
**Files:**
- Create: `packages/cli/__tests__/gc-cli.test.ts`
---
## Phase 4: `workflowAsAgent` Factory
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
**Files:**
- Create: `packages/workflow/src/workflow-as-agent.ts`
- Export from: `packages/workflow/src/index.ts`
```typescript
import type { AgentFn } from "./types.js";
export type WorkflowAsAgentOptions = {
storageRoot?: string;
};
export function workflowAsAgent(
workflowName: string,
options?: WorkflowAsAgentOptions,
): AgentFn {
return async (ctx) => {
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
// 1. Read registry → resolve name to bundle hash + path
const registry = await readWorkflowRegistry(storageRoot);
const entry = getRegisteredWorkflow(registry, workflowName);
if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`;
}
// 2. Load bundle
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExports = await extractBundleExports(bundlePath);
// 3. Create child thread input from ctx.start.content (parent prompt)
const input: ThreadInput = {
prompt: ctx.start.content,
steps: [],
};
// 4. Generate child threadId
const childThreadId = generateUlid();
// 5. Execute — collect all yields, return final content
const io: ExecuteThreadIo = { ... };
const result = await executeThread(bundleExports.run, workflowName, input, ...);
// 6. Return summary as agent content
return result.summary;
};
}
```
### Task 4.2: System-level depth limit
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
**Files:**
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
- Modify: registry or config types for `maxDepth` setting
### Task 4.3: Tests for workflowAsAgent
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
- Test: name resolution, depth limit, child thread execution
### Task 4.4: Integration test — nested workflow
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
**Files:**
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
---
## Execution Order
```
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
```
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
## Breaking Changes
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
- `RoleOutput` gains required `refs: string[]` field
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
-262
View File
@@ -1,262 +0,0 @@
# RFC: CAS-Based Thread Storage
> Status: Draft
> Author: 小橘 🍊(NEKO Team)
> Date: 2026-05-09
## Summary
Replace `.data.jsonl` with a fully CAS-based thread state chain. Threads become linked lists of immutable CAS nodes, indexed by a per-bundle `threads.json`.
## Motivation
`.data.jsonl` is a flat append-only file with three different row formats (start, role step, end). This makes forking expensive (copy file), deduplication impossible (forked threads repeat shared history), and GC complex (must parse every row to find CAS refs).
Threads are inherently immutable append-only sequences — a natural fit for CAS hash chains, similar to git's commit DAG.
## Design
### Node Types
Two CAS node types, using the existing `{ type, payload, refs }` CAS blob structure:
#### StartNode
Contains workflow-level parameters. **No threadId** (because the same StartNode can be shared across forks). Prompt is stored as a CAS blob and referenced via `refs[0]`.
```
CAS blob:
{
type: "start",
payload: {
name: "solve-issue",
hash: "BUNDLE_HASH",
maxRounds: 10,
depth: 0
},
refs: [
<prompt_hash> // refs[0]: initial task prompt (CAS blob)
]
}
```
- No `role`, `content`, `meta` — this is not a step, it's workflow metadata
- Prompt is **not** inline — it lives in CAS and is referenced by hash
#### StateNode
One per role step (including `__end__`).
```
CAS blob:
{
type: "state",
payload: {
role: "coder",
meta: { ... },
start: "<start_hash>",
content: "<content_merkle_hash>",
ancestors: ["<parent_hash>", "<grandparent_hash>", ...],
compact: null,
timestamp: 1234567890
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
**Payload is the source of truth.** Application code reads named fields from payload. `refs[]` is a **GC index** — automatically derived from payload by collecting all CAS hashes. GC only scans `refs[]` without understanding payload structure.
**Payload fields:**
| Field | Type | Meaning |
|-------|------|---------|
| `role` | `string` | Role name, or `"__end__"` for completion |
| `meta` | `object` | Structured metadata extracted from agent output |
| `start` | `string` | StartNode hash |
| `content` | `string` | Content Merkle node hash (carries role artifact refs) |
| `ancestors` | `string[]` | `[parent, grandparent, ...]` — up to 11 entries (1 parent + 10 skip-list). Empty for first step after start. `ancestors[0]` is the direct parent. |
| `compact` | `string \| null` | CAS hash of a compacted summary of all nodes before this one. When present, LLM context assembly can use this instead of walking the full chain. |
| `timestamp` | `number` | Unix timestamp in ms |
### Content Merkle Node
The content at `refs[2]` of each StateNode is itself a CAS Merkle node. This is where **role artifact references** live:
```
CAS blob:
{
type: "content",
payload: "<role output text>",
refs: [
<artifact_hash_1>, // e.g. a commit, a file, a sub-result
<artifact_hash_2>,
...
]
}
```
The Extractor is responsible for producing both `meta` and `refs` from raw agent output:
```
Agent raw output
Extractor → { meta, contentPayload, refs[] }
CAS put content Merkle: { type: "content", payload: contentPayload, refs }
↓ contentHash
StateNode: { ..., refs: [start, parent, contentHash, ...ancestors] }
```
This keeps StateNode refs fixed and simple. All role-specific artifact references are encapsulated in the content Merkle node. GC follows: `thread head → StateNode.refs → content Merkle.refs → artifacts`, full chain recursive.
### End Node
An end is just a StateNode with `role: "__end__"`:
```
{
type: "state",
payload: {
role: "__end__",
meta: { returnCode: 0, summary: "completed successfully" },
start: "<start_hash>",
content: "<content_hash>",
ancestors: ["<parent_hash>", ...],
compact: null,
timestamp: 1234567891
},
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
}
```
### Thread Index: `threads.json`
Per-bundle directory, one `threads.json` file. **Only active (in-progress) threads** live here:
```
~/.uncaged/workflow/bundles/<hash>/threads.json
```
```json
{
"01JTHREAD1AAAAAAAAAAAAAAA": {
"head": "<latest_state_node_hash>",
"start": "<start_node_hash>",
"updatedAt": 1234567891
}
}
```
When a thread completes (`__end__`), it is **removed from `threads.json`** and appended to a date-partitioned history file:
```
~/.uncaged/workflow/bundles/<hash>/history/{YYYY-MM-DD}.jsonl
```
Each line:
```json
{"threadId":"01JTHREAD1AAAAAAAAAAAAAAA","head":"<end_node_hash>","start":"<start_node_hash>","completedAt":1234567891}
```
Benefits:
- `threads.json` stays small — only in-flight threads
- Dashboard watches `threads.json` for real-time updates; completed threads don't trigger watches
- History is queryable by date but not actively monitored
- GC roots = all heads from `threads.json` + all heads from `history/*.jsonl`
### Ancestor Skip-List
Each StateNode carries up to 11 entries in `payload.ancestors` (1 parent + 10 skip-list, newest first):
```
Node 15: ancestors = [node14, node13, node12, node11, node10, node9, node8, node7, node6, node5, node4]
^parent ^--- skip-list (10 most recent) ---^
```
This enables:
- **Paginated fetch**: jump to any recent ancestor without walking the full chain
- **Partial replay**: fetch last N steps without loading the entire history
- The list is capped at 10 to keep node size bounded
### Fork
Forking a thread at step N:
1. Create new threadId
2. Create a new StateNode whose `parent` (refs[1]) points to the fork point's StateNode
3. Register the new threadId in `threads.json` with its own head
4. **Zero data duplication** — the forked thread shares all ancestor nodes via CAS
### Compact
When a StateNode has `payload.compact` set:
```json
{
"type": "state",
"payload": {
"role": "coder",
"meta": { ... },
"compact": "<cas_hash_of_summary>",
"timestamp": 1234
},
"refs": [...]
}
```
This means: "everything before this node has been summarized into the blob at `compact`". When building LLM context:
1. Walk back from head
2. If a node has `compact`, stop walking — use the compact summary + all nodes after it
3. If no compact found, use full chain
This enables long-running threads without unbounded context growth.
### GC
Simple mark-and-sweep:
1. **Roots**: all `head` and `start` hashes from `threads.json` + all `history/*.jsonl` files
2. **Mark**: from each root, recursively mark all reachable hashes via `refs[]` (including content Merkle → artifact refs)
3. **Sweep**: delete unmarked CAS blobs
No per-row format parsing needed. GC only needs to understand `refs[]`.
### refs[] Derivation
`refs[]` is auto-derived from payload at write time via a `collectRefs(payload)` function that extracts all CAS hash strings from named fields (`start`, `content`, `ancestors`, `compact`). Application code never reads `refs[]` — it reads named payload fields. This makes `refs[]` a pure GC optimization with zero semantic coupling.
### Extract Phase
The Extractor is expanded from the current design. Currently it only extracts `meta` from agent output. In the new design it extracts:
| Output | Purpose |
|--------|---------|
| `meta` | Structured metadata (same as before) |
| `contentPayload` | The text payload for the content Merkle node |
| `refs[]` | CAS hashes of artifacts produced by this role step |
The `refs[]` become the content Merkle node's refs, enabling GC to trace all role-produced artifacts.
## What Stays Unchanged
- `.info.jsonl` — debug logging stays as-is (high-frequency append, not suitable for CAS)
- CAS blob storage format (`~/.uncaged/workflow/cas/`)
- Bundle registry (`workflow.yaml`)
## Migration
Breaking change. Old `.data.jsonl` files become incompatible. No backward compat fallback (per project convention).
## Changes by Package
| Package | Changes |
|---------|---------|
| `protocol` | Replace `StartStep`, `RoleStep` types with `StartNode`, `StateNode`. Add `ContentMerkleNode` type. Expand `ExtractResult` to include `refs[]`. |
| `workflow-cas` | Add `findReachableHashes(roots)` for GC mark phase |
| `workflow-execute` | Rewrite engine to write CAS nodes + update `threads.json` instead of appending JSONL. Move completed threads to `history/`. Simplify `gc.ts`. Simplify `fork-thread.ts`. Expand extract phase to produce refs. |
| `workflow-runtime` | `ThreadContext` built by walking chain from head. `start.prompt` resolved from CAS via StartNode.refs[0]. |
| `cli` | `thread list/show/rm` read from `threads.json` + `history/`. SSE watches `threads.json`. |
| `dashboard` | Watch `threads.json` instead of `.data.jsonl` |
| Templates & Agents | Update extract definitions to produce `refs[]`. Update `ctx.start.content` → CAS resolved. |
-197
View File
@@ -1,197 +0,0 @@
# 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. `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` 要不要递归展示整个调用栈,还是只显示直接链接?
@@ -1,224 +0,0 @@
# 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 时,边变绿。
## 组件结构
```
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**
-191
View File
@@ -1,191 +0,0 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `dashboard`
- `agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
File diff suppressed because it is too large Load Diff
@@ -1,387 +0,0 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/util": "workspace:^",
"@uncaged/util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/protocol": "workspace:^",
"@uncaged/util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
-67
View File
@@ -1,67 +0,0 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
5. **Quick Start** — install, build, register workflow, start thread, run step
6. **CLI Reference** — brief command list, detailed usage in cli README
7. **Development** — pnpm install / build / check / test
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — pnpm add (for libs) or "included as binary" (for cli/agents)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli/agent packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from uwf --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For agent packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- pnpm run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
-517
View File
@@ -1,517 +0,0 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `ocas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:status-based map lookup → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: ocas_ref, payload: T, timestamp: number }` 的标准格式。
`ocas_ref` 类型的字符串字段在 ocas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 ocas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # ocas_ref → JSON Schema 节点(ocas 内置)
developer:
description: "Implements code changes"
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # ocas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # ocas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 ocas_ref(指向 ocas 内置 JSON Schema 节点)
- `graph``Record<Role | "$START", Record<Status, Target>>`,每个 Target = `{ role, prompt }`
- Status 来自上一个 role 输出的 `$status` 字段,`$START` 使用 `new`(首次启动)和 `resume`(恢复已完成的 thread)作为 status
- Prompt 模板使用 Mustache 渲染,变量来自 lastOutput
- 不含 agent binding — agent 配置在 `~/.uwf/config.yaml` 中管理
Moderator 的求值逻辑:
```typescript
evaluate(graph, lastRole, lastOutput) { role, prompt }
// 1. status = lastOutput.$status (e.g. "new" for $START first run, "resume" for completed thread resume)
// 2. target = graph[lastRole][status]
// 3. prompt = mustache.render(target.prompt, lastOutput)
```
注:routing 基于 `lastOutput.status` 字段的值,直接在 graph map 中查找对应的 Target。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # ocas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # ocas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # ocas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # ocas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # ocas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 ocas_ref,第一步为 `null`(不指向 StartNode)
- `output` — ocas_ref,指向符合 role meta schema 的 CAS 节点,可用 ocas 校验
- `detail` — ocas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uwf/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKey: "sk-..."
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKey: "sk-ant-..."
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-or-..."
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uwf/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uwf/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@ocas/core`
```
packages/
├── cli/ # @united-workforce/cli — uwf CLI(thread/workflow 命令,含 src/moderator/)
├── util-agent/ # @united-workforce/util-agent — Agent CLI 框架(含 extractor)
├── agent-hermes/ # @united-workforce/agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @united-workforce/agent-cursor — uwf-cursor CLI
└── protocol/ # @united-workforce/protocol — 共享类型定义
```
**外部依赖:**
- `@ocas/core` — CAS 存储、hash、schema 校验
- `@ocas/fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
Moderator 通过 status-based map lookup 进行路由。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 moderator 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // ocas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // ocas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
goal: string;
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // ocas_ref → ocas 内置 JSON Schema 节点
};
type Target = {
role: string; // 目标 role 名 或 "$END"
prompt: string; // Mustache 模板,渲染时注入 lastOutput
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
graph: Record<string, Record<string, Target>>; // Record<Role | "$START", Record<Status, Target>>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // ocas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // ocas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // ocas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 Moderator 求值
Moderator 使用 `evaluate(graph, lastRole, lastOutput)` 进行同步 status-based routing:
```typescript
// graph[lastRole][lastOutput.$status] → Target { role, prompt }
// $START 使用 "new"(首次启动)和 "resume"(恢复已完成 thread)作为 status
// prompt 通过 Mustache 模板渲染,变量来自 lastOutput
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKey: string; // API key stored directly
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uwf/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uwf/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
└── graph: Record<role, Record<status, Target>>
```