Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5643a06a39 |
@@ -9,4 +9,3 @@ bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
## Package map
|
||||
|
||||
@@ -26,13 +26,10 @@ Grouped by responsibility (npm name → folder).
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-office` → `workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
|
||||
| | `@uncaged/workflow-agent-docx-diff` → `workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
|
||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-document` → `workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
|
||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||
|
||||
## Dependency graph (workspace packages)
|
||||
@@ -268,4 +265,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
|
||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||
| **No daemon** | OS handles process lifecycle |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
|
||||
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/workflow-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/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-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/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-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/workflow-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` 须为本机有效绝对路径)
|
||||
@@ -11,8 +11,8 @@
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/json-cas": "^0.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
|
||||
+16
-41
@@ -3,21 +3,19 @@
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadShow,
|
||||
cmdThreadStart,
|
||||
cmdThreadStep,
|
||||
cmdThreadSteps,
|
||||
} from "./commands/thread.js";
|
||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import {
|
||||
cmdCasCat,
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
cmdCasReindex,
|
||||
cmdCasRefs,
|
||||
cmdCasSchemaGet,
|
||||
cmdCasSchemaList,
|
||||
@@ -146,30 +144,6 @@ thread
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("steps")
|
||||
.description("List all steps in a thread")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.action((threadId: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadSteps(storageRoot, threadId);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
thread
|
||||
.command("fork")
|
||||
.description("Fork a thread from a specific step")
|
||||
.argument("<step-hash>", "CAS hash of the StartNode or StepNode to fork from")
|
||||
.action((stepHash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const result = await cmdThreadFork(storageRoot, stepHash);
|
||||
writeOutput(result);
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("setup")
|
||||
.description("Configure provider, model, and agent")
|
||||
@@ -211,13 +185,24 @@ const cas = program.command("cas").description("Content-addressable storage oper
|
||||
|
||||
cas
|
||||
.command("get")
|
||||
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
|
||||
.description("Read a CAS node as JSON")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.option("--timestamp", "Include timestamp in output")
|
||||
.action((hash: string, opts: { timestamp?: boolean }) => {
|
||||
.action((hash: string) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasGet(storageRoot, hash, opts));
|
||||
writeOutput(await cmdCasGet(storageRoot, hash));
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("cat")
|
||||
.description("Output a CAS node (--payload for payload only)")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.option("--payload", "Output only the payload")
|
||||
.action((hash: string, opts: { payload?: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasCat(storageRoot, hash, opts));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,16 +251,6 @@ cas
|
||||
});
|
||||
});
|
||||
|
||||
cas
|
||||
.command("reindex")
|
||||
.description("Rebuild type index from all CAS nodes")
|
||||
.action(() => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
writeOutput(await cmdCasReindex(storageRoot));
|
||||
});
|
||||
});
|
||||
|
||||
const casSchema = cas.command("schema").description("CAS schema operations");
|
||||
|
||||
casSchema
|
||||
|
||||
@@ -28,18 +28,26 @@ function readJsonArg(fileOrInline: string): unknown {
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
opts: { timestamp?: boolean },
|
||||
): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
throw new Error(`Node not found: ${hash}`);
|
||||
}
|
||||
if (opts.timestamp) {
|
||||
return node;
|
||||
return node;
|
||||
}
|
||||
|
||||
export async function cmdCasCat(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
opts: { payload?: boolean },
|
||||
): Promise<unknown> {
|
||||
const store = openStore(storageRoot);
|
||||
const node = store.get(hash);
|
||||
if (node === null) {
|
||||
throw new Error(`Node not found: ${hash}`);
|
||||
}
|
||||
const { timestamp: _, ...rest } = node as Record<string, unknown>;
|
||||
return rest;
|
||||
return opts.payload ? node.payload : node;
|
||||
}
|
||||
|
||||
export async function cmdCasPut(
|
||||
@@ -100,10 +108,10 @@ export async function cmdCasSchemaList(
|
||||
// Include meta-schema itself
|
||||
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
for (const hash of store.list()) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null) {
|
||||
if (node !== null && node.type === metaHash) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const title =
|
||||
(schema.title as string | undefined) ??
|
||||
@@ -115,17 +123,6 @@ export async function cmdCasSchemaList(
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function cmdCasReindex(
|
||||
storageRoot: string,
|
||||
): Promise<{ status: string }> {
|
||||
const indexDir = join(storageRoot, "cas", "_index");
|
||||
const { rmSync } = await import("node:fs");
|
||||
rmSync(indexDir, { recursive: true, force: true });
|
||||
// Re-open store to trigger migration rebuild
|
||||
openStore(storageRoot);
|
||||
return { status: "reindexed" };
|
||||
}
|
||||
|
||||
export async function cmdCasSchemaGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
|
||||
@@ -8,17 +8,13 @@ import type {
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
@@ -441,110 +437,6 @@ export async function cmdThreadStep(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const activeHead = index[threadId];
|
||||
if (activeHead !== undefined) {
|
||||
return activeHead;
|
||||
}
|
||||
const hist = await findThreadInHistory(storageRoot, threadId);
|
||||
if (hist !== null) {
|
||||
return hist.head;
|
||||
}
|
||||
fail(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
export async function cmdThreadSteps(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
): Promise<ThreadStepsOutput> {
|
||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
|
||||
const startNode = uwf.store.get(chain.startHash);
|
||||
if (startNode === null) {
|
||||
fail(`StartNode not found: ${chain.startHash}`);
|
||||
}
|
||||
|
||||
const startEntry: StartEntry = {
|
||||
hash: chain.startHash,
|
||||
workflow: chain.start.workflow,
|
||||
prompt: chain.start.prompt,
|
||||
timestamp: startNode.timestamp,
|
||||
};
|
||||
|
||||
const stepEntries: StepEntry[] = [];
|
||||
|
||||
// Walk again to get hashes for each step
|
||||
let hash: CasRef | null = headHash;
|
||||
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
|
||||
while (hash !== null) {
|
||||
const node = uwf.store.get(hash);
|
||||
if (node === null || node.type !== uwf.schemas.stepNode) {
|
||||
break;
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
hashToNode.set(hash, { payload, timestamp: node.timestamp });
|
||||
hash = payload.prev;
|
||||
}
|
||||
|
||||
// Build chronological list with hashes
|
||||
// Walk from start's next to head
|
||||
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
||||
const ordered: { hash: CasRef; payload: StepNodePayload; timestamp: number }[] = [];
|
||||
while (cur !== null) {
|
||||
const entry = hashToNode.get(cur);
|
||||
if (entry === undefined) break;
|
||||
ordered.push({ hash: cur, ...entry });
|
||||
cur = entry.payload.prev;
|
||||
}
|
||||
ordered.reverse();
|
||||
|
||||
for (const item of ordered) {
|
||||
stepEntries.push({
|
||||
hash: item.hash,
|
||||
role: item.payload.role,
|
||||
output: expandOutput(uwf, item.payload.output),
|
||||
detail: item.payload.detail,
|
||||
agent: item.payload.agent,
|
||||
timestamp: item.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
thread: threadId,
|
||||
workflow: chain.start.workflow,
|
||||
steps: [startEntry, ...stepEntries],
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadFork(
|
||||
storageRoot: string,
|
||||
stepHash: CasRef,
|
||||
): Promise<ThreadForkOutput> {
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const node = uwf.store.get(stepHash);
|
||||
if (node === null) {
|
||||
fail(`CAS node not found: ${stepHash}`);
|
||||
}
|
||||
if (node.type !== uwf.schemas.startNode && node.type !== uwf.schemas.stepNode) {
|
||||
fail(`node ${stepHash} is not a StartNode or StepNode`);
|
||||
}
|
||||
|
||||
const newThreadId = generateUlid(Date.now()) as ThreadId;
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
index[newThreadId] = stepHash;
|
||||
await saveThreadsIndex(storageRoot, index);
|
||||
|
||||
return {
|
||||
thread: newThreadId,
|
||||
forkedFrom: {
|
||||
step: stepHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
|
||||
const index = await loadThreadsIndex(storageRoot);
|
||||
const head = index[threadId];
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
computeDurationMs,
|
||||
extractLastAssistantContent,
|
||||
messageToTurnPayload,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesSessionDetail,
|
||||
} from "../src/session-detail.js";
|
||||
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
|
||||
|
||||
describe("parseSessionIdFromStdout", () => {
|
||||
test("reads session_id from the last non-empty line", () => {
|
||||
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
|
||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
|
||||
});
|
||||
|
||||
test("reads session_id from the first line (quiet mode)", () => {
|
||||
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
|
||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
|
||||
});
|
||||
|
||||
test("returns null when no session_id line present", () => {
|
||||
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageToTurnPayload", () => {
|
||||
test("maps assistant tool_calls to toolCalls", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
|
||||
};
|
||||
const turn = messageToTurnPayload(msg, 0);
|
||||
expect(turn).toEqual({
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
|
||||
reasoning: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("skips user messages", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "user",
|
||||
content: "hi",
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
};
|
||||
expect(messageToTurnPayload(msg, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLastAssistantContent", () => {
|
||||
test("returns the last non-empty assistant content", () => {
|
||||
const messages: HermesSessionMessage[] = [
|
||||
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
|
||||
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
|
||||
];
|
||||
expect(extractLastAssistantContent(messages)).toBe("final answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDurationMs", () => {
|
||||
test("computes elapsed time from session_start", () => {
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640Z");
|
||||
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
|
||||
expect(duration).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeHermesSessionDetail", () => {
|
||||
test("stores hermes-detail root with cas_ref turns walkable", async () => {
|
||||
const session: HermesSessionJson = {
|
||||
session_id: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
session_start: "2026-05-18T13:31:59.028640",
|
||||
messages: [
|
||||
{ role: "user", content: "task", reasoning: null, tool_calls: null },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "thinking",
|
||||
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
|
||||
},
|
||||
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
|
||||
],
|
||||
};
|
||||
|
||||
const store = createMemoryStore();
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640");
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
|
||||
|
||||
expect(output).toBe("done");
|
||||
|
||||
const detailNode = store.get(detailHash);
|
||||
expect(detailNode).not.toBeNull();
|
||||
if (detailNode === null) {
|
||||
return;
|
||||
}
|
||||
expect(validate(store, detailNode)).toBe(true);
|
||||
expect(detailNode.payload).toMatchObject({
|
||||
sessionId: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
duration: 60_000,
|
||||
turnCount: 3,
|
||||
});
|
||||
|
||||
const turnRefs = refs(store, detailNode);
|
||||
expect(turnRefs).toHaveLength(3);
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, detailHash, (hash) => visited.push(hash));
|
||||
expect(visited).toContain(detailHash);
|
||||
for (const turnHash of turnRefs) {
|
||||
expect(visited).toContain(turnHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
|
||||
const HERMES_COMMAND = "hermes";
|
||||
const HERMES_MAX_TURNS = 90;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
function buildHistorySummary(history: AgentContext["history"]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const step = history[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -33,10 +26,8 @@ function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const systemPrompt = roleDef?.systemPrompt ?? "";
|
||||
const parts: string[] = [systemPrompt, "", "## Task", ctx.start.prompt];
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
const parts: string[] = [ctx.systemPrompt, "", "## Task", ctx.prompt];
|
||||
const historyBlock = buildHistorySummary(ctx.history);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
@@ -85,22 +76,9 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
async function runHermes(ctx: AgentContext): Promise<string> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const rawOutput = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
|
||||
const sessionId = parseSessionIdFromStdout(rawOutput);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, rawOutput);
|
||||
return { output: rawOutput, detailHash };
|
||||
return spawnHermesChat(fullPrompt);
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "args"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
args: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_TURN_SCHEMA: JSONSchema = {
|
||||
title: "hermes-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
|
||||
},
|
||||
reasoning: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
|
||||
title: "hermes-detail",
|
||||
type: "object",
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" },
|
||||
model: { type: "string" },
|
||||
duration: { type: "integer" },
|
||||
turnCount: { type: "integer" },
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Fallback detail when Hermes session file is unavailable. */
|
||||
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
|
||||
title: "hermes-raw-output",
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,223 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
HermesDetailPayload,
|
||||
HermesSessionJson,
|
||||
HermesSessionMessage,
|
||||
HermesToolCall,
|
||||
HermesTurnPayload,
|
||||
HermesTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
|
||||
|
||||
export function getHermesSessionsDir(): string {
|
||||
return join(homedir(), ".hermes", "sessions");
|
||||
}
|
||||
|
||||
export function getHermesSessionPath(sessionId: string): string {
|
||||
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
|
||||
}
|
||||
|
||||
/** Parse `session_id: …` from any line of Hermes stdout. */
|
||||
export function parseSessionIdFromStdout(stdout: string): string | null {
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const match = SESSION_ID_LINE.exec(line.trim());
|
||||
if (match?.[1] !== undefined) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
|
||||
for (const entry of raw) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const fn = entry.function;
|
||||
if (!isRecord(fn)) {
|
||||
continue;
|
||||
}
|
||||
const name = fn.name;
|
||||
const args = fn.arguments;
|
||||
if (typeof name !== "string" || typeof args !== "string") {
|
||||
continue;
|
||||
}
|
||||
calls.push({ function: { name, arguments: args } });
|
||||
}
|
||||
return calls.length > 0 ? calls : null;
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const role = raw.role;
|
||||
if (role !== "assistant" && role !== "tool" && role !== "user") {
|
||||
return null;
|
||||
}
|
||||
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
|
||||
const reasoning =
|
||||
typeof raw.reasoning === "string"
|
||||
? raw.reasoning
|
||||
: raw.reasoning === null || raw.reasoning === undefined
|
||||
? null
|
||||
: null;
|
||||
const tool_calls = parseToolCalls(raw.tool_calls);
|
||||
return { role, content, reasoning, tool_calls };
|
||||
}
|
||||
|
||||
function parseSessionJson(raw: unknown): HermesSessionJson | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const session_id = raw.session_id;
|
||||
const model = raw.model;
|
||||
const session_start = raw.session_start;
|
||||
const messagesRaw = raw.messages;
|
||||
if (
|
||||
typeof session_id !== "string" ||
|
||||
typeof model !== "string" ||
|
||||
typeof session_start !== "string" ||
|
||||
!Array.isArray(messagesRaw)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const messages: HermesSessionMessage[] = [];
|
||||
for (const entry of messagesRaw) {
|
||||
const msg = normalizeMessage(entry);
|
||||
if (msg !== null) {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
return { session_id, model, session_start, messages };
|
||||
}
|
||||
|
||||
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
|
||||
const path = getHermesSessionPath(sessionId);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
return parseSessionJson(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
|
||||
const startMs = Date.parse(sessionStart);
|
||||
if (Number.isNaN(startMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, nowMs - startMs);
|
||||
}
|
||||
|
||||
function mapSessionToolCalls(
|
||||
toolCalls: HermesSessionMessage["tool_calls"],
|
||||
): HermesToolCall[] | null {
|
||||
if (toolCalls === null || toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return toolCalls.map((call) => ({
|
||||
name: call.function.name,
|
||||
args: call.function.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function messageToTurnPayload(
|
||||
message: HermesSessionMessage,
|
||||
index: number,
|
||||
): HermesTurnPayload | null {
|
||||
if (message.role !== "assistant" && message.role !== "tool") {
|
||||
return null;
|
||||
}
|
||||
const role = message.role as HermesTurnRole;
|
||||
return {
|
||||
index,
|
||||
role,
|
||||
content: message.content ?? "",
|
||||
toolCalls: mapSessionToolCalls(message.tool_calls),
|
||||
reasoning: message.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text content (walks backward). */
|
||||
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type HermesSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
rawOutput: string;
|
||||
};
|
||||
|
||||
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail, rawOutput] = await Promise.all([
|
||||
putSchema(store, HERMES_TURN_SCHEMA),
|
||||
putSchema(store, HERMES_DETAIL_SCHEMA),
|
||||
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
|
||||
]);
|
||||
return { turn, detail, rawOutput };
|
||||
}
|
||||
|
||||
export async function storeHermesSessionDetail(
|
||||
store: Store,
|
||||
session: HermesSessionJson,
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const message of session.messages) {
|
||||
const turn = messageToTurnPayload(message, turnIndex);
|
||||
if (turn === null) {
|
||||
continue;
|
||||
}
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
|
||||
const detail: HermesDetailPayload = {
|
||||
sessionId: session.session_id,
|
||||
model: session.model,
|
||||
duration: computeDurationMs(session.session_start, nowMs),
|
||||
turnCount: turnHashes.length,
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractLastAssistantContent(session.messages);
|
||||
return { detailHash, output };
|
||||
}
|
||||
|
||||
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
return store.put(schemas.rawOutput, { text: rawOutput });
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
export type HermesTurnRole = "assistant" | "tool";
|
||||
|
||||
export type HermesToolCall = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export type HermesTurnPayload = {
|
||||
index: number;
|
||||
role: HermesTurnRole;
|
||||
content: string;
|
||||
toolCalls: HermesToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesDetailPayload = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
duration: number;
|
||||
turnCount: number;
|
||||
turns: string[];
|
||||
};
|
||||
|
||||
export type HermesSessionToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HermesSessionMessage = {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls: HermesSessionToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesSessionJson = {
|
||||
session_id: string;
|
||||
model: string;
|
||||
session_start: string;
|
||||
messages: HermesSessionMessage[];
|
||||
};
|
||||
@@ -18,8 +18,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/json-cas": "^0.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartNodePayload,
|
||||
@@ -7,7 +6,6 @@ import type {
|
||||
ThreadId,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import type { AgentContext } from "./types.js";
|
||||
|
||||
type ChainState = {
|
||||
@@ -22,8 +20,8 @@ function fail(message: string): never {
|
||||
}
|
||||
|
||||
function walkChain(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||
headHash: CasRef,
|
||||
): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
@@ -79,7 +77,7 @@ function walkChain(
|
||||
}
|
||||
|
||||
function expandOutput(
|
||||
store: Store,
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
outputRef: CasRef,
|
||||
): unknown {
|
||||
const node = store.get(outputRef);
|
||||
@@ -90,7 +88,7 @@ function expandOutput(
|
||||
}
|
||||
|
||||
async function buildHistory(
|
||||
store: Store,
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
stepsNewestFirst: StepNodePayload[],
|
||||
): Promise<StepContext[]> {
|
||||
const chronological = [...stepsNewestFirst].reverse();
|
||||
@@ -107,8 +105,8 @@ async function buildHistory(
|
||||
}
|
||||
|
||||
async function loadWorkflow(
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||
workflowRef: CasRef,
|
||||
) {
|
||||
const node = store.get(workflowRef);
|
||||
@@ -143,22 +141,22 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
systemPrompt: roleDef.systemPrompt,
|
||||
prompt: chain.start.prompt,
|
||||
history,
|
||||
workflow,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
@@ -187,15 +185,15 @@ export async function buildContextWithMeta(
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
start: chain.start,
|
||||
steps,
|
||||
systemPrompt: roleDef.systemPrompt,
|
||||
prompt: chain.start.prompt,
|
||||
history,
|
||||
workflow,
|
||||
store,
|
||||
meta: { storageRoot, store, schemas, headHash, chain },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { BuildContextMeta } from "./context.js";
|
||||
export { buildContext, buildContextWithMeta } from "./context.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
|
||||
export {
|
||||
extract,
|
||||
@@ -7,5 +8,4 @@ export {
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildContextWithMeta } from "./context.js";
|
||||
import { extract } from "./extract.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
import type { AgentContext, AgentOptions } from "./types.js";
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`${message}\n`);
|
||||
@@ -65,7 +65,7 @@ async function writeStepNode(options: {
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<string> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
@@ -85,11 +85,12 @@ async function extractOutput(
|
||||
|
||||
async function persistStep(options: {
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
|
||||
rawOutput: string;
|
||||
outputHash: CasRef;
|
||||
detailHash: CasRef;
|
||||
agentName: string;
|
||||
}): Promise<CasRef> {
|
||||
const { store, schemas, chain, headHash } = options.ctx.meta;
|
||||
const detailHash = await store.put(null, options.rawOutput);
|
||||
return writeStepNode({
|
||||
store,
|
||||
schemas,
|
||||
@@ -97,7 +98,7 @@ async function persistStep(options: {
|
||||
prevHash: chain.headIsStart ? null : headHash,
|
||||
role: options.ctx.role,
|
||||
outputHash: options.outputHash,
|
||||
detailHash: options.detailHash,
|
||||
detailHash,
|
||||
agentName: options.agentName,
|
||||
});
|
||||
}
|
||||
@@ -120,12 +121,12 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
|
||||
const rawOutput = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
rawOutput,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Hash, Store } from "@uncaged/json-cas";
|
||||
import { putSchema } from "@uncaged/json-cas";
|
||||
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
|
||||
import {
|
||||
START_NODE_SCHEMA,
|
||||
STEP_NODE_SCHEMA,
|
||||
WORKFLOW_SCHEMA,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
|
||||
export type UwfAgentSchemaHashes = {
|
||||
workflow: Hash;
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
export type AgentContext = ModeratorContext & {
|
||||
export type AgentContext = {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
store: Store;
|
||||
systemPrompt: string;
|
||||
prompt: string;
|
||||
history: StepContext[];
|
||||
workflow: WorkflowPayload;
|
||||
};
|
||||
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
};
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas-fs": "^0.3.0"
|
||||
"@uncaged/json-cas-fs": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -16,18 +16,14 @@ export type {
|
||||
RoleDefinition,
|
||||
RoleName,
|
||||
Scenario,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
|
||||
@@ -80,39 +80,6 @@ export type StepOutput = {
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
/** uwf thread steps — single step entry */
|
||||
export type StepEntry = {
|
||||
hash: CasRef;
|
||||
role: string;
|
||||
output: unknown;
|
||||
detail: CasRef;
|
||||
agent: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** uwf thread steps — start entry */
|
||||
export type StartEntry = {
|
||||
hash: CasRef;
|
||||
workflow: CasRef;
|
||||
prompt: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** uwf thread steps output */
|
||||
export type ThreadStepsOutput = {
|
||||
thread: ThreadId;
|
||||
workflow: CasRef;
|
||||
steps: [StartEntry, ...StepEntry[]];
|
||||
};
|
||||
|
||||
/** uwf thread fork output */
|
||||
export type ThreadForkOutput = {
|
||||
thread: ThreadId;
|
||||
forkedFrom: {
|
||||
step: CasRef;
|
||||
};
|
||||
};
|
||||
|
||||
/** uwf thread list */
|
||||
export type ThreadListItem = {
|
||||
thread: ThreadId;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createDocxDiffAgent } from "../src/agent.js";
|
||||
|
||||
describe("createDocxDiffAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
const agent = createDocxDiffAgent({ command: null });
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("AdapterFn returns a RoleFn (function)", () => {
|
||||
const agent = createDocxDiffAgent({ command: null });
|
||||
const roleFn = agent("", expect.anything() as never);
|
||||
expect(typeof roleFn).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has correct name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
||||
import { runDocxDiff } from "../src/runner.js";
|
||||
|
||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
||||
|
||||
function makeSpawn(result: MockSpawnResult) {
|
||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
||||
}
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("runDocxDiff", () => {
|
||||
test("exit 0: success, returns DifferMeta JSON", async () => {
|
||||
const dir = tempDir();
|
||||
const sourceDocx = join(dir, "original.docx");
|
||||
const modifiedDocx = join(dir, "modified.docx");
|
||||
const diffDocx = join(dir, "diff.docx");
|
||||
writeFileSync(sourceDocx, "");
|
||||
writeFileSync(modifiedDocx, "");
|
||||
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// simulate docx-diff creating the diff file
|
||||
writeFileSync(diffDocx, "");
|
||||
|
||||
const raw = await runDocxDiff(
|
||||
{ command: "docx-diff" },
|
||||
sourceDocx,
|
||||
modifiedDocx,
|
||||
diffDocx,
|
||||
spawnFn,
|
||||
);
|
||||
const meta = JSON.parse(raw);
|
||||
expect(meta.sourceDocx).toBe(sourceDocx);
|
||||
expect(meta.modifiedDocx).toBe(modifiedDocx);
|
||||
expect(meta.diffDocx).toBe(diffDocx);
|
||||
|
||||
expect(spawnFn.mock.calls[0][1]).toEqual([
|
||||
sourceDocx,
|
||||
modifiedDocx,
|
||||
"--output",
|
||||
"docx",
|
||||
"--out-file",
|
||||
diffDocx,
|
||||
]);
|
||||
});
|
||||
|
||||
test("exit 1 (changes found): treated as success", async () => {
|
||||
const dir = tempDir();
|
||||
const sourceDocx = join(dir, "s.docx");
|
||||
const modifiedDocx = join(dir, "m.docx");
|
||||
const diffDocx = join(dir, "diff.docx");
|
||||
writeFileSync(sourceDocx, "");
|
||||
writeFileSync(modifiedDocx, "");
|
||||
writeFileSync(diffDocx, "");
|
||||
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("exit 2: throws error", async () => {
|
||||
const dir = tempDir();
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
||||
).rejects.toThrow("docx-diff failed");
|
||||
});
|
||||
|
||||
test("timeout: throws error", async () => {
|
||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
||||
|
||||
await expect(
|
||||
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
|
||||
).rejects.toThrow("timed out");
|
||||
});
|
||||
|
||||
test("throws when diff file not created", async () => {
|
||||
const dir = tempDir();
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// do NOT create diffDocx
|
||||
|
||||
await expect(
|
||||
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
|
||||
).rejects.toThrow("diff file not found");
|
||||
});
|
||||
|
||||
test("uses PATH docx-diff when command is null", async () => {
|
||||
const dir = tempDir();
|
||||
const diffDocx = join(dir, "diff.docx");
|
||||
writeFileSync(diffDocx, "");
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
|
||||
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
|
||||
|
||||
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-docx-diff",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"@uncaged/workflow-template-document": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-util": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type { WriterMeta } from "@uncaged/workflow-template-document";
|
||||
import { runDocxDiff } from "./runner.js";
|
||||
import type { DocxDiffAgentConfig } from "./types.js";
|
||||
|
||||
export function createDocxDiffAgent(config: DocxDiffAgentConfig): 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 === undefined) 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 diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
|
||||
const raw = await runDocxDiff(
|
||||
config,
|
||||
writerMeta.sourceDocx,
|
||||
writerMeta.outputDocx,
|
||||
diffDocx,
|
||||
);
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createDocxDiffAgent } from "./agent.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { DocxDiffAgentConfig } from "./types.js";
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
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 binary; null uses PATH.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import type { DocxDiffAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
|
||||
function throwSpawnError(e: SpawnCliError): never {
|
||||
if (e.kind === "non_zero_exit")
|
||||
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
|
||||
if (e.kind === "timeout")
|
||||
throw new Error("docx-diff: timed out");
|
||||
throw new Error(`docx-diff: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
export async function runDocxDiff(
|
||||
config: DocxDiffAgentConfig,
|
||||
sourceDocx: string,
|
||||
modifiedDocx: string,
|
||||
diffDocx: string,
|
||||
spawnCliFn: SpawnCliFn = spawnCli,
|
||||
): Promise<string> {
|
||||
const command = config.command ?? "docx-diff";
|
||||
const result = await spawnCliFn(
|
||||
command,
|
||||
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
|
||||
{ cwd: null, timeoutMs: null },
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
const e = result.error;
|
||||
// exit 1 = changes found (normal for docx-diff)
|
||||
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
|
||||
// fall through to file check
|
||||
} else {
|
||||
throwSpawnError(e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(diffDocx);
|
||||
} catch {
|
||||
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
|
||||
}
|
||||
|
||||
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export type DocxDiffAgentConfig = {
|
||||
command: string | null;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util-agent" },
|
||||
{ "path": "../workflow-template-document" }
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { packageDescriptor } from "../src/package-descriptor.js";
|
||||
import { createOfficeAgent } from "../src/agent.js";
|
||||
|
||||
describe("createOfficeAgent", () => {
|
||||
test("returns an AdapterFn (function)", () => {
|
||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("AdapterFn returns a RoleFn (function)", () => {
|
||||
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
|
||||
const roleFn = agent("", expect.anything() as never);
|
||||
expect(typeof roleFn).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("packageDescriptor", () => {
|
||||
test("has correct name", () => {
|
||||
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
|
||||
});
|
||||
|
||||
test("has outputDir in configSchema required", () => {
|
||||
const schema = packageDescriptor.configSchema as { required: string[] };
|
||||
expect(schema.required).toContain("outputDir");
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { ok, err } from "@uncaged/workflow-util";
|
||||
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
|
||||
import { editDocument, generateDocument } from "../src/runner.js";
|
||||
|
||||
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
|
||||
|
||||
function makeSpawn(result: MockSpawnResult) {
|
||||
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
|
||||
}
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = join(tmpdir(), `office-test-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("generateDocument", () => {
|
||||
test("calls office-agent create with correct args and returns outputDocx path", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
|
||||
// Simulate CLI creating the file
|
||||
const outFile = join(base, "thread1", "output.docx");
|
||||
mkdirSync(join(base, "thread1"), { recursive: true });
|
||||
writeFileSync(outFile, "");
|
||||
|
||||
const result = await generateDocument(
|
||||
{ outputDir: base, command: "office-agent", timeout: null },
|
||||
"thread1",
|
||||
"Write a report",
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(result.outputDocx).toBe(outFile);
|
||||
expect(result.sourceDocx).toBeNull();
|
||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
||||
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
|
||||
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
|
||||
});
|
||||
|
||||
test("uses PATH office-agent when command is null", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
mkdirSync(join(base, "t2"), { recursive: true });
|
||||
writeFileSync(join(base, "t2", "output.docx"), "");
|
||||
|
||||
await generateDocument(
|
||||
{ outputDir: base, command: null, timeout: null },
|
||||
"t2",
|
||||
"Generate",
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
|
||||
});
|
||||
|
||||
test("throws on non_zero_exit", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
|
||||
).rejects.toThrow("office-agent failed (exit 1)");
|
||||
});
|
||||
|
||||
test("throws on timeout", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
|
||||
).rejects.toThrow("office-agent: timed out");
|
||||
});
|
||||
|
||||
test("throws when output file not created", async () => {
|
||||
const base = tempDir();
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// Do NOT create output.docx
|
||||
|
||||
await expect(
|
||||
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
|
||||
).rejects.toThrow("output file not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editDocument", () => {
|
||||
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
|
||||
const base = tempDir();
|
||||
// Create a fake inputDocx
|
||||
const inputFile = join(base, "source.docx");
|
||||
writeFileSync(inputFile, "original content");
|
||||
|
||||
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
|
||||
// Simulate CLI overwriting modified.docx
|
||||
const outDir = join(base, "te1");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(join(outDir, "modified.docx"), "modified content");
|
||||
|
||||
const result = await editDocument(
|
||||
{ outputDir: base, command: "office-agent", timeout: null },
|
||||
"te1",
|
||||
"Edit the doc",
|
||||
inputFile,
|
||||
spawnFn,
|
||||
);
|
||||
|
||||
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
|
||||
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
|
||||
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
|
||||
});
|
||||
|
||||
test("throws on spawn_failed", async () => {
|
||||
const base = tempDir();
|
||||
const inputFile = join(base, "src.docx");
|
||||
writeFileSync(inputFile, "");
|
||||
const spawnFn = makeSpawn(
|
||||
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
|
||||
);
|
||||
|
||||
await expect(
|
||||
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
|
||||
).rejects.toThrow("spawn failed");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-office",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { editDocument, generateDocument } from "./runner.js";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
type ParsedInput = { prompt: string; inputDocx: string | null };
|
||||
|
||||
function parseStartInput(content: string): ParsedInput {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
if (typeof parsed.prompt === "string") {
|
||||
return {
|
||||
prompt: parsed.prompt,
|
||||
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not JSON — treat whole content as prompt, generate mode
|
||||
}
|
||||
return { prompt: content, inputDocx: null };
|
||||
}
|
||||
|
||||
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);
|
||||
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
|
||||
|
||||
let raw: string;
|
||||
if (inputDocx === null) {
|
||||
const result = await generateDocument(config, ctx.threadId, prompt);
|
||||
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
|
||||
} else {
|
||||
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
|
||||
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
|
||||
}
|
||||
|
||||
const meta = schema.parse(JSON.parse(raw)) as T;
|
||||
return { meta, childThread: null };
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createOfficeAgent } from "./agent.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { OfficeAgentConfig } from "./types.js";
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
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; subdirs are created per threadId.",
|
||||
},
|
||||
command: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
description: "Path to office-agent CLI binary; null uses PATH.",
|
||||
},
|
||||
timeout: {
|
||||
anyOf: [{ type: "number" }, { type: "null" }],
|
||||
description: "Timeout in milliseconds; null means no limit.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
|
||||
import type { OfficeAgentConfig } from "./types.js";
|
||||
|
||||
type SpawnCliFn = typeof spawnCli;
|
||||
|
||||
function throwSpawnError(e: SpawnCliError): never {
|
||||
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");
|
||||
throw new Error(`office-agent: spawn failed: ${e.message}`);
|
||||
}
|
||||
|
||||
async function assertFileExists(path: string): Promise<void> {
|
||||
try {
|
||||
await stat(path);
|
||||
} catch {
|
||||
throw new Error(`office-agent: output file not found: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateDocument(
|
||||
config: OfficeAgentConfig,
|
||||
threadId: string,
|
||||
prompt: string,
|
||||
spawnCliFn: SpawnCliFn = spawnCli,
|
||||
): Promise<{ outputDocx: string; sourceDocx: null }> {
|
||||
const outputDir = join(config.outputDir, threadId);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const command = config.command ?? "office-agent";
|
||||
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
|
||||
cwd: outputDir,
|
||||
timeoutMs: config.timeout,
|
||||
});
|
||||
if (!result.ok) throwSpawnError(result.error);
|
||||
const outputDocx = join(outputDir, "output.docx");
|
||||
await assertFileExists(outputDocx);
|
||||
return { outputDocx, sourceDocx: null };
|
||||
}
|
||||
|
||||
export async function editDocument(
|
||||
config: OfficeAgentConfig,
|
||||
threadId: string,
|
||||
prompt: string,
|
||||
inputDocx: string,
|
||||
spawnCliFn: SpawnCliFn = spawnCli,
|
||||
): Promise<{ outputDocx: string; sourceDocx: string }> {
|
||||
const outputDir = join(config.outputDir, threadId);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const originalDocx = join(outputDir, "original.docx");
|
||||
const modifiedDocx = join(outputDir, "modified.docx");
|
||||
await copyFile(inputDocx, originalDocx);
|
||||
await copyFile(inputDocx, modifiedDocx);
|
||||
const command = config.command ?? "office-agent";
|
||||
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
|
||||
cwd: outputDir,
|
||||
timeoutMs: config.timeout,
|
||||
});
|
||||
if (!result.ok) throwSpawnError(result.error);
|
||||
await assertFileExists(modifiedDocx);
|
||||
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type OfficeAgentConfig = {
|
||||
outputDir: string;
|
||||
command: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
|
||||
import { buildDocumentDescriptor } from "../src/descriptor.js";
|
||||
import { documentTable } from "../src/moderator.js";
|
||||
import type { DifferMeta, WriterMeta } from "../src/roles/index.js";
|
||||
import type { DocumentMeta } from "../src/roles.js";
|
||||
|
||||
const documentModerator = tableToModerator(documentTable);
|
||||
|
||||
function makeCtx(
|
||||
steps: ModeratorContext<DocumentMeta>["steps"],
|
||||
): ModeratorContext<DocumentMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function writerGenerateStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER001",
|
||||
meta: { mode: "generate", outputDocx: "/out/output.docx", sourceDocx: null } satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function writerEditStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "writer",
|
||||
contentHash: "STUBHASHWRITER002",
|
||||
meta: { mode: "edit", outputDocx: "/out/modified.docx", sourceDocx: "/out/original.docx" } satisfies WriterMeta,
|
||||
refs: [],
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function differStep(): RoleStep<DocumentMeta> {
|
||||
return {
|
||||
role: "differ",
|
||||
contentHash: "STUBHASHDIFF001",
|
||||
meta: {
|
||||
sourceDocx: "/out/original.docx",
|
||||
modifiedDocx: "/out/modified.docx",
|
||||
diffDocx: "/out/diff.docx",
|
||||
} satisfies DifferMeta,
|
||||
refs: [],
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("documentTable", () => {
|
||||
test("START → writer", () => {
|
||||
expect(documentModerator(makeCtx([]))).toBe("writer");
|
||||
});
|
||||
|
||||
test("writer (generate) → END", () => {
|
||||
expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END);
|
||||
});
|
||||
|
||||
test("writer (edit) → differ", () => {
|
||||
expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ");
|
||||
});
|
||||
|
||||
test("differ → END", () => {
|
||||
expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocumentDescriptor", () => {
|
||||
test("descriptor passes validation", () => {
|
||||
const descriptor = buildDocumentDescriptor();
|
||||
expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow();
|
||||
});
|
||||
|
||||
test("descriptor has writer and differ roles", () => {
|
||||
const descriptor = buildDocumentDescriptor();
|
||||
expect(Object.keys(descriptor.roles)).toContain("writer");
|
||||
expect(Object.keys(descriptor.roles)).toContain("differ");
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-document",
|
||||
"version": "0.1.0",
|
||||
"files": ["src", "dist", "package.json"],
|
||||
"type": "module",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
import { documentTable } from "./moderator.js";
|
||||
import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js";
|
||||
|
||||
export function buildDocumentDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: DOCUMENT_WORKFLOW_DESCRIPTION,
|
||||
roles: documentRoles,
|
||||
table: documentTable,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
import { documentTable } from "./moderator.js";
|
||||
import { DOCUMENT_WORKFLOW_DESCRIPTION, type DocumentMeta, documentRoles } from "./roles.js";
|
||||
|
||||
export { buildDocumentDescriptor } from "./descriptor.js";
|
||||
export { documentTable } from "./moderator.js";
|
||||
export {
|
||||
type DifferMeta,
|
||||
differMetaSchema,
|
||||
differRole,
|
||||
type WriterMeta,
|
||||
writerMetaSchema,
|
||||
writerRole,
|
||||
} from "./roles/index.js";
|
||||
export {
|
||||
DOCUMENT_WORKFLOW_DESCRIPTION,
|
||||
type DocumentMeta,
|
||||
type DocumentRoles,
|
||||
documentRoles,
|
||||
} from "./roles.js";
|
||||
export type { DocumentStartInput } from "./types.js";
|
||||
|
||||
export const documentWorkflowDefinition: WorkflowDefinition<DocumentMeta> = {
|
||||
description: DOCUMENT_WORKFLOW_DESCRIPTION,
|
||||
roles: documentRoles,
|
||||
table: documentTable,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
END,
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type { WriterMeta } from "./roles/writer.js";
|
||||
import type { DocumentMeta } from "./roles.js";
|
||||
|
||||
const writerIsEditMode: ModeratorCondition<DocumentMeta> = {
|
||||
name: "writerIsEditMode",
|
||||
description: "Writer ran in edit mode and produced a modified document",
|
||||
check: (ctx) => {
|
||||
const writerStep = ctx.steps.find((s) => s.role === "writer");
|
||||
if (writerStep === undefined) return false;
|
||||
return (writerStep.meta as WriterMeta).mode === "edit";
|
||||
},
|
||||
};
|
||||
|
||||
export const documentTable: ModeratorTable<DocumentMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "writer" }],
|
||||
writer: [
|
||||
{ condition: writerIsEditMode, role: "differ" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
],
|
||||
differ: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import { type DifferMeta, differRole } from "./roles/differ.js";
|
||||
import { type WriterMeta, writerRole } from "./roles/writer.js";
|
||||
|
||||
export const DOCUMENT_WORKFLOW_DESCRIPTION =
|
||||
"Generates a new Word document from a prompt, or edits an existing one and produces a diff report.";
|
||||
|
||||
export type DocumentMeta = {
|
||||
writer: WriterMeta;
|
||||
differ: DifferMeta;
|
||||
};
|
||||
|
||||
export type DocumentRoles = {
|
||||
[K in keyof DocumentMeta]: RoleDefinition<DocumentMeta[K]>;
|
||||
};
|
||||
|
||||
export const documentRoles: DocumentRoles = {
|
||||
writer: writerRole,
|
||||
differ: differRole,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const differMetaSchema = z.object({
|
||||
sourceDocx: z.string(),
|
||||
modifiedDocx: z.string(),
|
||||
diffDocx: z.string(),
|
||||
});
|
||||
|
||||
export type DifferMeta = z.infer<typeof differMetaSchema>;
|
||||
|
||||
export const differRole: RoleDefinition<DifferMeta> = {
|
||||
description: "Produces a Word-format diff report of the writer's changes (edit mode only).",
|
||||
systemPrompt: "",
|
||||
schema: differMetaSchema,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type { DifferMeta } from "./differ.js";
|
||||
export { differMetaSchema, differRole } from "./differ.js";
|
||||
export type { WriterMeta } from "./writer.js";
|
||||
export { writerMetaSchema, writerRole } from "./writer.js";
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export 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(),
|
||||
sourceDocx: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type WriterMeta = z.infer<typeof writerMetaSchema>;
|
||||
|
||||
export const writerRole: RoleDefinition<WriterMeta> = {
|
||||
description: "Generates or modifies a Word document via an external agent.",
|
||||
systemPrompt: "",
|
||||
schema: writerMetaSchema,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type DocumentStartInput = {
|
||||
prompt: string;
|
||||
inputDocx: string | null;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-register" }
|
||||
]
|
||||
}
|
||||
+1
-17
@@ -1,27 +1,11 @@
|
||||
#!/usr/bin/env bun
|
||||
// Mock agent for smoke testing
|
||||
import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas";
|
||||
import { createAgent } from "../packages/uwf-agent-kit/src/index.js";
|
||||
|
||||
const MOCK_RAW_OUTPUT_SCHEMA: JSONSchema = {
|
||||
title: "mock-raw-output",
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const agent = createAgent({
|
||||
name: "mock",
|
||||
run: async (ctx) => {
|
||||
const output = `Mock output for role ${ctx.role}: task was "${ctx.start.prompt}"`;
|
||||
const { store } = ctx;
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, MOCK_RAW_OUTPUT_SCHEMA);
|
||||
const detailHash = await store.put(schemaHash, { text: output });
|
||||
return { output, detailHash };
|
||||
return `Mock output for role ${ctx.role}: task was "${ctx.prompt}"`;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+1
-4
@@ -37,9 +37,6 @@
|
||||
{ "path": "packages/uwf-moderator" },
|
||||
{ "path": "packages/cli-uwf" },
|
||||
{ "path": "packages/uwf-agent-kit" },
|
||||
{ "path": "packages/uwf-agent-hermes" },
|
||||
{ "path": "packages/workflow-template-document" },
|
||||
{ "path": "packages/workflow-agent-office" },
|
||||
{ "path": "packages/workflow-agent-docx-diff" }
|
||||
{ "path": "packages/uwf-agent-hermes" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user