Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dacebe1841 | |||
| c42125946d | |||
| 4c9ce72395 | |||
| 8b43f7993b | |||
| cf9e2cd3d6 | |||
| 7a99c1a9d6 | |||
| 546237db85 | |||
| 1ed7e32067 | |||
| bd5e5a435b | |||
| 67e689ff1a | |||
| 06eb2dff3b | |||
| a2bd3126c8 | |||
| 710d42d6b9 | |||
| 072d900fcb | |||
| cfebd07124 | |||
| f2be6fc057 | |||
| d392563549 | |||
| 2af8196451 | |||
| ad74768630 | |||
| a38ca7e8db | |||
| 3d97968887 | |||
| ade6227ffe | |||
| 13789e2c66 | |||
| 6758adc1d5 | |||
| 7c12015855 | |||
| 0f6859678c | |||
| 84798510b0 | |||
| 6eace09826 | |||
| cb39a6693a | |||
| 36d120b745 | |||
| 86dd37b0c8 | |||
| bb0f2ca678 | |||
| ec0bc672f6 | |||
| f08ba6914c | |||
| f6dd4d59a1 | |||
| d8cdc8ab88 | |||
| 20ddc5d7aa | |||
| 2846311f8d | |||
| ed0043b8ac | |||
| bee3911f3f | |||
| 4285b8b180 | |||
| f0b7be79fb | |||
| d4f05adeba |
@@ -9,3 +9,5 @@ bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
.DS_Store
|
||||
*.py
|
||||
|
||||
@@ -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` 须为本机有效绝对路径)
|
||||
@@ -11,8 +11,8 @@
|
||||
"uwf": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^",
|
||||
"@uncaged/uwf-moderator": "workspace:^",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
|
||||
+67
-17
@@ -1,21 +1,26 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Command } from "commander";
|
||||
import type { ThreadId } from "@uncaged/uwf-protocol";
|
||||
|
||||
import {
|
||||
cmdThreadFork,
|
||||
cmdThreadKill,
|
||||
cmdThreadList,
|
||||
cmdThreadRead,
|
||||
THREAD_READ_DEFAULT_QUOTA,
|
||||
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,
|
||||
@@ -40,7 +45,7 @@ function runAction(action: () => Promise<void>): void {
|
||||
const program = new Command();
|
||||
|
||||
program.name("uwf").description("Stateless workflow CLI");
|
||||
program.option("--format <fmt>", "Output format: json, yaml, table", "json");
|
||||
program.option("--format <fmt>", "Output format: json or yaml", "json");
|
||||
|
||||
const workflow = program.command("workflow").description("Workflow registry and CAS");
|
||||
|
||||
@@ -144,6 +149,52 @@ 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("read")
|
||||
.description("Read thread context as human-readable markdown")
|
||||
.argument("<thread-id>", "Thread ULID")
|
||||
.option("--quota <chars>", "Max output characters", String(THREAD_READ_DEFAULT_QUOTA))
|
||||
.option("--before <step-hash>", "Load steps before this hash (exclusive)")
|
||||
.option("--start", "Include start step in output")
|
||||
.option("--detail", "Expand detail content for each step")
|
||||
.action((threadId: string, opts: { quota: string; before: string | undefined; start: boolean; detail: boolean }) => {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
runAction(async () => {
|
||||
const quota = Number.parseInt(opts.quota, 10);
|
||||
if (!Number.isFinite(quota) || quota < 1) {
|
||||
process.stderr.write("invalid --quota: must be a positive integer\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const before = opts.before ?? null;
|
||||
const markdown = await cmdThreadRead(storageRoot, threadId as ThreadId, quota, before, opts.start ?? false, opts.detail ?? false);
|
||||
process.stdout.write(markdown.endsWith("\n") ? markdown : `${markdown}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -185,24 +236,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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,6 +291,16 @@ 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,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(
|
||||
@@ -108,10 +100,10 @@ export async function cmdCasSchemaList(
|
||||
// Include meta-schema itself
|
||||
entries.push({ hash: metaHash, title: "(meta-schema)" });
|
||||
|
||||
for (const hash of store.list()) {
|
||||
for (const hash of store.listByType(metaHash)) {
|
||||
if (hash === metaHash) continue;
|
||||
const node = store.get(hash);
|
||||
if (node !== null && node.type === metaHash) {
|
||||
if (node !== null) {
|
||||
const schema = node.payload as JSONSchema;
|
||||
const title =
|
||||
(schema.title as string | undefined) ??
|
||||
@@ -123,6 +115,17 @@ 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,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { validate } from "@uncaged/json-cas";
|
||||
import { getSchema, validate } from "@uncaged/json-cas";
|
||||
import type { JSONSchema, Store as CasStore } from "@uncaged/json-cas";
|
||||
import { stringify } from "yaml";
|
||||
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
|
||||
import { evaluate } from "@uncaged/uwf-moderator";
|
||||
import type {
|
||||
@@ -8,13 +10,17 @@ import type {
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
WorkflowConfig,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/uwf-protocol";
|
||||
@@ -36,6 +42,7 @@ import {
|
||||
import { isCasRef } from "../validate.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
type ChainState = {
|
||||
startHash: CasRef;
|
||||
@@ -44,6 +51,12 @@ type ChainState = {
|
||||
headIsStart: boolean;
|
||||
};
|
||||
|
||||
type OrderedStepItem = {
|
||||
hash: CasRef;
|
||||
payload: StepNodePayload;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type KillOutput = {
|
||||
thread: ThreadId;
|
||||
archived: boolean;
|
||||
@@ -262,6 +275,215 @@ function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
|
||||
return node.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expand all cas_ref fields in a CAS node's payload,
|
||||
* replacing hash strings with the referenced node's expanded payload.
|
||||
*/
|
||||
function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unknown {
|
||||
const seen = visited ?? new Set<string>();
|
||||
if (seen.has(hash)) return hash; // cycle guard
|
||||
seen.add(hash);
|
||||
|
||||
const node = store.get(hash);
|
||||
if (node === null) return hash;
|
||||
|
||||
const schema = getSchema(store, node.type);
|
||||
if (schema === null) return node.payload;
|
||||
|
||||
return expandValue(store, schema, node.payload, seen);
|
||||
}
|
||||
|
||||
function expandValue(store: CasStore, schema: JSONSchema, value: unknown, visited: Set<string>): unknown {
|
||||
// If this field is a cas_ref, expand it
|
||||
if (schema.format === "cas_ref") {
|
||||
if (typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// anyOf (nullable refs)
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||
return expandDeep(store, value as CasRef, visited);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Array of cas_ref items
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
const itemSchema = schema.items as JSONSchema;
|
||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||
}
|
||||
|
||||
// Object with properties
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
|
||||
const props = schema.properties as Record<string, JSONSchema>;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const propSchema = props[key];
|
||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectOrderedSteps(
|
||||
uwf: UwfStore,
|
||||
headHash: CasRef,
|
||||
chain: ChainState,
|
||||
): OrderedStepItem[] {
|
||||
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;
|
||||
}
|
||||
|
||||
let cur: CasRef | null = chain.headIsStart ? null : headHash;
|
||||
const ordered: OrderedStepItem[] = [];
|
||||
while (cur !== null) {
|
||||
const entry = hashToNode.get(cur);
|
||||
if (entry === undefined) {
|
||||
break;
|
||||
}
|
||||
ordered.push({ hash: cur, ...entry });
|
||||
cur = entry.payload.prev;
|
||||
}
|
||||
ordered.reverse();
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
return stringify(value).trimEnd();
|
||||
}
|
||||
|
||||
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
|
||||
return [
|
||||
`## Step ${index}: ${item.payload.role}`,
|
||||
"",
|
||||
`- **Hash:** \`${item.hash}\``,
|
||||
`- **Agent:** ${item.payload.agent}`,
|
||||
"",
|
||||
"### Output",
|
||||
"",
|
||||
"```yaml",
|
||||
outputYaml,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatThreadReadMarkdown(options: {
|
||||
threadId: ThreadId;
|
||||
workflowName: string;
|
||||
workflowHash: CasRef;
|
||||
prompt: string;
|
||||
ordered: OrderedStepItem[];
|
||||
uwf: UwfStore;
|
||||
workflow: WorkflowPayload;
|
||||
quota: number;
|
||||
before: CasRef | null;
|
||||
showStart: boolean;
|
||||
showDetail: boolean;
|
||||
}): string {
|
||||
const { ordered, uwf, workflow, quota, before, showStart, showDetail } = options;
|
||||
|
||||
// Determine which steps to consider
|
||||
let candidates = ordered;
|
||||
if (before !== null) {
|
||||
const idx = candidates.findIndex((s) => s.hash === before);
|
||||
if (idx === -1) {
|
||||
fail(`step ${before} not found in thread ${options.threadId}`);
|
||||
}
|
||||
candidates = candidates.slice(0, idx);
|
||||
}
|
||||
|
||||
// Walk backward from newest, accumulating chars until quota exceeded
|
||||
const selected: OrderedStepItem[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const item = candidates[i];
|
||||
if (item === undefined) continue;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||
selected.unshift(item);
|
||||
totalChars += blockLen;
|
||||
if (totalChars > quota) break;
|
||||
}
|
||||
|
||||
const skippedCount = candidates.length - selected.length;
|
||||
const parts: string[] = [];
|
||||
|
||||
// Start section
|
||||
if (before === null || showStart) {
|
||||
parts.push(
|
||||
[
|
||||
`# Thread \`${options.threadId}\``,
|
||||
"",
|
||||
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||
"",
|
||||
"## Task",
|
||||
"",
|
||||
options.prompt,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// Skip hint
|
||||
if (skippedCount > 0 && selected.length > 0) {
|
||||
const firstSelected = selected[0];
|
||||
if (firstSelected !== undefined) {
|
||||
parts.push(
|
||||
`*(${skippedCount} earlier step${skippedCount > 1 ? "s" : ""}, load with \`uwf thread read ${options.threadId} --before ${firstSelected.hash}\`)*`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step blocks
|
||||
const startIndex = candidates.length - selected.length;
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const item = selected[i];
|
||||
if (item === undefined) continue;
|
||||
const stepNum = startIndex + i + 1;
|
||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||
const ts = new Date(item.timestamp).toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
||||
const stepLines = [
|
||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||
];
|
||||
const roleDef = workflow.roles[item.payload.role];
|
||||
if (roleDef) {
|
||||
stepLines.push("", "### Prompt", "", roleDef.systemPrompt);
|
||||
}
|
||||
stepLines.push(
|
||||
"",
|
||||
"### Output",
|
||||
"",
|
||||
"```yaml",
|
||||
outputYaml,
|
||||
"```",
|
||||
);
|
||||
if (showDetail && item.payload.detail) {
|
||||
const detailExpanded = expandDeep(uwf.store, item.payload.detail);
|
||||
const detailYaml = formatYaml(detailExpanded);
|
||||
stepLines.push("", "### Detail", "", "```yaml", detailYaml, "```");
|
||||
}
|
||||
parts.push(stepLines.join("\n"));
|
||||
}
|
||||
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
@@ -437,6 +659,115 @@ 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[] = [];
|
||||
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||
|
||||
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 cmdThreadRead(
|
||||
storageRoot: string,
|
||||
threadId: ThreadId,
|
||||
quota: number = THREAD_READ_DEFAULT_QUOTA,
|
||||
before: CasRef | null = null,
|
||||
showStart: boolean = false,
|
||||
showDetail: boolean = false,
|
||||
): Promise<string> {
|
||||
const headHash = await resolveHeadHash(storageRoot, threadId);
|
||||
const uwf = await createUwfStore(storageRoot);
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflow = loadWorkflowPayload(uwf, chain.start.workflow);
|
||||
const ordered = collectOrderedSteps(uwf, headHash, chain);
|
||||
|
||||
return formatThreadReadMarkdown({
|
||||
threadId,
|
||||
workflowName: workflow.name,
|
||||
workflowHash: chain.start.workflow,
|
||||
prompt: chain.start.prompt,
|
||||
ordered,
|
||||
uwf,
|
||||
workflow,
|
||||
quota,
|
||||
before,
|
||||
showStart,
|
||||
showDetail,
|
||||
});
|
||||
}
|
||||
|
||||
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,24 +1,6 @@
|
||||
import { stringify } from "yaml";
|
||||
|
||||
export type OutputFormat = "json" | "yaml" | "table";
|
||||
|
||||
function formatTable(data: Array<Record<string, unknown>>): string {
|
||||
if (data.length === 0) return "";
|
||||
const keys = Object.keys(data[0]);
|
||||
const widths = keys.map((k) => {
|
||||
let max = k.length;
|
||||
for (const row of data) {
|
||||
const len = String(row[k] ?? "").length;
|
||||
if (len > max) max = len;
|
||||
}
|
||||
return max;
|
||||
});
|
||||
const header = keys.map((k, i) => k.toUpperCase().padEnd(widths[i])).join(" ");
|
||||
const rows = data.map((row) =>
|
||||
keys.map((k, i) => String(row[k] ?? "").padEnd(widths[i])).join(" "),
|
||||
);
|
||||
return [header, ...rows].join("\n");
|
||||
}
|
||||
export type OutputFormat = "json" | "yaml";
|
||||
|
||||
export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
switch (format) {
|
||||
@@ -26,10 +8,5 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
|
||||
return JSON.stringify(data);
|
||||
case "yaml":
|
||||
return stringify(data).trimEnd();
|
||||
case "table":
|
||||
if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object" && data[0] !== null) {
|
||||
return formatTable(data as Array<Record<string, unknown>>);
|
||||
}
|
||||
return stringify(data).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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,6 +21,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/uwf-agent-kit": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
const HERMES_COMMAND = "hermes";
|
||||
const HERMES_MAX_TURNS = 90;
|
||||
|
||||
function buildHistorySummary(history: AgentContext["history"]): string {
|
||||
if (history.length === 0) {
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const step = history[i];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -26,15 +33,17 @@ function buildHistorySummary(history: AgentContext["history"]): string {
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Hermes. */
|
||||
export function buildHermesPrompt(ctx: AgentContext): string {
|
||||
const parts: string[] = [ctx.systemPrompt, "", "## Task", ctx.prompt];
|
||||
const historyBlock = buildHistorySummary(ctx.history);
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
const systemPrompt = roleDef?.systemPrompt ?? "";
|
||||
const parts: string[] = [systemPrompt, "", "## Task", ctx.start.prompt];
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function spawnHermesChat(prompt: string): Promise<string> {
|
||||
function spawnHermesChat(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"chat",
|
||||
@@ -67,7 +76,7 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||
@@ -76,9 +85,23 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<string> {
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
return spawnHermesChat(fullPrompt);
|
||||
const { stdout, stderr } = await spawnHermesChat(fullPrompt);
|
||||
const { store } = ctx;
|
||||
|
||||
// --quiet mode: session_id may be on stdout or stderr
|
||||
const sessionId = parseSessionIdFromStdout(stderr) ?? parseSessionIdFromStdout(stdout);
|
||||
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, stdout);
|
||||
return { output: stdout, detailHash };
|
||||
}
|
||||
|
||||
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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.1.3",
|
||||
"@uncaged/json-cas-fs": "^0.1.2",
|
||||
"@uncaged/json-cas": "^0.3.0",
|
||||
"@uncaged/json-cas-fs": "^0.3.0",
|
||||
"@uncaged/uwf-protocol": "workspace:^",
|
||||
"dotenv": "^16.6.1",
|
||||
"yaml": "^2.8.4"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartNodePayload,
|
||||
@@ -6,6 +7,7 @@ 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 = {
|
||||
@@ -20,8 +22,8 @@ function fail(message: string): never {
|
||||
}
|
||||
|
||||
function walkChain(
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
headHash: CasRef,
|
||||
): ChainState {
|
||||
const headNode = store.get(headHash);
|
||||
@@ -77,7 +79,7 @@ function walkChain(
|
||||
}
|
||||
|
||||
function expandOutput(
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
store: Store,
|
||||
outputRef: CasRef,
|
||||
): unknown {
|
||||
const node = store.get(outputRef);
|
||||
@@ -88,7 +90,7 @@ function expandOutput(
|
||||
}
|
||||
|
||||
async function buildHistory(
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
store: Store,
|
||||
stepsNewestFirst: StepNodePayload[],
|
||||
): Promise<StepContext[]> {
|
||||
const chronological = [...stepsNewestFirst].reverse();
|
||||
@@ -105,8 +107,8 @@ async function buildHistory(
|
||||
}
|
||||
|
||||
async function loadWorkflow(
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
|
||||
store: Store,
|
||||
schemas: AgentStore["schemas"],
|
||||
workflowRef: CasRef,
|
||||
) {
|
||||
const node = store.get(workflowRef);
|
||||
@@ -141,22 +143,22 @@ export async function buildContext(threadId: ThreadId, role: string): Promise<Ag
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
systemPrompt: roleDef.systemPrompt,
|
||||
prompt: chain.start.prompt,
|
||||
history,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
export type BuildContextMeta = {
|
||||
storageRoot: string;
|
||||
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
|
||||
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
|
||||
store: Store;
|
||||
schemas: AgentStore["schemas"];
|
||||
headHash: CasRef;
|
||||
chain: ChainState;
|
||||
};
|
||||
@@ -185,15 +187,15 @@ export async function buildContextWithMeta(
|
||||
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
|
||||
}
|
||||
|
||||
const history = await buildHistory(store, chain.stepsNewestFirst);
|
||||
const steps = await buildHistory(store, chain.stepsNewestFirst);
|
||||
|
||||
return {
|
||||
threadId,
|
||||
role,
|
||||
systemPrompt: roleDef.systemPrompt,
|
||||
prompt: chain.start.prompt,
|
||||
history,
|
||||
start: chain.start,
|
||||
steps,
|
||||
workflow,
|
||||
store,
|
||||
meta: { storageRoot, store, schemas, headHash, chain },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,
|
||||
@@ -8,4 +7,5 @@ export {
|
||||
resolveModel,
|
||||
} from "./extract.js";
|
||||
export { createAgent } from "./run.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
|
||||
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
|
||||
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } 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 } from "./types.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } 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<string> {
|
||||
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
|
||||
return runWithMessage("agent run failed", () => options.run(ctx));
|
||||
}
|
||||
|
||||
@@ -85,12 +85,11 @@ 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,
|
||||
@@ -98,7 +97,7 @@ async function persistStep(options: {
|
||||
prevHash: chain.headIsStart ? null : headHash,
|
||||
role: options.ctx.role,
|
||||
outputHash: options.outputHash,
|
||||
detailHash,
|
||||
detailHash: options.detailHash,
|
||||
agentName: options.agentName,
|
||||
});
|
||||
}
|
||||
@@ -121,12 +120,12 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
fail(`unknown role: ${role}`);
|
||||
}
|
||||
|
||||
const rawOutput = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
rawOutput,
|
||||
outputHash,
|
||||
detailHash: agentResult.detailHash,
|
||||
agentName: agentLabel(options.name),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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,15 +1,19 @@
|
||||
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
import type { Store } from "@uncaged/json-cas";
|
||||
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
|
||||
|
||||
export type AgentContext = {
|
||||
export type AgentContext = ModeratorContext & {
|
||||
threadId: ThreadId;
|
||||
role: string;
|
||||
systemPrompt: string;
|
||||
prompt: string;
|
||||
history: StepContext[];
|
||||
store: Store;
|
||||
workflow: WorkflowPayload;
|
||||
};
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
|
||||
export type AgentRunResult = {
|
||||
output: string;
|
||||
detailHash: string;
|
||||
};
|
||||
|
||||
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||
|
||||
export type AgentOptions = {
|
||||
name: string;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/json-cas-fs": "^0.1.3"
|
||||
"@uncaged/json-cas-fs": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -16,14 +16,18 @@ export type {
|
||||
RoleDefinition,
|
||||
RoleName,
|
||||
Scenario,
|
||||
StartEntry,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepEntry,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
|
||||
@@ -80,6 +80,39 @@ 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;
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
+17
-1
@@ -1,11 +1,27 @@
|
||||
#!/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) => {
|
||||
return `Mock output for role ${ctx.role}: task was "${ctx.prompt}"`;
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+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