Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f6859678c | |||
| 84798510b0 | |||
| 6eace09826 | |||
| cb39a6693a | |||
| 86dd37b0c8 | |||
| ec0bc672f6 | |||
| f08ba6914c | |||
| f6dd4d59a1 | |||
| d8cdc8ab88 | |||
| 20ddc5d7aa | |||
| 2846311f8d | |||
| ed0043b8ac | |||
| bee3911f3f | |||
| 4285b8b180 | |||
| f0b7be79fb | |||
| d4f05adeba |
@@ -9,3 +9,4 @@ 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 **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
## Package map
|
||||
|
||||
@@ -26,10 +26,13 @@ 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)
|
||||
@@ -265,4 +268,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 |
|
||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
| **21-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
@@ -0,0 +1,387 @@
|
||||
# 设计文档: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` 须为本机有效绝对路径)
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
|
||||
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
|
||||
import {
|
||||
cmdCasCat,
|
||||
cmdCasGet,
|
||||
cmdCasHas,
|
||||
cmdCasPut,
|
||||
@@ -185,24 +184,13 @@ const cas = program.command("cas").description("Content-addressable storage oper
|
||||
|
||||
cas
|
||||
.command("get")
|
||||
.description("Read a CAS node as JSON")
|
||||
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
|
||||
.argument("<hash>", "CAS hash (13 char)")
|
||||
.action((hash: string) => {
|
||||
.option("--timestamp", "Include timestamp in output")
|
||||
.action((hash: string, opts: { timestamp?: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
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));
|
||||
writeOutput(await cmdCasGet(storageRoot, hash, opts));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,26 +28,18 @@ 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}`);
|
||||
}
|
||||
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}`);
|
||||
if (opts.timestamp) {
|
||||
return node;
|
||||
}
|
||||
return opts.payload ? node.payload : node;
|
||||
const { timestamp: _, ...rest } = node as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function cmdCasPut(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createDocxDiffAgent } from "./agent.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { DocxDiffAgentConfig } from "./types.js";
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type DocxDiffAgentConfig = {
|
||||
command: string | null;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createOfficeAgent } from "./agent.js";
|
||||
export { packageDescriptor } from "./package-descriptor.js";
|
||||
export type { OfficeAgentConfig } from "./types.js";
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type OfficeAgentConfig = {
|
||||
outputDir: string;
|
||||
command: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
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 }],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { DifferMeta } from "./differ.js";
|
||||
export { differMetaSchema, differRole } from "./differ.js";
|
||||
export type { WriterMeta } from "./writer.js";
|
||||
export { writerMetaSchema, writerRole } from "./writer.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type DocumentStartInput = {
|
||||
prompt: string;
|
||||
inputDocx: string | null;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-register" }
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -37,6 +37,9 @@
|
||||
{ "path": "packages/uwf-moderator" },
|
||||
{ "path": "packages/cli-uwf" },
|
||||
{ "path": "packages/uwf-agent-kit" },
|
||||
{ "path": "packages/uwf-agent-hermes" }
|
||||
{ "path": "packages/uwf-agent-hermes" },
|
||||
{ "path": "packages/workflow-template-document" },
|
||||
{ "path": "packages/workflow-agent-office" },
|
||||
{ "path": "packages/workflow-agent-docx-diff" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user