Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1bc01ed85 | |||
| d3b5d66208 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 | |||
| bd110b76e1 | |||
| dc10ccceaa | |||
| c040a90a8f | |||
| ec4599a230 | |||
| 1f4bd3f431 | |||
| bebf4aad45 | |||
| 11ba185fef | |||
| 730340d123 | |||
| c848216396 | |||
| 2698e0a6cb | |||
| 47f2b1a128 | |||
| 0c02cb7574 | |||
| 320810ec25 | |||
| 91f585c534 | |||
| 299ff126d9 | |||
| 931eb81458 | |||
| c604d1f600 | |||
| 20bcc65f61 | |||
| f5612ef1b5 | |||
| a92deeaf3f | |||
| 1e936cf04a | |||
| ea16057803 | |||
| 4493fd8979 | |||
| cc1ee8d5e3 | |||
| 0ad5c85f5a | |||
| d02d410dcd | |||
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 98122b446d | |||
| 4a31cf9d63 |
@@ -5,3 +5,4 @@ bun.lock
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
|
||||
@@ -229,6 +229,37 @@ const mod = await import(bundlePath);
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Package Build & Distribution
|
||||
|
||||
每个包用 `tsc` 编译出 `.js` + `.d.ts` 到 `dist/`,**发布 `dist`,不发 `src`,不 bundle**。
|
||||
|
||||
### 规则
|
||||
|
||||
- ✅ `tsc --build` 编译,保留目录结构(tree-shaking 友好)
|
||||
- ✅ `package.json` 的 `exports` 指向 `dist/`(编译产物),不指向 `src/`
|
||||
- ✅ 消费端不受 tsconfig 差异影响
|
||||
- ❌ 不要用 rollup/esbuild 打成单文件
|
||||
- ❌ `exports` 中不要出现 `./src/`
|
||||
|
||||
### package.json exports 格式
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
多入口同理,所有路径指向 `dist/`。
|
||||
|
||||
### 例外
|
||||
|
||||
- `workflow-gateway`(Cloudflare Workers)和 `workflow-dashboard`(私有 app)不发 npm,exports 可指向 `src/`。
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
@@ -245,6 +276,64 @@ bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Publishing to Gitea npm Registry
|
||||
|
||||
All public `@uncaged/*` packages are published to the Gitea npm registry at `git.shazhou.work`. Workflow workspaces consume packages from this registry via `bunfig.toml`.
|
||||
|
||||
```bash
|
||||
# Publish all packages (bun pm pack resolves workspace:* → actual versions)
|
||||
bun run publish:gitea
|
||||
|
||||
# Dry run — see what would be published
|
||||
bun run publish:gitea:dry
|
||||
```
|
||||
|
||||
Prerequisites: `.npmrc` in monorepo root with Gitea auth token (`//git.shazhou.work/api/packages/shazhou/npm/:_authToken=<token>`).
|
||||
|
||||
### Workflow Workspace Setup
|
||||
|
||||
External workflow repos (e.g. `xingyue-workflows`) use the Gitea registry for `@uncaged/*` packages. Add a `bunfig.toml`:
|
||||
|
||||
```toml
|
||||
[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
```
|
||||
|
||||
Then `bun install` resolves `@uncaged/*` from Gitea, all other packages from npmjs.
|
||||
|
||||
### Cross-repo Development (bun link)
|
||||
|
||||
Alternative for development against un-published local changes:
|
||||
|
||||
```bash
|
||||
bun run link # Register all packages (from monorepo root)
|
||||
bun run link:consume # Link into CWD's project (⚠️ don't bun install after)
|
||||
bun run link:unlink # Restore original deps
|
||||
```
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
The recommended development flow for building workflows:
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun run publish:gitea — auto topo-sort, bun pm pack → npm publish
|
||||
▼
|
||||
git.shazhou.work npm registry — @uncaged/* scoped packages
|
||||
│ bun install — via bunfig.toml scoped registry
|
||||
▼
|
||||
my-workflows/ (workspace) — bunfig.toml + normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun run publish:gitea` (packages auto-discovered from `packages/*/`, topologically sorted, `workspace:*` resolved to real versions)
|
||||
2. **Workspace** → `bun install` fetches latest from Gitea, `bun install` is safe to run anytime
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
@@ -0,0 +1,191 @@
|
||||
# workflow-agent-react — ReAct Agent Package
|
||||
|
||||
**Status**: RFC v3
|
||||
**Author**: 小橘 🍊
|
||||
|
||||
## Problem
|
||||
|
||||
现有的 agent 包都依赖外部 CLI 进程:
|
||||
|
||||
| Package | 机制 | 能力 |
|
||||
|---------|------|------|
|
||||
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
|
||||
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
|
||||
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
|
||||
|
||||
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
||||
|
||||
## 核心设计变更:AdapterFn 替代 AgentFn
|
||||
|
||||
### 现状的问题
|
||||
|
||||
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
|
||||
|
||||
```
|
||||
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
|
||||
```
|
||||
|
||||
### 新抽象:AdapterFn
|
||||
|
||||
```typescript
|
||||
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
|
||||
|
||||
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
```
|
||||
|
||||
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
|
||||
- **`schema`** — role 的 meta schema,定义输出格式
|
||||
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
|
||||
|
||||
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
|
||||
|
||||
### AgentContext 不再需要
|
||||
|
||||
`AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
|
||||
|
||||
### createWorkflow 签名变更
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// After
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
```
|
||||
|
||||
engine 对每个 role 的执行逻辑:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
|
||||
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
|
||||
|
||||
// After
|
||||
const roleFn = adapter(role.systemPrompt, role.metaSchema);
|
||||
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
|
||||
```
|
||||
|
||||
## `createReactAdapter` — 复用 workflow-reactor
|
||||
|
||||
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor` 的 `ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
|
||||
|
||||
```typescript
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
type ReactAdapterConfig = {
|
||||
provider: LlmProvider;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: createLlmFn(config.provider),
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => buildStructuredTool(s),
|
||||
systemPromptForStructuredTool: () => prompt,
|
||||
toolHandler: (call, ctx) =>
|
||||
config.toolHandler(call.function.name, call.function.arguments),
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const input = buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.value;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
|
||||
|
||||
## `agentToAdapter` — 向后兼容
|
||||
|
||||
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`:
|
||||
|
||||
```typescript
|
||||
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
|
||||
const result = await agent(agentCtx);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
return extract(output, schema, extractProvider);
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
|
||||
|
||||
## 包结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-react/
|
||||
src/
|
||||
types.ts # ReactAdapterConfig, ReactToolHandler
|
||||
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
|
||||
thread-input.ts # ThreadContext → user message string
|
||||
index.ts
|
||||
__tests__/
|
||||
create-react-adapter.test.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
依赖:
|
||||
- `@uncaged/workflow-protocol` — `ThreadContext`, `LlmProvider`
|
||||
- `@uncaged/workflow-reactor` — `createLlmFn`, `createThreadReactor`, types
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
| 改动 | 影响 |
|
||||
|------|------|
|
||||
| `AgentBinding` → `AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
|
||||
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
|
||||
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
|
||||
|
||||
### 需修改的包
|
||||
|
||||
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
|
||||
2. `workflow-runtime` — 更新 re-export
|
||||
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
|
||||
4. `workflow-util-agent` — `buildAgentPrompt` → `buildThreadInput`,接收 `ThreadContext`
|
||||
5. 所有 bundle-entry — `agent:` → `adapter:`
|
||||
|
||||
### 不受影响
|
||||
|
||||
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
|
||||
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
|
||||
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
|
||||
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
|
||||
|
||||
## 工具集(后续讨论)
|
||||
|
||||
| 工具 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| `read_file` | 读文件 | P0 |
|
||||
| `write_file` | 写文件 | P0 |
|
||||
| `patch_file` | find-and-replace 编辑 | P0 |
|
||||
| `shell_exec` | 执行 shell 命令 | P0 |
|
||||
| `search_files` | grep / find | P1 |
|
||||
| `list_files` | ls | P1 |
|
||||
+7
-1
@@ -9,10 +9,16 @@
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
"test": "bun run --filter '*' test",
|
||||
"link": "./scripts/link-all.sh",
|
||||
"link:consume": "./scripts/link-all.sh --consume",
|
||||
"link:unlink": "./scripts/link-all.sh --unlink",
|
||||
"publish:gitea": "./scripts/publish.sh patch",
|
||||
"publish:gitea:dry": "./scripts/publish.sh --dry-run patch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("setup --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Configuration:");
|
||||
expect(u).toContain("setup [--provider <name>]");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -38,8 +38,16 @@ describe("init workspace", () => {
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
scripts: { bundle: string };
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
|
||||
|
||||
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
|
||||
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
|
||||
expect(bundleSrc).toContain("Bun.build");
|
||||
expect(bundleSrc).toContain("-entry.ts");
|
||||
expect(bundleSrc).toContain("distDir");
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
@@ -83,7 +91,7 @@ describe("init workspace", () => {
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"ModeratorTable",
|
||||
"AgentFn",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
@@ -117,9 +125,6 @@ describe("init workspace", () => {
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(slash.ok).toBe(false);
|
||||
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
@@ -127,6 +132,14 @@ describe("init workspace", () => {
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts nested path as workspace name", async () => {
|
||||
const nested = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(nested.ok).toBe(true);
|
||||
if (nested.ok) {
|
||||
expect(nested.value.rootPath).toContain("a/b");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("init workspace <name>");
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdSetup } from "../src/commands/setup/index.js";
|
||||
|
||||
describe("setup command (CLI mode)", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
|
||||
const r = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config).not.toBeNull();
|
||||
if (reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
});
|
||||
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
|
||||
expect(reg.value.config.maxDepth).toBe(3);
|
||||
expect(reg.value.config.supervisorInterval).toBe(3);
|
||||
|
||||
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
|
||||
expect(raw).toContain("dashscope");
|
||||
expect(raw).toContain("qwen-plus");
|
||||
});
|
||||
|
||||
test("idempotent: second run updates apiKey and preserves workflows", async () => {
|
||||
const initialYaml = `config:
|
||||
maxDepth: 7
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-old
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
workflows:
|
||||
keep-me:
|
||||
hash: "0000000000000"
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
|
||||
|
||||
const r2 = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-newkey",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r2.ok).toBe(true);
|
||||
if (!r2.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
|
||||
expect(reg.value.config.maxDepth).toBe(7);
|
||||
expect(reg.value.config.supervisorInterval).toBe(2);
|
||||
expect(reg.value.workflows["keep-me"]).toBeDefined();
|
||||
if (reg.value.workflows["keep-me"] === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
|
||||
});
|
||||
|
||||
test("runCli setup dispatches with flags and exits 0", async () => {
|
||||
const code = await runCli(storageRoot, [
|
||||
"setup",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--default-model",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
expect(code).toBe(0);
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
|
||||
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -70,10 +70,10 @@ const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
@@ -187,6 +187,14 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-gateway": "workspace:*",
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -66,6 +67,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
|
||||
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
const SETUP_USAGE_COMMANDS = [
|
||||
{
|
||||
name: "",
|
||||
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
|
||||
description:
|
||||
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
@@ -38,9 +39,10 @@ export function formatCliUsage(
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
prefix: `${group.name}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -42,7 +44,9 @@ function biomeJson(): string {
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
// Exclude generated bundle script — it uses Bun globals and console that
|
||||
// conflict with the workspace's Biome rules (noConsole, etc.).
|
||||
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
@@ -86,7 +90,7 @@ function agentsMd(): string {
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
@@ -96,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
@@ -107,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
@@ -157,6 +161,12 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
function readmeMd(workspaceName: string): string {
|
||||
return `# ${workspaceName}
|
||||
|
||||
@@ -184,32 +194,137 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
`;
|
||||
}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"type JsonDeps = {",
|
||||
" dependencies: Record<string, string> | null;",
|
||||
" devDependencies: Record<string, string> | null;",
|
||||
"};",
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
"",
|
||||
"function entryStem(name: string): string {",
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
||||
" const names = new Set<string>();",
|
||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
||||
" for (const pkgPath of paths) {",
|
||||
" let raw: string;",
|
||||
" try {",
|
||||
' raw = await readFile(pkgPath, "utf8");',
|
||||
" } catch {",
|
||||
" continue;",
|
||||
" }",
|
||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
||||
" for (const block of blocks) {",
|
||||
" if (block == null) {",
|
||||
" continue;",
|
||||
" }",
|
||||
" for (const key of Object.keys(block)) {",
|
||||
' if (key.startsWith("@uncaged/workflow")) {',
|
||||
" names.add(key);",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" if (names.size === 0) {",
|
||||
' names.add("@uncaged/workflow-runtime");',
|
||||
' names.add("@uncaged/workflow-protocol");',
|
||||
" }",
|
||||
" return [...names];",
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
" try {",
|
||||
" files = await readdir(workflowsDir);",
|
||||
" } catch {",
|
||||
' console.error("bundle: missing workflows/ directory");',
|
||||
" process.exitCode = 1;",
|
||||
" return;",
|
||||
" }",
|
||||
" const entries = files.filter(isEntryFile);",
|
||||
" if (entries.length === 0) {",
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" const external = await uncagedWorkflowExternals();",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
" const result = await Bun.build({",
|
||||
" entrypoints: [entryPath],",
|
||||
" outdir: distDir,",
|
||||
' format: "esm",',
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" external,",
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
" console.error(log);",
|
||||
" }",
|
||||
` throw new Error(\`bundle failed for \${file}\`);`,
|
||||
" }",
|
||||
" const dts =",
|
||||
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
|
||||
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
|
||||
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"await main();",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
|
||||
const resolved = resolve(parentDir, workspaceName);
|
||||
const rootPath = resolved;
|
||||
const dirName = basename(resolved);
|
||||
|
||||
if (dirName === "" || dirName === "." || dirName === "..") {
|
||||
return err(`invalid workspace path: ${workspaceName}`);
|
||||
}
|
||||
|
||||
const rootPath = join(parentDir, workspaceName);
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
await mkdir(rootPath, { recursive: true });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: true });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: true });
|
||||
await mkdir(join(rootPath, "scripts"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
|
||||
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import {
|
||||
registerWithGateway,
|
||||
startHeartbeat,
|
||||
startTunnel,
|
||||
unregisterFromGateway,
|
||||
} from "./tunnel.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
@@ -56,6 +53,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let hostname = "127.0.0.1";
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let noTunnel = false;
|
||||
let tunnelUrl: string | null = null;
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
@@ -68,6 +66,9 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
"--tunnel-url": (v) => {
|
||||
tunnelUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -87,7 +88,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
|
||||
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
@@ -107,47 +108,64 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start cloudflared quick tunnel
|
||||
printCliLine("starting cloudflared quick tunnel...");
|
||||
const tunnel = await startTunnel(options.port);
|
||||
let resolvedTunnelUrl: string;
|
||||
let stopWsClient: (() => void) | null = null;
|
||||
|
||||
if (!tunnel) {
|
||||
printCliLine("failed to create tunnel — continuing without gateway registration");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
if (options.tunnelUrl !== null) {
|
||||
resolvedTunnelUrl = options.tunnelUrl;
|
||||
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
|
||||
} else {
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine(
|
||||
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
|
||||
);
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
localPort: options.port,
|
||||
log,
|
||||
});
|
||||
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
|
||||
}
|
||||
|
||||
printCliLine(`tunnel: ${tunnel.url}`);
|
||||
|
||||
// Register with gateway
|
||||
if (options.gatewaySecret) {
|
||||
if (agentToken === null) {
|
||||
printCliLine("internal error: agent token missing");
|
||||
await new Promise(() => {});
|
||||
return 1;
|
||||
}
|
||||
const token = agentToken;
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
token,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
resolvedTunnelUrl,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
token,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient?.();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
tunnel.process.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -157,7 +175,6 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type ServeOptions = {
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
tunnelUrl: string | null;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
localPort: number;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
|
||||
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
|
||||
const u = new URL(gatewayUrl);
|
||||
if (u.protocol === "https:") {
|
||||
u.protocol = "wss:";
|
||||
} else if (u.protocol === "http:") {
|
||||
u.protocol = "ws:";
|
||||
}
|
||||
u.pathname = "/ws/connect";
|
||||
u.search = "";
|
||||
u.searchParams.set("name", name);
|
||||
u.searchParams.set("secret", secret);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of h) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleGatewayMessage(
|
||||
ws: WebSocket,
|
||||
raw: string,
|
||||
params: GatewayWsClientParams,
|
||||
): Promise<void> {
|
||||
const req = parseWsRequestJson(raw);
|
||||
if (req === null) {
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
|
||||
const initHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
initHeaders.set(k, v);
|
||||
}
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(localUrl, {
|
||||
method: req.method,
|
||||
headers: initHeaders,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
});
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
|
||||
};
|
||||
ws.send(JSON.stringify(errBody));
|
||||
return;
|
||||
}
|
||||
const bodyText = await resp.text();
|
||||
const headerRecord = headersToRecord(resp.headers);
|
||||
const out: WsResponse = {
|
||||
id: req.id,
|
||||
status: resp.status,
|
||||
headers: headerRecord,
|
||||
body: bodyText,
|
||||
};
|
||||
ws.send(JSON.stringify(out));
|
||||
}
|
||||
|
||||
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
|
||||
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
|
||||
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearReconnectTimer = (): void => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2RLP", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
|
||||
try {
|
||||
socket = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
attempt = 0;
|
||||
params.log("4PWN3V82", "gateway WebSocket connected");
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
socket = null;
|
||||
params.log(
|
||||
"8QTR6ZKC",
|
||||
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
|
||||
);
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
params.log("9BWS1M7F", "gateway WebSocket error");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "shutdown");
|
||||
}
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
|
||||
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
import { loadPresetProviders } from "./preset-providers.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
type OpenAiModelEntry = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OpenAiModelsResponse = {
|
||||
data: OpenAiModelEntry[];
|
||||
};
|
||||
|
||||
function usageSetup(): string {
|
||||
return [
|
||||
"uncaged-workflow setup — configure workflow.yaml providers and default model",
|
||||
"",
|
||||
"Non-interactive (agent mode):",
|
||||
" uncaged-workflow setup \\",
|
||||
" --provider <name> \\",
|
||||
" --base-url <url> \\",
|
||||
" --api-key <key> \\",
|
||||
" --default-model <provider/model> \\",
|
||||
" [--init-workspace <name>]",
|
||||
"",
|
||||
"Interactive: run with no flags (prompts for each value).",
|
||||
"",
|
||||
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
type ParsedSetup = SetupCliArgs | "interactive" | "help";
|
||||
|
||||
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
|
||||
|
||||
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
|
||||
"--provider": "provider",
|
||||
"--base-url": "baseUrl",
|
||||
"--api-key": "apiKey",
|
||||
"--default-model": "defaultModel",
|
||||
"--init-workspace": "initWorkspaceName",
|
||||
};
|
||||
|
||||
function emptyFlagState(): Record<SetupFlagField, string | null> {
|
||||
return {
|
||||
provider: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
defaultModel: null,
|
||||
initWorkspaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeParsedSetup(
|
||||
state: Record<SetupFlagField, string | null>,
|
||||
): Result<ParsedSetup, string> {
|
||||
const hasAnyFlag =
|
||||
state.provider !== null ||
|
||||
state.baseUrl !== null ||
|
||||
state.apiKey !== null ||
|
||||
state.defaultModel !== null ||
|
||||
state.initWorkspaceName !== null;
|
||||
|
||||
if (!hasAnyFlag) {
|
||||
return ok("interactive");
|
||||
}
|
||||
|
||||
if (state.provider === null) {
|
||||
return err(
|
||||
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
|
||||
);
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (state.baseUrl === null) {
|
||||
missing.push("--base-url");
|
||||
}
|
||||
if (state.apiKey === null) {
|
||||
missing.push("--api-key");
|
||||
}
|
||||
if (state.defaultModel === null) {
|
||||
missing.push("--default-model");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return err(`missing required flag(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const b = state.baseUrl;
|
||||
const k = state.apiKey;
|
||||
const m = state.defaultModel;
|
||||
if (b === null || k === null || m === null) {
|
||||
return err("internal: missing required flags after validation");
|
||||
}
|
||||
|
||||
return ok({
|
||||
provider: state.provider,
|
||||
baseUrl: b,
|
||||
apiKey: k,
|
||||
defaultModel: m,
|
||||
initWorkspaceName: state.initWorkspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
|
||||
const state = emptyFlagState();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
if (tok === "--help" || tok === "-h") {
|
||||
return ok("help");
|
||||
}
|
||||
const field = SETUP_FLAG_TO_FIELD[tok];
|
||||
if (field === undefined) {
|
||||
return err(`unknown argument: ${tok}`);
|
||||
}
|
||||
const v = requireNext(argv, i, tok);
|
||||
if (!v.ok) {
|
||||
return v;
|
||||
}
|
||||
state[field] = v.value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return finalizeParsedSetup(state);
|
||||
}
|
||||
|
||||
async function promptLine(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = await rl.question(label);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
type SecretInputState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
onData: (chunk: string) => void;
|
||||
fulfill: (value: string) => void;
|
||||
};
|
||||
|
||||
function isLineTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "\u0004";
|
||||
}
|
||||
|
||||
function handleLineTerminator(state: SecretInputState): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(state.rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.fulfill(state.buf.trim());
|
||||
}
|
||||
|
||||
function handleBackspace(state: SecretInputState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInterrupt(rawWasSet: boolean): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
function isBackspace(c: string): boolean {
|
||||
return c === "\u007F" || c === "\b";
|
||||
}
|
||||
|
||||
/** Process a single character in secret input. Returns "done" to stop reading. */
|
||||
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
|
||||
if (isLineTerminator(c)) {
|
||||
handleLineTerminator(state);
|
||||
return "done";
|
||||
}
|
||||
if (isBackspace(c)) {
|
||||
handleBackspace(state);
|
||||
return "skip";
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
handleInterrupt(state.rawWasSet);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return "append";
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((fulfill) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (processSecretChar(c, state) === "done") return;
|
||||
}
|
||||
};
|
||||
|
||||
state.onData = onData;
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as OpenAiModelsResponse;
|
||||
if (!Array.isArray(body.data)) {
|
||||
return [];
|
||||
}
|
||||
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
|
||||
// wordart, wanx, wan2, paraformer) but harmless for other providers.
|
||||
const NON_CHAT_RE =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT_RE.test(id))
|
||||
.sort();
|
||||
} catch (e) {
|
||||
setupDispatchLog(
|
||||
"V8NQ4JT6",
|
||||
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
|
||||
|
||||
function printProviderMenu(presets: readonly PresetProvider[]): void {
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets.at(i);
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
presets: readonly PresetProvider[],
|
||||
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
return err(`invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets.at(choiceNum - 1);
|
||||
if (!selected) return err(`invalid choice: ${choice}`);
|
||||
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
|
||||
}
|
||||
|
||||
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") return err("provider name must not be empty");
|
||||
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") return err("base URL must not be empty");
|
||||
return ok({ provider, baseUrl });
|
||||
}
|
||||
|
||||
function printModelList(models: string[]): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length;
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2;
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
const model = models.at(j) ?? "";
|
||||
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
models: string[],
|
||||
): Promise<Result<string, string>> {
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
printModelList(models);
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
return ok(models.at(modelNum - 1) ?? modelInput);
|
||||
}
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
async function selectWorkspace(rl: {
|
||||
question: (q: string) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") return null;
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function stripProviderPrefix(model: string): string {
|
||||
if (model.includes("/")) {
|
||||
return model.split("/").pop() ?? model;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
printCliLine("Configure the LLM provider that workflow agents will use.\n");
|
||||
|
||||
const presets = loadPresetProviders();
|
||||
printProviderMenu(presets);
|
||||
|
||||
const providerResult = await selectProvider(rl, presets);
|
||||
if (!providerResult.ok) {
|
||||
rl.close();
|
||||
return providerResult;
|
||||
}
|
||||
const { provider, baseUrl } = providerResult.value;
|
||||
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key for this provider: ");
|
||||
if (apiKey === "") return err("API key must not be empty");
|
||||
const rl2 = createInterface({ input, output });
|
||||
|
||||
printCliLine("\nFetching available models...");
|
||||
const models = await fetchAvailableModels(baseUrl, apiKey);
|
||||
const modelResult = await selectModel(rl2, models);
|
||||
if (!modelResult.ok) {
|
||||
rl2.close();
|
||||
return modelResult;
|
||||
}
|
||||
|
||||
const bare = stripProviderPrefix(modelResult.value);
|
||||
const defaultModel = `${provider}/${bare}`;
|
||||
printCliLine(` → ${defaultModel}`);
|
||||
|
||||
const initWorkspaceName = await selectWorkspace(rl2);
|
||||
rl2.close();
|
||||
|
||||
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseSetupArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${parsed.error}\n\n${usageSetup()}`);
|
||||
return 1;
|
||||
}
|
||||
if (parsed.value === "help") {
|
||||
printCliLine(usageSetup());
|
||||
return 0;
|
||||
}
|
||||
|
||||
let args: SetupCliArgs;
|
||||
if (parsed.value === "interactive") {
|
||||
const collected = await collectInteractiveSetup();
|
||||
if (!collected.ok) {
|
||||
printCliError(collected.error);
|
||||
return 1;
|
||||
}
|
||||
args = collected.value;
|
||||
} else {
|
||||
args = parsed.value;
|
||||
}
|
||||
|
||||
const result = await cmdSetup(storageRoot, args);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printSetupSummary(result.value);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { dispatchSetup } from "./dispatch.js";
|
||||
export { loadPresetProviders } from "./preset-providers.js";
|
||||
export { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { PresetProvider } from "./types.js";
|
||||
|
||||
type RawPresetEntry = {
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
};
|
||||
|
||||
function isRawEntry(v: unknown): v is RawPresetEntry {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
}
|
||||
|
||||
let cached: ReadonlyArray<PresetProvider> | null = null;
|
||||
|
||||
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Preset LLM providers for `uncaged-workflow setup`.
|
||||
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
|
||||
# Add new providers here — no code changes required.
|
||||
|
||||
# ── International ──────────────────────────────────────────
|
||||
|
||||
- name: openai
|
||||
label: OpenAI
|
||||
baseUrl: https://api.openai.com/v1
|
||||
|
||||
- name: xai
|
||||
label: xAI
|
||||
baseUrl: https://api.x.ai/v1
|
||||
|
||||
- name: openrouter
|
||||
label: OpenRouter
|
||||
baseUrl: https://openrouter.ai/api/v1
|
||||
|
||||
- name: venice
|
||||
label: Venice
|
||||
baseUrl: https://api.venice.ai/api/v1
|
||||
|
||||
# ── China ──────────────────────────────────────────────────
|
||||
|
||||
- name: dashscope
|
||||
label: DashScope (Alibaba)
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
- name: deepseek
|
||||
label: DeepSeek
|
||||
baseUrl: https://api.deepseek.com/v1
|
||||
|
||||
- name: siliconflow
|
||||
label: SiliconFlow
|
||||
baseUrl: https://api.siliconflow.cn/v1
|
||||
|
||||
- name: volcengine
|
||||
label: Volcengine (ByteDance)
|
||||
baseUrl: https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
- name: kimi
|
||||
label: Kimi (Moonshot)
|
||||
baseUrl: https://api.moonshot.cn/v1
|
||||
|
||||
- name: glm
|
||||
label: GLM (Zhipu AI)
|
||||
baseUrl: https://open.bigmodel.cn/api/paas/v4
|
||||
|
||||
- name: glm-intl
|
||||
label: GLM (Zhipu AI Intl)
|
||||
baseUrl: https://api.z.ai/api/paas/v4
|
||||
|
||||
- name: stepfun
|
||||
label: StepFun
|
||||
baseUrl: https://api.stepfun.com/v1
|
||||
|
||||
- name: minimax
|
||||
label: MiniMax
|
||||
baseUrl: https://api.minimax.io/v1
|
||||
|
||||
- name: tencent
|
||||
label: Tencent TokenHub
|
||||
baseUrl: https://tokenhub.tencentmaas.com/v1
|
||||
|
||||
- name: xiaomi
|
||||
label: Xiaomi MiMo
|
||||
baseUrl: https://api.xiaomimimo.com/v1
|
||||
|
||||
# ── Local ──────────────────────────────────────────────────
|
||||
|
||||
- name: ollama
|
||||
label: Ollama (local)
|
||||
baseUrl: http://localhost:11434/v1
|
||||
@@ -0,0 +1,103 @@
|
||||
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
splitProviderModelRef,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { cmdInitWorkspace } from "../init/index.js";
|
||||
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
): Result<WorkflowConfig, string> {
|
||||
const modelSplit = splitProviderModelRef(input.defaultModel);
|
||||
if (!modelSplit.ok) {
|
||||
return err(modelSplit.error);
|
||||
}
|
||||
if (modelSplit.value.providerName !== input.provider) {
|
||||
return err(
|
||||
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxDepth = prev === null ? 3 : prev.maxDepth;
|
||||
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
|
||||
const providers = {
|
||||
...(prev === null ? {} : prev.providers),
|
||||
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
|
||||
};
|
||||
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
|
||||
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdSetup(
|
||||
storageRoot: string,
|
||||
input: SetupCliArgs,
|
||||
): Promise<Result<CmdSetupSuccess, string>> {
|
||||
const readResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!readResult.ok) {
|
||||
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
|
||||
return err(readResult.error.message);
|
||||
}
|
||||
|
||||
const current = readResult.value;
|
||||
const merged = mergeWorkflowConfig(current.config, input);
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
const nextConfig = merged.value;
|
||||
const nextRegistry = {
|
||||
config: nextConfig,
|
||||
workflows: current.workflows,
|
||||
};
|
||||
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
if (!written.ok) {
|
||||
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
|
||||
return err(written.error.message);
|
||||
}
|
||||
|
||||
const registryPath = workflowRegistryPath(storageRoot);
|
||||
|
||||
let initWorkspaceRootPath: string | null = null;
|
||||
if (input.initWorkspaceName !== null) {
|
||||
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
|
||||
if (!initResult.ok) {
|
||||
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
|
||||
return err(initResult.error);
|
||||
}
|
||||
initWorkspaceRootPath = initResult.value.rootPath;
|
||||
}
|
||||
|
||||
return ok({
|
||||
registryPath,
|
||||
provider: input.provider,
|
||||
defaultModel: input.defaultModel,
|
||||
maxDepth: nextConfig.maxDepth,
|
||||
supervisorInterval: nextConfig.supervisorInterval,
|
||||
initWorkspaceRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function printSetupSummary(result: CmdSetupSuccess): void {
|
||||
printCliLine(`wrote registry: ${result.registryPath}`);
|
||||
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
|
||||
printCliLine(`config.models.default = "${result.defaultModel}"`);
|
||||
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
|
||||
if (result.initWorkspaceRootPath !== null) {
|
||||
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
|
||||
export type SetupCliArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
initWorkspaceName: string | null;
|
||||
};
|
||||
|
||||
export type PresetProvider = {
|
||||
name: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export type CmdSetupSuccess = {
|
||||
registryPath: string;
|
||||
provider: string;
|
||||
defaultModel: string;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
initWorkspaceRootPath: string | null;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
@@ -6,6 +6,21 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
async function readParentStateFromStartNode(
|
||||
cas: { get(hash: string): Promise<string | null> },
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.parentState;
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
@@ -19,7 +34,15 @@ export async function cmdThreadShow(
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
|
||||
const parentState = await readParentStateFromStartNode(cas, resolved.start);
|
||||
|
||||
const steps: Array<{
|
||||
role: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
childThread: string | null;
|
||||
}> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
@@ -33,6 +56,7 @@ export async function cmdThreadShow(
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||
childThread: fr.payload.childThread,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +65,7 @@ export async function cmdThreadShow(
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
parentState,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
@@ -182,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||
|
||||
\`\`\`typescript
|
||||
// Required exports
|
||||
// Required named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowRun;
|
||||
export const run: WorkflowFn;
|
||||
\`\`\`
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
|
||||
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
|
||||
roles: Record<string, {
|
||||
description: string;
|
||||
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
|
||||
}>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
from: string; // role name, or "__start__"
|
||||
to: string; // role name, or "__end__"
|
||||
condition: string; // e.g. "FALLBACK"
|
||||
conditionDescription?: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
|
||||
|
||||
## WorkflowFn
|
||||
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
|
||||
## ModeratorTable
|
||||
|
||||
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
|
||||
|
||||
\`\`\`typescript
|
||||
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
const table: ModeratorTable<MyMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
|
||||
firstRole: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## AdapterFn / AdapterBinding
|
||||
|
||||
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
|
||||
|
||||
\`\`\`typescript
|
||||
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -229,15 +258,16 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + ModeratorTable + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + definition)
|
||||
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
# 4. Build the ESM bundle
|
||||
bun run bundle # uses scripts/bundle.ts
|
||||
|
||||
# 4. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||
# 5. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
|
||||
|
||||
# 5. Test
|
||||
# 6. Test
|
||||
uncaged-workflow run my-workflow --prompt "test task"
|
||||
uncaged-workflow live --latest
|
||||
\`\`\`
|
||||
@@ -245,5 +275,46 @@ uncaged-workflow live --latest
|
||||
## Versioning
|
||||
|
||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Lazy initialization is mandatory
|
||||
|
||||
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
|
||||
|
||||
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
|
||||
|
||||
\`\`\`typescript
|
||||
// ❌ WRONG — breaks register
|
||||
const provider = { apiKey: process.env.MY_KEY! };
|
||||
const adapter = createAdapter(provider);
|
||||
|
||||
// ✅ CORRECT — only reads env when run() is called
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: Provider | null = null;
|
||||
return (prompt, schema) => {
|
||||
return async (ctx, runtime) => {
|
||||
if (!cached) cached = { apiKey: process.env.MY_KEY! };
|
||||
// ... use cached provider
|
||||
};
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Bundle import restrictions
|
||||
|
||||
The bundle validator only allows these import specifiers:
|
||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||
- \`@uncaged/workflow-*\` packages
|
||||
|
||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
||||
|
||||
### No default exports
|
||||
|
||||
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
|
||||
|
||||
### Single-file ESM
|
||||
|
||||
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config with explicit workspace", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
@@ -14,6 +15,7 @@ describe("validateCursorAgentConfig", () => {
|
||||
|
||||
test("accepts valid config with null workspace and llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
@@ -22,8 +24,23 @@ describe("validateCursorAgentConfig", () => {
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects empty workspace string", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
@@ -37,6 +54,7 @@ describe("validateCursorAgentConfig", () => {
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
@@ -50,6 +68,7 @@ describe("validateCursorAgentConfig", () => {
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
@@ -60,8 +79,9 @@ describe("validateCursorAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn with explicit workspace", () => {
|
||||
test("returns an AdapterFn with explicit workspace", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
@@ -70,8 +90,9 @@ describe("createCursorAgent", () => {
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AgentFn with null workspace and llmProvider", () => {
|
||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
@@ -80,25 +101,25 @@ describe("createCursorAgent", () => {
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
}),
|
||||
).toThrow();
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("throws when null workspace without llmProvider", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
}),
|
||||
).toThrow();
|
||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -14,5 +18,11 @@
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import { extractWorkspacePath } from "./extract-workspace.js";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
@@ -29,17 +34,17 @@ function resolveCursorModel(model: string | null): string {
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return async (ctx) => {
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
let workspace: string;
|
||||
|
||||
if (config.workspace !== null) {
|
||||
@@ -48,7 +53,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
|
||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
||||
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
|
||||
if (extracted === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
||||
@@ -58,7 +64,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
}
|
||||
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
@@ -71,7 +78,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: workspace,
|
||||
timeoutMs,
|
||||
});
|
||||
@@ -79,5 +86,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
if (config.workspace !== null && config.workspace.length === 0) {
|
||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
||||
}
|
||||
|
||||
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
|
||||
describe("validateHermesAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: -5,
|
||||
});
|
||||
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createHermesAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
|
||||
const agent = createHermesAgent({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: -5,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -10,5 +14,11 @@
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
@@ -25,16 +30,17 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
@@ -47,7 +53,7 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
if (config.model !== null) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
const run = await spawnCli("hermes", args, {
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: null,
|
||||
timeoutMs,
|
||||
});
|
||||
@@ -55,5 +61,5 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type HermesAgentConfig = {
|
||||
/** Absolute path to the hermes CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number | null;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
export function validateHermesAgentConfig(config: HermesAgentConfig): Result<void, string> {
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the hermes CLI binary");
|
||||
}
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
}
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
type ExtractFn,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
function makeCtx(userContent: string): AgentContext {
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: userContent,
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState: null,
|
||||
},
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
};
|
||||
}
|
||||
|
||||
const testSchema = z.object({ summary: z.string() });
|
||||
|
||||
function makeRuntime(): WorkflowRuntime {
|
||||
let stored = "";
|
||||
const cas: CasStore = {
|
||||
put: async (content: string) => {
|
||||
stored = content;
|
||||
return "HASH001";
|
||||
},
|
||||
get: async () => stored,
|
||||
delete: async () => {},
|
||||
list: async () => [],
|
||||
};
|
||||
const extract: ExtractFn = async (_schema, _contentHash) => ({
|
||||
meta: { summary: "extracted" },
|
||||
contentPayload: stored,
|
||||
refs: [],
|
||||
});
|
||||
return { cas, extract };
|
||||
}
|
||||
|
||||
describe("createLlmAdapter", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("posts system + user (start.content) and returns assistant text", async () => {
|
||||
test("posts system + user (start.content) and returns typed meta with childThread: null", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
|
||||
@@ -33,11 +61,13 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const out = await adapter(makeCtx("trigger text"));
|
||||
const roleFn = adapter("system instructions", testSchema);
|
||||
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(out).toBe("model reply");
|
||||
expect(result.meta).toEqual({ summary: "extracted" });
|
||||
expect(result.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("throws on non-ok fetch response", async () => {
|
||||
@@ -51,8 +81,9 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const roleFn = adapter("system", testSchema);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
|
||||
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
@@ -61,8 +92,9 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const roleFn = adapter("system", testSchema);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
|
||||
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,6 +12,16 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
err,
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
@@ -97,13 +91,13 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
return async (ctx: AgentContext) => {
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
{ role: "system", content: ctx.currentRole.systemPrompt },
|
||||
{ role: "system", content: prompt },
|
||||
{ role: "user", content: ctx.start.content },
|
||||
],
|
||||
});
|
||||
@@ -111,5 +105,5 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ok, START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
|
||||
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createReactAdapter } from "../src/create-react-adapter.js";
|
||||
import type { ReactAdapterConfig } from "../src/types.js";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeThread(prompt: string): ThreadContext {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const STUB_RUNTIME: WorkflowRuntime = {
|
||||
cas: {
|
||||
put: async (_content: string) => "STUBHASH",
|
||||
get: async (_hash: string) => null,
|
||||
delete: async (_hash: string) => {},
|
||||
list: async () => [],
|
||||
},
|
||||
extract: async (_schema, _contentHash) => ({
|
||||
meta: {},
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const TEST_SCHEMA = z
|
||||
.object({
|
||||
summary: z.string(),
|
||||
score: z.number(),
|
||||
})
|
||||
.meta({ title: "resolve", description: "Submit the final result." });
|
||||
|
||||
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
|
||||
const message: Record<string, unknown> = { role: "assistant" };
|
||||
if (content !== null) {
|
||||
message.content = content;
|
||||
}
|
||||
if (toolCalls !== null) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
return JSON.stringify({ choices: [{ message }] });
|
||||
}
|
||||
|
||||
function makeToolCallResponse(name: string, args: Record<string, unknown>, id: string): string {
|
||||
return makeChatResponse(null, [
|
||||
{
|
||||
id,
|
||||
type: "function",
|
||||
function: { name, arguments: JSON.stringify(args) },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("createReactAdapter", () => {
|
||||
test("direct resolve: LLM immediately calls resolve tool with valid args", async () => {
|
||||
const llm: LlmFn = async (_input) => {
|
||||
return ok(makeToolCallResponse("resolve", { summary: "done", score: 42 }, "call_1"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "done", score: 42 });
|
||||
expect(result.childThread).toBeNull();
|
||||
});
|
||||
|
||||
test("tool call then resolve: LLM calls user tool first, then resolves", async () => {
|
||||
let callCount = 0;
|
||||
const llm: LlmFn = async (_input) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return ok(makeToolCallResponse("search", { query: "test" }, "call_1"));
|
||||
}
|
||||
return ok(makeToolCallResponse("resolve", { summary: "found it", score: 99 }, "call_2"));
|
||||
};
|
||||
|
||||
const searchTool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
description: "Search for information",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const toolResults: string[] = [];
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [searchTool],
|
||||
toolHandler: async (name, args) => {
|
||||
toolResults.push(`${name}:${args}`);
|
||||
return "search result: found the answer";
|
||||
},
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "found it", score: 99 });
|
||||
expect(toolResults).toHaveLength(1);
|
||||
expect(toolResults[0]).toContain("search:");
|
||||
});
|
||||
|
||||
test("plain JSON response accepted", async () => {
|
||||
const llm: LlmFn = async (_input) => {
|
||||
return ok(makeChatResponse(JSON.stringify({ summary: "plain", score: 7 }), null));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "plain", score: 7 });
|
||||
});
|
||||
|
||||
test("schema validation failure + retry: invalid args then valid args", async () => {
|
||||
let callCount = 0;
|
||||
const llm: LlmFn = async (_input) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
// Invalid: score should be number, not string
|
||||
return ok(
|
||||
makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"),
|
||||
);
|
||||
}
|
||||
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [],
|
||||
toolHandler: async () => "unused",
|
||||
maxRounds: 5,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
|
||||
|
||||
expect(result.meta).toEqual({ summary: "fixed", score: 10 });
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
test("max rounds exceeded: throws error", async () => {
|
||||
const searchTool: ToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
description: "Search",
|
||||
parameters: { type: "object", properties: {}, required: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const llm: LlmFn = async (_input) => {
|
||||
// Always call search, never resolve
|
||||
return ok(makeToolCallResponse("search", {}, "call_n"));
|
||||
};
|
||||
|
||||
const config: ReactAdapterConfig = {
|
||||
llm,
|
||||
tools: [searchTool],
|
||||
toolHandler: async () => "still searching...",
|
||||
maxRounds: 3,
|
||||
};
|
||||
|
||||
const adapter = createReactAdapter(config);
|
||||
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
|
||||
|
||||
await expect(roleFn(makeThread("test task"), STUB_RUNTIME)).rejects.toThrow(
|
||||
"max_react_rounds_exceeded",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { patchFileTool, readFileTool, shellExecTool, writeFileTool } from "../src/tools/index.js";
|
||||
|
||||
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
|
||||
mkdirSync(TMP_DIR, { recursive: true });
|
||||
|
||||
const tmpFile = (name: string) => join(TMP_DIR, name);
|
||||
|
||||
const cleanupFiles: string[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
for (const f of cleanupFiles) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(TMP_DIR);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
describe("read_file", () => {
|
||||
test("reads file with line numbers", async () => {
|
||||
const p = tmpFile("read-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
const content = "line1\nline2\nline3\n";
|
||||
require("node:fs").writeFileSync(p, content);
|
||||
|
||||
const result = await readFileTool.handler(
|
||||
JSON.stringify({ path: p, offset: null, limit: null }),
|
||||
);
|
||||
expect(result).toContain("1|line1");
|
||||
expect(result).toContain("2|line2");
|
||||
expect(result).toContain("3|line3");
|
||||
});
|
||||
|
||||
test("reads with offset and limit", async () => {
|
||||
const p = tmpFile("read-test2.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "a\nb\nc\nd\ne\n");
|
||||
|
||||
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: 2, limit: 2 }));
|
||||
expect(result).toBe("2|b\n3|c");
|
||||
});
|
||||
|
||||
test("returns error for missing file", async () => {
|
||||
const result = await readFileTool.handler(
|
||||
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
|
||||
);
|
||||
expect(result).toContain("Error:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("write_file", () => {
|
||||
test("writes file and creates dirs", async () => {
|
||||
const p = tmpFile("sub/write-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
|
||||
const result = await writeFileTool.handler(JSON.stringify({ path: p, content: "hello world" }));
|
||||
expect(result).toContain("11 bytes");
|
||||
expect(readFileSync(p, "utf-8")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch_file", () => {
|
||||
test("patches file content", async () => {
|
||||
const p = tmpFile("patch-test.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "foo bar baz");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }),
|
||||
);
|
||||
expect(result).toContain("Successfully");
|
||||
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
|
||||
});
|
||||
|
||||
test("errors on not found", async () => {
|
||||
const p = tmpFile("patch-test2.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "foo");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
|
||||
);
|
||||
expect(result).toContain("not found");
|
||||
});
|
||||
|
||||
test("errors on non-unique match", async () => {
|
||||
const p = tmpFile("patch-test3.txt");
|
||||
cleanupFiles.push(p);
|
||||
require("node:fs").writeFileSync(p, "aaa bbb aaa");
|
||||
|
||||
const result = await patchFileTool.handler(
|
||||
JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }),
|
||||
);
|
||||
expect(result).toContain("not unique");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shell_exec", () => {
|
||||
test("runs echo", async () => {
|
||||
const result = await shellExecTool.handler(
|
||||
JSON.stringify({ command: "echo hello", timeout: null }),
|
||||
);
|
||||
expect(result.trim()).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles timeout", async () => {
|
||||
const result = await shellExecTool.handler(JSON.stringify({ command: "sleep 10", timeout: 1 }));
|
||||
expect(result).toContain("timed out");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-react",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-reactor": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import { buildThreadInput } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { ReactAdapterConfig } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function readToolName(parametersSchema: Record<string, unknown>): string {
|
||||
const title = parametersSchema.title;
|
||||
if (typeof title === "string" && title.trim().length > 0) {
|
||||
return title.trim();
|
||||
}
|
||||
return "resolve";
|
||||
}
|
||||
|
||||
function readToolDescription(parametersSchema: Record<string, unknown>): string {
|
||||
const d = parametersSchema.description;
|
||||
if (typeof d === "string" && d.trim().length > 0) {
|
||||
return d.trim();
|
||||
}
|
||||
return "Submit the final structured result.";
|
||||
}
|
||||
|
||||
export function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: config.llm,
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => {
|
||||
const rawJsonSchema = z.toJSONSchema(s) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
const name = readToolName(parameters);
|
||||
return {
|
||||
name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name,
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (_name) => prompt,
|
||||
toolHandler: async (call, _thread) => {
|
||||
return config.toolHandler(call.function.name, call.function.arguments);
|
||||
},
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const input = await buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return { meta: result.value, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { createReactAdapter } from "./create-react-adapter.js";
|
||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
import { patchFileTool } from "./patch-file.js";
|
||||
import { readFileTool } from "./read-file.js";
|
||||
import { shellExecTool } from "./shell-exec.js";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
import { writeFileTool } from "./write-file.js";
|
||||
|
||||
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
|
||||
|
||||
export const defaultTools: readonly ToolDefinition[] = ALL_TOOLS.map((t) => t.definition);
|
||||
|
||||
export async function defaultToolHandler(name: string, args: string): Promise<string> {
|
||||
const entry = ALL_TOOLS.find((t) => t.definition.function.name === name);
|
||||
if (!entry) return `Unknown tool: ${name}`;
|
||||
return entry.handler(args);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { defaultToolHandler, defaultTools } from "./defaults.js";
|
||||
export { patchFileTool } from "./patch-file.js";
|
||||
export { readFileTool } from "./read-file.js";
|
||||
export { shellExecTool } from "./shell-exec.js";
|
||||
export type { ToolEntry, ToolHandler } from "./types.js";
|
||||
export { writeFileTool } from "./write-file.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const patchFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "patch_file",
|
||||
description: "Find and replace a string in a file (first occurrence only).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file" },
|
||||
old_string: { type: "string", description: "Text to find" },
|
||||
new_string: { type: "string", description: "Replacement text" },
|
||||
},
|
||||
required: ["path", "old_string", "new_string"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { path: string; old_string: string; new_string: string };
|
||||
const content = await readFile(parsed.path, "utf-8");
|
||||
const firstIdx = content.indexOf(parsed.old_string);
|
||||
if (firstIdx === -1) {
|
||||
return `Error: old_string not found in ${parsed.path}`;
|
||||
}
|
||||
const secondIdx = content.indexOf(parsed.old_string, firstIdx + 1);
|
||||
if (secondIdx !== -1) {
|
||||
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
|
||||
}
|
||||
const updated =
|
||||
content.slice(0, firstIdx) +
|
||||
parsed.new_string +
|
||||
content.slice(firstIdx + parsed.old_string.length);
|
||||
await writeFile(parsed.path, updated);
|
||||
return `Successfully patched ${parsed.path}`;
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const readFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a text file and return lines with line numbers.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to read" },
|
||||
offset: {
|
||||
type: ["number", "null"],
|
||||
description: "Start line number (1-indexed, default: 1)",
|
||||
},
|
||||
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as {
|
||||
path: string;
|
||||
offset: number | null;
|
||||
limit: number | null;
|
||||
};
|
||||
const content = await readFile(parsed.path, "utf-8");
|
||||
const allLines = content.split("\n");
|
||||
const offset = parsed.offset ?? 1;
|
||||
const start = Math.max(0, offset - 1);
|
||||
const end =
|
||||
parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
|
||||
const lines = allLines.slice(start, end);
|
||||
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
const MAX_OUTPUT = 10000;
|
||||
|
||||
function truncate(text: string): string {
|
||||
return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n...(truncated)` : text;
|
||||
}
|
||||
|
||||
function classifyExecError(err: unknown): string {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"status" in err &&
|
||||
(err as { status: unknown }).status === null
|
||||
) {
|
||||
return "Error: command timed out";
|
||||
}
|
||||
if (err && typeof err === "object" && "stderr" in err) {
|
||||
const e = err as { stderr: string; stdout: string; status: number };
|
||||
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
|
||||
return truncate(combined) || `Error: command exited with status ${e.status}`;
|
||||
}
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
export const shellExecTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "shell_exec",
|
||||
description: "Execute a shell command and return stdout + stderr.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string", description: "Shell command to run" },
|
||||
timeout: { type: ["number", "null"], description: "Timeout in seconds (default: 30)" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { command: string; timeout: number | null };
|
||||
const timeoutMs = (parsed.timeout ?? 30) * 1000;
|
||||
const output = execSync(parsed.command, {
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: MAX_OUTPUT * 2,
|
||||
});
|
||||
return truncate(output);
|
||||
} catch (err: unknown) {
|
||||
return classifyExecError(err);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
export type ToolHandler = (args: string) => Promise<string>;
|
||||
|
||||
export type ToolEntry = {
|
||||
definition: ToolDefinition;
|
||||
handler: ToolHandler;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ToolEntry } from "./types.js";
|
||||
|
||||
export const writeFileTool: ToolEntry = {
|
||||
definition: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write_file",
|
||||
description: "Write content to a file, creating parent directories as needed.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to write" },
|
||||
content: { type: "string", description: "File content" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args: string): Promise<string> => {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as { path: string; content: string };
|
||||
await mkdir(dirname(parsed.path), { recursive: true });
|
||||
const buf = Buffer.from(parsed.content, "utf-8");
|
||||
await writeFile(parsed.path, buf);
|
||||
return `Successfully wrote ${buf.length} bytes to ${parsed.path}`;
|
||||
} catch (err) {
|
||||
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
export type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
export type ReactAdapterConfig = {
|
||||
llm: LlmFn;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
@@ -8,7 +12,7 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-dashboard",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,7 +13,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
||||
@@ -66,8 +66,13 @@ export type AgentEndpoint = {
|
||||
|
||||
export type WorkflowSummary = {
|
||||
name: string;
|
||||
currentHash: string;
|
||||
versions: number;
|
||||
hash: string | null;
|
||||
timestamp: number | null;
|
||||
};
|
||||
|
||||
export type WorkflowHistoryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ThreadSummary = {
|
||||
@@ -130,7 +135,7 @@ export type WorkflowDetail = {
|
||||
name: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
history: unknown[];
|
||||
history: readonly WorkflowHistoryEntry[];
|
||||
descriptor: WorkflowDescriptor | null;
|
||||
};
|
||||
|
||||
@@ -147,13 +152,16 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
|
||||
return fetchJson(agentBase(agent), "/workflows");
|
||||
}
|
||||
|
||||
export function getWorkflowDescriptor(
|
||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function getWorkflowDescriptor(
|
||||
agent: string,
|
||||
name: string,
|
||||
): Promise<WorkflowDescriptor | null> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`).then(
|
||||
(res) => res.descriptor,
|
||||
);
|
||||
const res = await getWorkflowDetail(agent, name);
|
||||
return res.descriptor;
|
||||
}
|
||||
|
||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
|
||||
@@ -70,7 +70,6 @@ export function LoginPage({ onLogin }: Props) {
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
|
||||
|
||||
@@ -56,11 +56,11 @@ function StartCard({ record }: { record: ThreadStartRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMessage({ record }: { record: RoleRecord }) {
|
||||
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
|
||||
const color = roleColor(record.role);
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-lg border text-sm"
|
||||
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -114,12 +114,17 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordCard({ record }: { record: ThreadRecord }) {
|
||||
type RecordCardProps = {
|
||||
record: ThreadRecord;
|
||||
highlighted: boolean;
|
||||
};
|
||||
|
||||
export function RecordCard({ record, highlighted }: RecordCardProps) {
|
||||
switch (record.type) {
|
||||
case "thread-start":
|
||||
return <StartCard record={record} />;
|
||||
case "role":
|
||||
return <RoleMessage record={record} />;
|
||||
return <RoleMessage record={record} highlighted={highlighted} />;
|
||||
case "workflow-result":
|
||||
return <ResultCard record={record} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getThread,
|
||||
getWorkflowDescriptor,
|
||||
@@ -26,51 +26,6 @@ function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type GraphPanelProps = {
|
||||
descriptor: WorkflowDescriptor;
|
||||
workflowName: string | null;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
};
|
||||
|
||||
function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const edgeCount = descriptor.graph.edges.length;
|
||||
return (
|
||||
<div
|
||||
className="mb-4 rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{open ? "▼" : "▶"} Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ height: 300, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={nodeStates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
|
||||
const states = new Map<string, NodeState>();
|
||||
const roleRecords = records.filter(
|
||||
@@ -102,6 +57,9 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||
|
||||
const liveActive = sse.connected && !sse.completed;
|
||||
const records = liveActive
|
||||
@@ -121,6 +79,35 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||
|
||||
const firstIndexByRole = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i];
|
||||
if (r.type === "role" && !m.has(r.role)) {
|
||||
m.set(r.role, i);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [records]);
|
||||
|
||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
||||
const el = firstCardByRoleRef.current.get(roleName);
|
||||
if (el == null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(roleName);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||
useEffect(() => {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -193,24 +180,85 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<GraphPanel descriptor={descriptor} workflowName={workflowName} nodeStates={nodeStates} />
|
||||
)}
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 120px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{descriptor.graph.edges.length} edge
|
||||
{descriptor.graph.edges.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={nodeStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r, i) => (
|
||||
<RecordCard key={`${threadId}-${i}`} record={r} />
|
||||
))}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
<div className="flex-1 min-w-0">
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
else firstCardByRoleRef.current.delete(r.role);
|
||||
}}
|
||||
>
|
||||
<RecordCard record={r} highlighted={flash} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,46 @@ import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
} from "@xyflow/react";
|
||||
import type { ConditionEdgeData } from "./types.ts";
|
||||
|
||||
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
// Radius for feedback edge corners
|
||||
const FEEDBACK_RADIUS = 16;
|
||||
|
||||
/**
|
||||
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
|
||||
* The path goes: source right → arc → vertical up → arc → target right
|
||||
*/
|
||||
function feedbackPath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
): string {
|
||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||
const r = FEEDBACK_RADIUS;
|
||||
|
||||
// Start from source right side, go right, then up, then left to target right side
|
||||
const segments = [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
// Horizontal to the right
|
||||
`L ${rightX - r} ${sourceY}`,
|
||||
// Arc turning upward
|
||||
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
|
||||
// Vertical upward
|
||||
`L ${rightX} ${targetY + r}`,
|
||||
// Arc turning left
|
||||
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
|
||||
// Horizontal left to target
|
||||
`L ${targetX} ${targetY}`,
|
||||
];
|
||||
|
||||
return segments.join(" ");
|
||||
}
|
||||
|
||||
export function ConditionEdge(props: EdgeProps) {
|
||||
const {
|
||||
id,
|
||||
@@ -24,28 +59,41 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
const edgeData = data as ConditionEdgeData | undefined;
|
||||
const isFallback = edgeData?.isFallback ?? false;
|
||||
const isSelfLoop = source === target;
|
||||
const isFeedback = edgeData?.isFeedback ?? false;
|
||||
|
||||
const [path, labelX, labelY] = isSelfLoop
|
||||
? getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
})
|
||||
: getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
let path: string;
|
||||
let defaultLabelX: number;
|
||||
let defaultLabelY: number;
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
|
||||
if (isFeedback) {
|
||||
// Custom feedback path routed to the right
|
||||
path = feedbackPath(sourceX, sourceY, targetX, targetY);
|
||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
||||
defaultLabelX = rightX;
|
||||
defaultLabelY = (sourceY + targetY) / 2;
|
||||
} else {
|
||||
const result = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: isSelfLoop ? 20 : 8,
|
||||
offset: isSelfLoop ? 50 : undefined,
|
||||
});
|
||||
path = result[0];
|
||||
defaultLabelX = result[1];
|
||||
defaultLabelY = result[2];
|
||||
}
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||
const label = edgeData?.condition ?? "";
|
||||
|
||||
// Use pre-computed label position if available, otherwise fall back to default
|
||||
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||
const labelY = edgeData?.labelY ?? defaultLabelY;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,19 +103,21 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||
/>
|
||||
{edgeData && !isFallback && edgeData.condition !== "" && (
|
||||
{label !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-bg)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
||||
whiteSpace: "nowrap",
|
||||
zIndex: 10,
|
||||
}}
|
||||
title={edgeData.conditionDescription ?? undefined}
|
||||
title={edgeData?.conditionDescription ?? undefined}
|
||||
>
|
||||
{edgeData.condition}
|
||||
{label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
|
||||
@@ -21,6 +21,10 @@ export type ConditionEdgeData = {
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
isFallback: boolean;
|
||||
isFeedback: boolean;
|
||||
isSelfLoop: boolean;
|
||||
labelX: number | null;
|
||||
labelY: number | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
|
||||
const ROLE_NODE_HEIGHT = 60;
|
||||
const TERMINAL_NODE_SIZE = 40;
|
||||
|
||||
// Vertical gap between nodes in the spine
|
||||
const LAYER_GAP = 80;
|
||||
// Horizontal offset for feedback (back) edges routed on the right side
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
|
||||
type LayoutInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
roles: Record<string, { description: string }>;
|
||||
@@ -21,15 +25,6 @@ type LayoutResult = {
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function nodeSize(id: string): { width: number; height: number } {
|
||||
if (id === START_ID || id === END_ID) {
|
||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
||||
@@ -37,6 +32,79 @@ function nodeSize(id: string): { width: number; height: number } {
|
||||
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
||||
}
|
||||
|
||||
function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the linear spine from the graph using topological ordering.
|
||||
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
|
||||
* Self-loops are neither forward nor feedback — they're handled separately.
|
||||
*/
|
||||
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
||||
// Collect all node IDs
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
|
||||
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
|
||||
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
|
||||
// or FALLBACK if no other option.
|
||||
const forwardAdj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const existing = forwardAdj.get(e.from) ?? [];
|
||||
existing.push(e.to);
|
||||
forwardAdj.set(e.from, existing);
|
||||
}
|
||||
|
||||
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
|
||||
const visited = new Set<string>();
|
||||
const spine: string[] = [];
|
||||
|
||||
// Build a set of "primary" next targets per node (non-FALLBACK first)
|
||||
const primaryNext = new Map<string, string>();
|
||||
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const list = edgesByFrom.get(e.from) ?? [];
|
||||
list.push(e);
|
||||
edgesByFrom.set(e.from, list);
|
||||
}
|
||||
|
||||
// For each node, the "primary" next is the first non-FALLBACK target,
|
||||
// or the FALLBACK target if all edges are FALLBACK
|
||||
for (const [from, edgeList] of edgesByFrom) {
|
||||
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
|
||||
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
|
||||
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
|
||||
}
|
||||
|
||||
// Walk the spine from __start__
|
||||
let current: string | null = START_ID;
|
||||
while (current !== null && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
spine.push(current);
|
||||
const next = primaryNext.get(current);
|
||||
if (next !== undefined && next !== "" && !visited.has(next)) {
|
||||
current = next;
|
||||
} else {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining nodes not on the main path (shouldn't normally happen)
|
||||
for (const id of ids) {
|
||||
if (!visited.has(id)) {
|
||||
spine.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return spine;
|
||||
}
|
||||
|
||||
function buildRoleNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
@@ -68,60 +136,95 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
function computeLayout(input: LayoutInput): LayoutResult {
|
||||
const spine = extractSpine(input.edges);
|
||||
const rank = new Map<string, number>();
|
||||
for (let i = 0; i < spine.length; i++) {
|
||||
rank.set(spine[i], i);
|
||||
}
|
||||
|
||||
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
},
|
||||
};
|
||||
// Position nodes along a vertical spine, centered horizontally
|
||||
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
let y = 0;
|
||||
for (const id of spine) {
|
||||
const size = nodeSize(id);
|
||||
// Center-align all nodes on the spine
|
||||
const x = centerX - size.width / 2;
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
y += size.height + LAYER_GAP;
|
||||
}
|
||||
|
||||
// Build nodes
|
||||
const nodes: Node[] = [];
|
||||
for (const id of spine) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges with label positions
|
||||
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
||||
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = rank.get(e.from) ?? 0;
|
||||
const targetRank = rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback) {
|
||||
// Label on the right side of the feedback arc
|
||||
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = rightX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
// Forward edge: label between source bottom and target top
|
||||
const midX = centerX;
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
labelX = midX;
|
||||
labelY = midY;
|
||||
}
|
||||
// Self-loop: let ReactFlow default handle it
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isSelfLoop,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
return useMemo(() => {
|
||||
const ids = collectNodeIds(input.edges);
|
||||
|
||||
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const id of ids) {
|
||||
const size = nodeSize(id);
|
||||
g.setNode(id, { width: size.width, height: size.height });
|
||||
}
|
||||
for (const e of input.edges) {
|
||||
if (e.from === e.to) {
|
||||
continue;
|
||||
}
|
||||
g.setEdge(e.from, e.to, {}, edgeKey(e));
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
const nodes: Node[] = [];
|
||||
for (const id of ids) {
|
||||
const dagNode = g.node(id);
|
||||
const size = nodeSize(id);
|
||||
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, pos, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, pos, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = input.edges.map(buildEdge);
|
||||
|
||||
return { nodes, edges };
|
||||
}, [input.edges, input.roles, input.nodeStates]);
|
||||
return useMemo(
|
||||
() => computeLayout(input),
|
||||
[input.edges, input.roles, input.nodeStates],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react";
|
||||
import {
|
||||
Background,
|
||||
type EdgeTypes,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type NodeTypes,
|
||||
type OnNodeClick,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||
@@ -12,6 +20,7 @@ type Props = {
|
||||
graph: WorkflowGraphData;
|
||||
roles: Record<string, { description: string }>;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
onNodeClick: ((roleName: string) => void) | null;
|
||||
};
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
@@ -23,9 +32,17 @@ const edgeTypes: EdgeTypes = {
|
||||
condition: ConditionEdge,
|
||||
};
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
|
||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
||||
if (node.type !== "role") return;
|
||||
onRoleClick(node.id);
|
||||
}
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
layout.edges.map((e) => ({
|
||||
@@ -46,8 +63,11 @@ export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
|
||||
edges={styledEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodeClick={onNodeClickHandler}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
|
||||
@@ -1,12 +1,174 @@
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkflowDetail } from "../api.ts";
|
||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
};
|
||||
|
||||
type DetailCacheEntry =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ok"; detail: WorkflowDetail };
|
||||
|
||||
function versionCount(detail: WorkflowDetail): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
function ExpandedWorkflowBody({
|
||||
cacheEntry,
|
||||
staticNodeStates,
|
||||
}: {
|
||||
cacheEntry: DetailCacheEntry | undefined;
|
||||
staticNodeStates: Map<string, NodeState>;
|
||||
}) {
|
||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading workflow details...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (cacheEntry.status === "error") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
||||
{cacheEntry.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { detail } = cacheEntry;
|
||||
const descriptor = detail.descriptor;
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const vc = versionCount(detail);
|
||||
|
||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pt-3 border-t flex gap-4"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasGraph ? (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden flex-1"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 600, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={staticNodeStates}
|
||||
onNodeClick={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowList({ agent }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setDetailsByName(new Map());
|
||||
}, [agent]);
|
||||
|
||||
const ensureDetailLoaded = useCallback(
|
||||
(name: string) => {
|
||||
setDetailsByName((prev) => {
|
||||
const cur = prev.get(name);
|
||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(prev).set(name, { status: "loading" });
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getWorkflowDetail(agent, name);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "ok", detail });
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "error", message });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[agent],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
const wasExpanded = expanded.has(name);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!wasExpanded) {
|
||||
ensureDetailLoaded(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
@@ -21,26 +183,58 @@ export function WorkflowList({ agent }: Props) {
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workflows.map((w) => (
|
||||
<div
|
||||
key={w.name}
|
||||
className="p-4 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{w.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{w.versions} version{w.versions !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
{workflows.map((w) => {
|
||||
const isOpen = expanded.has(w.name);
|
||||
return (
|
||||
<div
|
||||
key={w.name}
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
{w.currentHash}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(w.name)}
|
||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{isOpen ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="font-medium">{w.name}</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono truncate"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{w.hash !== null ? w.hash : "—"}
|
||||
</code>
|
||||
{w.timestamp !== null ? (
|
||||
<span
|
||||
className="text-xs mt-1 block"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Updated {new Date(w.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="px-4 pb-4">
|
||||
<ExpandedWorkflowBody
|
||||
cacheEntry={detailsByName.get(w.name)}
|
||||
staticNodeStates={staticNodeStates}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,3 +33,19 @@ body {
|
||||
.wf-node-pulse {
|
||||
animation: wf-node-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wf-record-card-highlight {
|
||||
0% {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
35% {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
100% {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-record-card-highlight {
|
||||
animation: wf-record-card-highlight 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
import type { ExecuteThreadOptions } from "../src/engine/types.js";
|
||||
|
||||
const TEST_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -299,7 +299,37 @@ async function driveWorkflowGenerator(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
const iterResult = await Promise.race([
|
||||
gen.next(),
|
||||
new Promise<never>((_, reject) => {
|
||||
if (executeOptions.signal.aborted) {
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
executeOptions.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("The operation was aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
]).catch((e) => {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
return { done: true as const, value: { returnCode: 130, summary: "thread aborted" } };
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (executeOptions.signal.aborted || (iterResult.done && iterResult.value.returnCode === 130)) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
logger,
|
||||
abortLogTag: "H4KQ7RW3",
|
||||
});
|
||||
}
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
@@ -499,6 +529,7 @@ export async function executeThread(
|
||||
content: input.prompt,
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
parentState: options.parentStateHash,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */
|
||||
/**
|
||||
* Absolute path to the worker script for spawning bundle worker processes.
|
||||
*
|
||||
* Uses `.js` extension so it resolves correctly whether running from
|
||||
* src/ (bun handles .js → .ts) or dist/ (compiled .js exists).
|
||||
*/
|
||||
export function getWorkerHostScriptPath(): string {
|
||||
return fileURLToPath(new URL("./worker.ts", import.meta.url));
|
||||
return fileURLToPath(new URL("./worker.js", import.meta.url));
|
||||
}
|
||||
|
||||
@@ -42,4 +42,7 @@ export {
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
|
||||
|
||||
/** @deprecated Use {@link workflowAdapter} instead. */
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowFn,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type * as z from "zod/v4";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAdapterMaxDepth(config: WorkflowConfig | null): number {
|
||||
return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAdapterOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveStorageRoot(options: WorkflowAdapterOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(
|
||||
storageRoot: string,
|
||||
ctx: ThreadContext,
|
||||
): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/** Resolve the workflow bundle and validate depth limits. */
|
||||
async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) {
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
throw new Error(`failed to read workflow registry: ${registryResult.error.message}`);
|
||||
}
|
||||
|
||||
const maxDepth = workflowAdapterMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`);
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
throw new Error(`workflow "${workflowName}" not found in registry`);
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
throw new Error(String(bundleExportsResult.error));
|
||||
}
|
||||
|
||||
return { entry, run: bundleExportsResult.value.run };
|
||||
}
|
||||
|
||||
/** Execute the child workflow thread and return a summary + root hash. */
|
||||
async function runChildThread(params: {
|
||||
workflowName: string;
|
||||
storageRoot: string;
|
||||
ctx: ThreadContext;
|
||||
run: WorkflowFn;
|
||||
bundleHash: string;
|
||||
nextDepth: number;
|
||||
}) {
|
||||
const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params;
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: bundleHash,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
const result = await executeThread(
|
||||
run,
|
||||
workflowName,
|
||||
{ prompt: ctx.start.content, steps: [] },
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`,
|
||||
rootHash: result.rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AdapterFn} that runs another registered workflow in a new child thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
*
|
||||
* The child thread's root hash is returned as `childThread` in the result,
|
||||
* enabling parent→child tracking in the CAS Merkle tree.
|
||||
*/
|
||||
export function workflowAdapter(
|
||||
workflowName: string,
|
||||
options: WorkflowAdapterOptions | null = null,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const storageRoot = resolveStorageRoot(options);
|
||||
const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1);
|
||||
|
||||
try {
|
||||
const { summary, rootHash } = await runChildThread({
|
||||
workflowName,
|
||||
storageRoot,
|
||||
ctx,
|
||||
run,
|
||||
bundleHash: entry.hash,
|
||||
nextDepth: ctx.depth + 1,
|
||||
});
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`child workflow "${workflowName}" failed: ${message}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,127 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow-register";
|
||||
import {
|
||||
extractBundleExports,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "@uncaged/workflow-util";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
|
||||
if (config === null) {
|
||||
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
|
||||
}
|
||||
return config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
|
||||
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
|
||||
const index = await readThreadsIndex(bundleDir);
|
||||
const entry = index[ctx.threadId] ?? null;
|
||||
return entry !== null ? entry.head : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
|
||||
* This module is kept for backward compatibility and will be removed in a future release.
|
||||
*/
|
||||
export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options: WorkflowAsAgentOptions | null = null,
|
||||
): AgentFn {
|
||||
return async (ctx: AgentContext): Promise<AgentFnResult> => {
|
||||
const nextDepth = ctx.depth + 1;
|
||||
|
||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
||||
}
|
||||
|
||||
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
return `ERROR: ${bundleExportsResult.error}`;
|
||||
}
|
||||
|
||||
const input = {
|
||||
prompt: ctx.start.content,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: entry.hash,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const signalNever = new AbortController();
|
||||
|
||||
const parentHeadState = await readParentHeadState(storageRoot, ctx);
|
||||
|
||||
try {
|
||||
const result = await executeThread(
|
||||
bundleExportsResult.value.run,
|
||||
workflowName,
|
||||
input,
|
||||
{
|
||||
depth: nextDepth,
|
||||
parentStateHash: parentHeadState,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
forkContinuation: null,
|
||||
replayTimestamps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
return { output: summary, childThread: result.rootHash };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
export {
|
||||
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
|
||||
workflowAdapter as workflowAsAgent,
|
||||
} from "./workflow-adapter.js";
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./ws-protocol": "./src/ws-protocol.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/** One Durable Object instance per agent name; holds the reverse WebSocket from the agent CLI. */
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
|
||||
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
||||
|
||||
type AgentSocketEnv = {
|
||||
GATEWAY_SECRET: string;
|
||||
};
|
||||
|
||||
export const AGENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/agent-socket/status";
|
||||
export const AGENT_SOCKET_INTERNAL_PROXY_PATH = "/internal/agent-socket/proxy";
|
||||
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
type PendingEntry = {
|
||||
resolve: (r: Response) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function wsResponseToHttp(wr: WsResponse): Response {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(wr.headers)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
return new Response(wr.body, { status: wr.status, headers });
|
||||
}
|
||||
|
||||
export class AgentSocket extends DurableObject<AgentSocketEnv> {
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
|
||||
private requireAuth(request: Request): Response | null {
|
||||
const auth = request.headers.get("Authorization");
|
||||
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
|
||||
return jsonResponse(401, { error: "unauthorized" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleStatusGet(request: Request): Response {
|
||||
const denied = this.requireAuth(request);
|
||||
if (denied !== null) {
|
||||
return denied;
|
||||
}
|
||||
const sockets = this.ctx.getWebSockets();
|
||||
const connected = sockets.length > 0;
|
||||
return new Response(JSON.stringify({ connected, connectedCount: sockets.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleProxyPost(request: Request): Promise<Response> {
|
||||
const denied = this.requireAuth(request);
|
||||
if (denied !== null) {
|
||||
return denied;
|
||||
}
|
||||
const raw = await request.text();
|
||||
const wsRequest = parseWsRequestJson(raw);
|
||||
if (wsRequest === null) {
|
||||
return jsonResponse(400, { error: "invalid proxy body" });
|
||||
}
|
||||
|
||||
const sockets = this.ctx.getWebSockets();
|
||||
const ws = sockets[0];
|
||||
if (ws === undefined) {
|
||||
return jsonResponse(503, { error: "no active websocket" });
|
||||
}
|
||||
|
||||
return await new Promise<Response>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(jsonResponse(504, { error: "gateway timeout" }));
|
||||
}, PROXY_TIMEOUT_MS);
|
||||
|
||||
this.pending.set(wsRequest.id, {
|
||||
resolve: (r: Response) => {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(r);
|
||||
},
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
ws.send(JSON.stringify(wsRequest));
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(wsRequest.id);
|
||||
resolve(jsonResponse(502, { error: "websocket send failed" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_STATUS_PATH && request.method === "GET") {
|
||||
return this.handleStatusGet(request);
|
||||
}
|
||||
|
||||
if (url.pathname === AGENT_SOCKET_INTERNAL_PROXY_PATH && request.method === "POST") {
|
||||
return this.handleProxyPost(request);
|
||||
}
|
||||
|
||||
if (request.headers.get("Upgrade") !== "websocket") {
|
||||
return new Response("expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
ws.close(1000, "replaced by new connection");
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
this.ctx.acceptWebSocket(server);
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
async webSocketMessage(_ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
||||
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
||||
const wr = parseWsResponseJson(text);
|
||||
if (wr === null) {
|
||||
return;
|
||||
}
|
||||
const entry = this.pending.get(wr.id);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(entry.timer);
|
||||
this.pending.delete(wr.id);
|
||||
entry.resolve(wsResponseToHttp(wr));
|
||||
}
|
||||
|
||||
async webSocketClose(
|
||||
_ws: WebSocket,
|
||||
_code: number,
|
||||
_reason: string,
|
||||
_wasClean: boolean,
|
||||
): Promise<void> {
|
||||
this.rejectAllPending("agent websocket closed");
|
||||
}
|
||||
|
||||
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {
|
||||
this.rejectAllPending("agent websocket error");
|
||||
}
|
||||
|
||||
private rejectAllPending(message: string): void {
|
||||
const entries = [...this.pending.values()];
|
||||
this.pending.clear();
|
||||
for (const entry of entries) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.resolve(jsonResponse(502, { error: message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import {
|
||||
AGENT_SOCKET_INTERNAL_PROXY_PATH,
|
||||
AGENT_SOCKET_INTERNAL_STATUS_PATH,
|
||||
AgentSocket,
|
||||
} from "./agent-socket.js";
|
||||
import type { WsRequest } from "./ws-protocol.js";
|
||||
|
||||
export { AgentSocket };
|
||||
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
AGENT_SOCKET: DurableObjectNamespace<AgentSocket>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,9 +43,165 @@ function checkDashboardAuth(c: {
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
}
|
||||
|
||||
function isLocalAgentUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(raw: Headers, agentToken: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of raw) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === "host" || lower === "authorization") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
lower === "connection" ||
|
||||
lower === "keep-alive" ||
|
||||
lower === "proxy-connection" ||
|
||||
lower === "transfer-encoding" ||
|
||||
lower === "upgrade"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
if (agentToken !== "") {
|
||||
out["X-Agent-Token"] = agentToken;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDashboardProxyHeaders(raw: Headers, token: string): Headers {
|
||||
const headers = new Headers(raw);
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization");
|
||||
if (token !== "") {
|
||||
headers.set("X-Agent-Token", token);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readBodyForWsProxy(method: string, req: Request): Promise<string | null> {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
const buf = await req.arrayBuffer();
|
||||
return buf.byteLength === 0 ? null : new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function fetchThroughAgentSocket(
|
||||
bindings: Env["Bindings"],
|
||||
agent: string,
|
||||
gateSecret: string,
|
||||
wsRequest: WsRequest,
|
||||
): Promise<Response> {
|
||||
const stub = bindings.AGENT_SOCKET.get(bindings.AGENT_SOCKET.idFromName(agent));
|
||||
return stub.fetch(
|
||||
new Request(`https://do.internal${AGENT_SOCKET_INTERNAL_PROXY_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gateSecret}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(wsRequest),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAgentWithRecordHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
forwardRecord: Record<string, string>,
|
||||
bodyStr: string | null,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(forwardRecord)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
return fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== "GET" && method !== "HEAD" ? (bodyStr ?? undefined) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentWithDashboardHeaders(
|
||||
targetUrl: string,
|
||||
method: string,
|
||||
headers: Headers,
|
||||
rawBody: BodyInit | null | undefined,
|
||||
): Promise<Response> {
|
||||
return fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== "GET" && method !== "HEAD" ? rawBody : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentSocketStatus(
|
||||
env: Env["Bindings"],
|
||||
name: string,
|
||||
): Promise<{ ok: true; connected: boolean } | { ok: false }> {
|
||||
try {
|
||||
const id = env.AGENT_SOCKET.idFromName(name);
|
||||
const stub = env.AGENT_SOCKET.get(id);
|
||||
const resp = await stub.fetch(
|
||||
new Request(`https://do${AGENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
||||
}),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return { ok: false };
|
||||
}
|
||||
const body = (await resp.json()) as { connected: boolean };
|
||||
return { ok: true, connected: body.connected };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean | null): string {
|
||||
if (doConnected === true) {
|
||||
return "online";
|
||||
}
|
||||
if (doConnected === false) {
|
||||
if (isLocalAgentUrl(record.url)) {
|
||||
return "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
return age < TTL_SECONDS * 1000 ? "online" : "offline";
|
||||
}
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
return age < TTL_SECONDS * 1000 ? "online" : "offline";
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Agent reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
app.get("/ws/connect", async (c) => {
|
||||
const secret = c.req.query("secret");
|
||||
const name = c.req.query("name");
|
||||
if (name === undefined || name === "") {
|
||||
return c.json({ error: "name required" }, 400);
|
||||
}
|
||||
if (secret !== c.env.GATEWAY_SECRET) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
if (c.req.header("Upgrade") !== "websocket") {
|
||||
return c.text("expected WebSocket upgrade", 426);
|
||||
}
|
||||
const id = c.env.AGENT_SOCKET.idFromName(name);
|
||||
const stub = c.env.AGENT_SOCKET.get(id);
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
||||
const gateway = new Hono<Env>();
|
||||
|
||||
@@ -95,11 +261,12 @@ gateway.get("/endpoints", async (c) => {
|
||||
for (const key of list.keys) {
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(key.name, "json");
|
||||
if (record) {
|
||||
const age = Date.now() - record.lastHeartbeat;
|
||||
const doStatus = await fetchAgentSocketStatus(c.env, record.name);
|
||||
const doConnected = doStatus.ok ? doStatus.connected : null;
|
||||
endpoints.push({
|
||||
name: record.name,
|
||||
url: record.url,
|
||||
status: age < TTL_SECONDS * 1000 ? "online" : "offline",
|
||||
status: endpointStatusFromKvAndDo(record, doConnected),
|
||||
lastHeartbeat: record.lastHeartbeat,
|
||||
});
|
||||
}
|
||||
@@ -110,7 +277,7 @@ gateway.get("/endpoints", async (c) => {
|
||||
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
|
||||
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent tunnel URL (dashboard auth) ──
|
||||
app.all("/api/agents/:agent/*", async (c) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
const agent = c.req.param("agent");
|
||||
@@ -120,26 +287,45 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
return c.json({ error: "agent not found" }, 404);
|
||||
}
|
||||
|
||||
// Build target URL: strip /api/:agent prefix, forward the rest
|
||||
const url = new URL(c.req.url);
|
||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
||||
const proxyPath = `/api${pathAfterAgent}${url.search}`;
|
||||
const method = c.req.method;
|
||||
const token = record.agentToken ?? "";
|
||||
const forwardRecord = buildForwardHeaders(c.req.raw.headers, token);
|
||||
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("Authorization"); // don't forward dashboard key to agent
|
||||
if (record.agentToken) {
|
||||
headers.set("X-Agent-Token", record.agentToken);
|
||||
const doStatus = await fetchAgentSocketStatus(c.env, agent);
|
||||
if (doStatus.ok && doStatus.connected) {
|
||||
const bodyStr = await readBodyForWsProxy(method, c.req.raw);
|
||||
const wsRequest: WsRequest = {
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
path: proxyPath,
|
||||
headers: forwardRecord,
|
||||
body: bodyStr,
|
||||
};
|
||||
const proxyResp = await fetchThroughAgentSocket(c.env, agent, c.env.GATEWAY_SECRET, wsRequest);
|
||||
if (proxyResp.status !== 503) {
|
||||
return new Response(proxyResp.body, {
|
||||
status: proxyResp.status,
|
||||
headers: proxyResp.headers,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const resp = await fetchAgentWithRecordHeaders(targetUrl, method, forwardRecord, bodyStr);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json({ error: "agent unreachable", detail: String(err) }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
|
||||
try {
|
||||
const resp = await fetch(targetUrl, {
|
||||
method: c.req.method,
|
||||
headers,
|
||||
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
|
||||
});
|
||||
|
||||
// Stream response back
|
||||
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
|
||||
return new Response(resp.body, {
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
@@ -149,4 +335,5 @@ app.all("/api/agents/:agent/*", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Cloudflare Workers entry expects default export
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Wire format for HTTP-over-WebSocket proxy between gateway Durable Object and local serve. */
|
||||
|
||||
export type WsRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
|
||||
export type WsResponse = {
|
||||
id: string;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/** Parse and validate a JSON payload as {@link WsRequest}. */
|
||||
export function parseWsRequestJson(raw: string): WsRequest | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const id = parsed.id;
|
||||
const method = parsed.method;
|
||||
const path = parsed.path;
|
||||
const headers = parsed.headers;
|
||||
const body = parsed.body;
|
||||
if (!isNonEmptyString(id) || !isNonEmptyString(method) || !isNonEmptyString(path)) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(headers)) {
|
||||
return null;
|
||||
}
|
||||
const headerRecord: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof v !== "string") {
|
||||
return null;
|
||||
}
|
||||
headerRecord[k] = v;
|
||||
}
|
||||
if (body !== null && typeof body !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { id, method, path, headers: headerRecord, body: body === null ? null : body };
|
||||
}
|
||||
|
||||
/** Parse and validate a JSON payload as {@link WsResponse}. */
|
||||
export function parseWsResponseJson(raw: string): WsResponse | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const id = parsed.id;
|
||||
const status = parsed.status;
|
||||
const headers = parsed.headers;
|
||||
const respBody = parsed.body;
|
||||
if (!isNonEmptyString(id) || typeof status !== "number" || !Number.isFinite(status)) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(headers)) {
|
||||
return null;
|
||||
}
|
||||
const headerRecord: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (typeof v !== "string") {
|
||||
return null;
|
||||
}
|
||||
headerRecord[k] = v;
|
||||
}
|
||||
if (typeof respBody !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { id, status: Math.trunc(status), headers: headerRecord, body: respBody };
|
||||
}
|
||||
@@ -6,4 +6,11 @@ compatibility_date = "2025-04-01"
|
||||
binding = "ENDPOINTS"
|
||||
id = "88b118d1cfab4c049f9c1684848811a3"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [{ name = "AGENT_SOCKET", class_name = "AgentSocket" }]
|
||||
|
||||
[[migrations]]
|
||||
tag = "add-agent-socket"
|
||||
new_sqlite_classes = ["AgentSocket"]
|
||||
|
||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
||||
|
||||
@@ -27,6 +27,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
|
||||
content: "test",
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
} as StartStep,
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-protocol",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./moderator-table.js": {
|
||||
"types": "./dist/moderator-table.d.ts",
|
||||
"import": "./src/moderator-table.ts"
|
||||
"import": "./dist/moderator-table.js"
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -9,11 +9,10 @@ export type {
|
||||
} from "./cas-types.js";
|
||||
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -27,8 +26,10 @@ export type {
|
||||
ResolvedModel,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -62,6 +62,7 @@ export type StartStep = {
|
||||
content: string;
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
parentState: string | null;
|
||||
};
|
||||
|
||||
export type RoleStep<M extends RoleMeta> = {
|
||||
@@ -142,13 +143,17 @@ export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
export type AgentFnResult = string | { output: string; childThread: string | null };
|
||||
// ── Adapter (replaces Agent) ────────────────────────────────────────
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
|
||||
export type RoleResult<T> = { meta: T; childThread: string | null };
|
||||
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
||||
|
||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
|
||||
// ── Workflow Runtime & Definition ──────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-reactor",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-register",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -37,13 +37,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
spec === "@uncaged/workflow" ||
|
||||
spec === "@uncaged/workflow-runtime" ||
|
||||
spec === "@uncaged/workflow-protocol" ||
|
||||
spec === "@uncaged/workflow-cas" ||
|
||||
spec === "@uncaged/workflow-util"
|
||||
) {
|
||||
if (spec.startsWith("@uncaged/workflow")) {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
@@ -114,7 +108,8 @@ function bindingInitializerIsCallable(init: Node): boolean {
|
||||
return (
|
||||
init.type === "FunctionExpression" ||
|
||||
init.type === "ArrowFunctionExpression" ||
|
||||
init.type === "CallExpression"
|
||||
init.type === "CallExpression" ||
|
||||
init.type === "Identifier"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -16,5 +20,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ async function threadFromStartHead<M extends RoleMeta>(
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: p.parentState,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
@@ -120,6 +121,7 @@ async function threadFromStateHead<M extends RoleMeta>(
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: firstTs,
|
||||
parentState: sp.parentState,
|
||||
},
|
||||
steps,
|
||||
};
|
||||
|
||||
@@ -3,11 +3,9 @@ import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js"
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import {
|
||||
type AdapterBinding,
|
||||
type AdapterFn,
|
||||
type AdvanceOutcome,
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
type AgentFnResult,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
@@ -39,7 +37,7 @@ function resolveExtractedRefs(
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const h of [...a, ...b]) {
|
||||
@@ -51,28 +49,18 @@ function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[]
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeAgentResult(result: AgentFnResult): {
|
||||
output: string;
|
||||
childThread: string | null;
|
||||
} {
|
||||
if (typeof result === "string") {
|
||||
return { output: result, childThread: null };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
function adapterForRole(binding: AdapterBinding, roleName: string): AdapterFn {
|
||||
const overrides = binding.overrides;
|
||||
const overrideFn: AgentFn | undefined =
|
||||
const overrideFn: AdapterFn | undefined =
|
||||
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
|
||||
return overrideFn !== undefined ? overrideFn : binding.agent;
|
||||
return overrideFn !== undefined ? overrideFn : binding.adapter;
|
||||
}
|
||||
|
||||
async function advanceOneRound<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles"> & {
|
||||
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
},
|
||||
binding: AgentBinding,
|
||||
binding: AdapterBinding,
|
||||
params: {
|
||||
thread: ModeratorContext<M>;
|
||||
runtime: WorkflowRuntime;
|
||||
@@ -94,37 +82,27 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
|
||||
}
|
||||
|
||||
const agentCtx: AgentContext<M> = {
|
||||
...modCtx,
|
||||
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||
};
|
||||
|
||||
const agent = agentForRole(binding, next);
|
||||
const agentResult = normalizeAgentResult(await agent(agentCtx as unknown as AgentContext));
|
||||
|
||||
const agentContentHash = await putContentNodeWithRefs(runtime.cas, agentResult.output, []);
|
||||
|
||||
const extracted = await runtime.extract(
|
||||
const adapter = adapterForRole(binding, next);
|
||||
const roleFn = adapter(
|
||||
roleDef.systemPrompt,
|
||||
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||
agentContentHash,
|
||||
);
|
||||
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
|
||||
const meta = result.meta;
|
||||
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extracted.meta,
|
||||
meta,
|
||||
);
|
||||
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
|
||||
|
||||
const contentHash =
|
||||
artifactRefs.length === 0
|
||||
? agentContentHash
|
||||
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
|
||||
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
|
||||
const contentPayload = JSON.stringify(meta);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
|
||||
const refs = refsFromMeta.length === 0 ? [contentHash] : [...refsFromMeta, contentHash];
|
||||
|
||||
const step = {
|
||||
role: next,
|
||||
contentHash,
|
||||
meta: extracted.meta,
|
||||
meta,
|
||||
refs,
|
||||
timestamp: Date.now(),
|
||||
} as RoleStep<M>;
|
||||
@@ -136,22 +114,22 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
childThread: agentResult.childThread,
|
||||
childThread: result.childThread,
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator table to runtime agents.
|
||||
* Binds pure role definitions + moderator table to an adapter.
|
||||
* Assign with `export const run = createWorkflow(def, binding)`.
|
||||
*
|
||||
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
|
||||
* engine resolves from the workflow registry's `extract` scene.
|
||||
* The adapter is responsible for returning typed meta directly — no separate
|
||||
* extract call is needed.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
|
||||
binding: AgentBinding,
|
||||
binding: AdapterBinding,
|
||||
): WorkflowFn {
|
||||
const pickNext = tableToModerator(def.table);
|
||||
const loopDef = { roles: def.roles, pickNext };
|
||||
|
||||
@@ -2,10 +2,9 @@ export { buildThreadContext } from "./build-context.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AgentBinding,
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -17,8 +16,10 @@ export type {
|
||||
ModeratorTransition,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
// imports from "@uncaged/workflow-runtime" continues to work.
|
||||
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
AgentFnResult,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -21,8 +20,10 @@ export type {
|
||||
ResolvedModel,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
||||
content: "Implement the feature",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,32 +5,20 @@
|
||||
*/
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnv(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const llmProvider = {
|
||||
baseUrl:
|
||||
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
|
||||
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
|
||||
baseUrl: optionalEnv(
|
||||
"WORKFLOW_LLM_BASE_URL",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
|
||||
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
|
||||
};
|
||||
|
||||
const agent = createCursorAgent({
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
|
||||
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
|
||||
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
|
||||
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
|
||||
@@ -39,7 +27,7 @@ const agent = createCursorAgent({
|
||||
llmProvider,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
|
||||
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = wf;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -7,12 +7,17 @@ import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
|
||||
import {
|
||||
type AdapterFn,
|
||||
createWorkflow,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleResult,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
type WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
|
||||
@@ -21,92 +26,13 @@ import type { SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const solveIssueModerator = tableToModerator(solveIssueTable);
|
||||
|
||||
function jsonResponse(payload: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
|
||||
return jsonResponse({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
});
|
||||
}
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return buildPlainJsonResponse(args);
|
||||
};
|
||||
globalThis.fetch = Object.assign(mockFetch, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolCallResponse(args: Record<string, unknown>): Response {
|
||||
return jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "tc_extract_1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function installMockToolCallCompletions(
|
||||
sequence: ReadonlyArray<Record<string, unknown>>,
|
||||
): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockToolCallCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return buildToolCallResponse(args);
|
||||
};
|
||||
globalThis.fetch = Object.assign(mockFetch, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
parentState: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,17 +93,6 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
||||
};
|
||||
}
|
||||
|
||||
function createStubExtract(casDir: string) {
|
||||
return createExtract(
|
||||
{
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "test",
|
||||
},
|
||||
{ cas: createCasStore(casDir) },
|
||||
);
|
||||
}
|
||||
|
||||
function makeThread(prompt: string) {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
@@ -188,11 +103,41 @@ function makeThread(prompt: string) {
|
||||
content: prompt,
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
parentState: null,
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that returns a fixed sequence of meta values. */
|
||||
function createSequenceAdapter(sequence: ReadonlyArray<Record<string, unknown>>): AdapterFn {
|
||||
let i = 0;
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const meta = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (meta === undefined) {
|
||||
throw new Error("createSequenceAdapter: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates an AdapterFn that tracks calls and returns fixed meta. */
|
||||
function createTrackingAdapter(
|
||||
name: string,
|
||||
calls: string[],
|
||||
meta: Record<string, unknown>,
|
||||
): AdapterFn {
|
||||
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
|
||||
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
calls.push(name);
|
||||
return { meta: meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes initial → preparer → developer → submitter → END", () => {
|
||||
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
|
||||
@@ -225,8 +170,6 @@ describe("solveIssueModerator", () => {
|
||||
});
|
||||
|
||||
test("returns END for any unexpected last step (defensive)", () => {
|
||||
// A submitter step with a pseudo-unknown future status would still be
|
||||
// routed to END, since the moderator is a closed switch over known roles.
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx([
|
||||
@@ -240,19 +183,16 @@ describe("solveIssueModerator", () => {
|
||||
});
|
||||
|
||||
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
let casDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
if (casDir !== undefined) {
|
||||
await rm(casDir, { recursive: true, force: true }).catch(() => {});
|
||||
casDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
test("structured extraction yields preparer meta from mocked chat completions", async () => {
|
||||
test("adapter yields preparer meta directly", async () => {
|
||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
@@ -264,18 +204,18 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
};
|
||||
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const adapter = createSequenceAdapter([EXPECT_PREPARER_META]);
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
agent: async () => "",
|
||||
overrides: { developer: async () => "stub-root-hash" },
|
||||
adapter,
|
||||
overrides: null,
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createStubExtract(casDir),
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
@@ -286,41 +226,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
||||
});
|
||||
|
||||
test("structured extraction also accepts tool_calls extraction path", async () => {
|
||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/home/user/repos/tool-call",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
};
|
||||
restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
agent: async () => "",
|
||||
overrides: { developer: async () => "stub-root-hash" },
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createStubExtract(casDir),
|
||||
});
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
if (first.done) {
|
||||
throw new Error("expected yield");
|
||||
}
|
||||
expect(first.value.role).toBe("preparer");
|
||||
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
||||
});
|
||||
|
||||
test("per-role agent overrides default", async () => {
|
||||
test("per-role adapter overrides default", async () => {
|
||||
const PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/tmp/r",
|
||||
defaultBranch: "main",
|
||||
@@ -337,35 +243,22 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
|
||||
status: "submitted",
|
||||
prUrl: "https://github.com/example/repo/pull/2",
|
||||
};
|
||||
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
agent: async () => {
|
||||
calls.push("default");
|
||||
return "";
|
||||
},
|
||||
adapter: createTrackingAdapter("default", calls, PREPARER_META),
|
||||
overrides: {
|
||||
preparer: async () => {
|
||||
calls.push("preparer");
|
||||
return "";
|
||||
},
|
||||
developer: async () => {
|
||||
calls.push("developer");
|
||||
return "stub-root-hash";
|
||||
},
|
||||
submitter: async () => {
|
||||
calls.push("submitter");
|
||||
return "";
|
||||
},
|
||||
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
|
||||
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
|
||||
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
|
||||
},
|
||||
});
|
||||
const gen = run(makeThread("task"), {
|
||||
cas,
|
||||
extract: createStubExtract(casDir),
|
||||
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
|
||||
});
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
|
||||
@@ -2,34 +2,25 @@
|
||||
* solve-issue bundle entry — 小橘 🍊
|
||||
*
|
||||
* preparer + submitter → hermes agent
|
||||
* developer → workflow-as-agent (delegates to "develop" workflow)
|
||||
* developer → workflow adapter (delegates to "develop" workflow)
|
||||
*/
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { workflowAsAgent } from "@uncaged/workflow-execute";
|
||||
import { workflowAdapter } from "@uncaged/workflow-execute";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { optionalEnv } from "@uncaged/workflow-util";
|
||||
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
function optionalEnv(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const hermesAgent = createHermesAgent({
|
||||
const adapter = createHermesAgent({
|
||||
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
|
||||
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
|
||||
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
|
||||
: null,
|
||||
});
|
||||
|
||||
const developerAgent = workflowAsAgent("develop");
|
||||
|
||||
const wf = createWorkflow(solveIssueWorkflowDefinition, {
|
||||
agent: hermesAgent,
|
||||
adapter,
|
||||
overrides: {
|
||||
developer: developerAgent,
|
||||
developer: workflowAdapter("develop"),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { buildAgentPrompt } from "../src/index.js";
|
||||
|
||||
function startTask(content: string): AgentContext["start"] {
|
||||
function startTask(content: string, parentState: string | null = null): AgentContext["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content,
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
parentState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +96,35 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("parentState null omits Parent Context section", async () => {
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("top-level task"),
|
||||
depth: 0,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).not.toContain("## Parent Context");
|
||||
});
|
||||
|
||||
test("parentState non-null includes Parent Context section with hash", async () => {
|
||||
const parentHash = "01PARENTSTATE0000000000001";
|
||||
const ctx: AgentContext = {
|
||||
start: startTask("child task", parentHash),
|
||||
depth: 1,
|
||||
bundleHash: "TESTHASH00001",
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
expect(text).toContain("## Parent Context");
|
||||
expect(text).toContain(parentHash);
|
||||
expect(text).toContain(`uncaged-workflow cas get ${parentHash}`);
|
||||
});
|
||||
|
||||
test("middle steps show meta summary only and latest shows hash", async () => {
|
||||
const ha = "01HASHA00000000000000000001";
|
||||
const hb = "01HASHB00000000000000000001";
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-util-agent",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.24",
|
||||
"files": [
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||
import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
/**
|
||||
* Builds a user-message string from thread context: task, previous steps, and tool hints.
|
||||
* Does NOT include a system prompt — that is passed separately via the adapter.
|
||||
*/
|
||||
export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
|
||||
if (ctx.start.parentState !== null) {
|
||||
lines.push("## Parent Context");
|
||||
lines.push(
|
||||
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
|
||||
ctx.start.parentState,
|
||||
);
|
||||
lines.push(
|
||||
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
|
||||
@@ -45,3 +59,12 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link buildThreadInput} instead. This wrapper prepends the system prompt
|
||||
* from `ctx.currentRole` for backward compatibility with existing agents.
|
||||
*/
|
||||
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
return `${ctx.currentRole.systemPrompt}\n\n${threadInput}`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user