Compare commits

..

1 Commits

Author SHA1 Message Date
xingyue f8835e0016 feat: WebSocket reverse-connection gateway Phase 1 (#210)
- Add AgentSocket Durable Object (holds one WS per agent name)
- Add /ws/connect route with GATEWAY_SECRET auth
- Add ws-client.ts with auto-reconnect (exponential backoff 1s-30s)
- serve defaults to WS mode (no cloudflared needed)
- Keep --tunnel-url and --no-tunnel as fallback options
- Endpoints list merges KV heartbeat + DO WebSocket status

Testing: #211
2026-05-13 11:05:02 +08:00
82 changed files with 866 additions and 2545 deletions
-1
View File
@@ -5,4 +5,3 @@ bun.lock
tsconfig.tsbuildinfo
.npmrc
bunfig.toml
+2
View File
@@ -0,0 +1,2 @@
[test]
pathIgnorePatterns = ["dist/**"]
-191
View File
@@ -1,191 +0,0 @@
# 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 |
-1
View File
@@ -18,7 +18,6 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
}
@@ -91,7 +91,7 @@ describe("init workspace", () => {
"RoleDefinition",
"WorkflowDefinition",
"ModeratorTable",
"AdapterFn",
"AgentFn",
"ExtractFn",
"RoleMeta",
]) {
@@ -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" };
+1 -7
View File
@@ -1,17 +1,11 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.18",
"files": [
"src",
"dist",
"package.json"
],
"version": "0.3.5",
"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 { 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(
@@ -90,7 +91,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/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -100,10 +101,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
@@ -111,7 +112,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)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -128,7 +128,6 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
localPort: options.port,
log,
});
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
@@ -1,11 +1,9 @@
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;
};
@@ -26,58 +24,6 @@ export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secre
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);
@@ -141,14 +87,15 @@ export function startGatewayWsClient(params: GatewayWsClientParams): () => void
});
ws.addEventListener("message", (ev) => {
const data = ev.data;
if (typeof data !== "string") {
params.log("T9W2KL5H", "gateway WebSocket non-text frame ignored");
return;
let preview: string;
if (typeof ev.data === "string") {
preview = ev.data;
} else if (ev.data instanceof ArrayBuffer) {
preview = `[binary ${String(ev.data.byteLength)} bytes]`;
} else {
preview = "[non-text message]";
}
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
});
params.log("3FHK5NDJ", `gateway → agent (phase 2 stub): ${preview.slice(0, 500)}`);
});
};
@@ -1,7 +1,7 @@
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 { resolve as resolvePath } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
@@ -10,7 +10,6 @@ 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";
@@ -155,67 +154,11 @@ async function promptLine(
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) => {
let buf = "";
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
@@ -223,22 +166,46 @@ async function promptSecret(label: string): Promise<string> {
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;
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
fulfill(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
buf += c;
process.stdout.write("*");
}
};
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`;
async function fetchAvailableModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
const url = baseUrl.replace(/\/+$/, "") + "/models";
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
@@ -261,158 +228,139 @@ async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<st
.filter((id) => !NON_CHAT_RE.test(id))
.sort();
} catch (e) {
setupDispatchLog(
"V8NQ4JT6",
`fetch models failed: ${e instanceof Error ? e.message : String(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 numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets[i]!;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const { provider, baseUrl } = providerResult.value;
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
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) {
rl.close();
return err(`invalid choice: ${choice}`);
}
let provider: string;
let baseUrl: string;
if (choiceNum <= presets.length) {
const selected = presets[choiceNum - 1]!;
provider = selected.name;
baseUrl = selected.baseUrl;
printCliLine(`\n → ${selected.label} (${baseUrl})\n`);
} else {
provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") {
return err("provider name must not be empty");
}
baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") {
return err("base URL must not be empty");
}
}
// Close readline before raw-mode secret prompt, reopen after.
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") return err("API key must not be empty");
if (apiKey === "") {
return err("API key must not be empty");
}
const rl2 = createInterface({ input, output });
// Try to list available models from the provider.
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
const modelResult = await selectModel(rl2, models);
if (!modelResult.ok) {
rl2.close();
return modelResult;
let selectedModel: string;
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
const cols = process.stdout.columns || 80;
const nw = String(models.length).length; // number width
// Each cell: " <num>) <model> " — prefix is 2 + nw + 2 = nw+4
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2; // +2 gap between columns
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);
cells.push(` ${num}) ${(models[j]!).padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl2, `Default model [1-${models.length}]: `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
selectedModel = models[modelNum - 1]!;
} else {
// Treat as a literal model name.
selectedModel = modelInput;
}
} else {
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl2, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
selectedModel = modelInput;
}
const bare = stripProviderPrefix(modelResult.value);
// Strip provider prefix if user included one (e.g. pasted "MiniMax/MiniMax-M2.7").
const bare = selectedModel.includes("/") ? selectedModel.split("/").pop()! : selectedModel;
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
const initWorkspaceName = await selectWorkspace(rl2);
let initWorkspaceName: string | null = null;
// Loop until a valid workspace path is provided or the user skips.
while (true) {
const wsPath = await promptLine(
rl2,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") {
break;
}
const candidate = wsPath === "" ? "./workflows" : wsPath;
// Validate path before passing to cmdSetup.
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;
}
initWorkspaceName = candidate;
break;
}
rl2.close();
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
return ok({
provider,
baseUrl,
apiKey,
defaultModel,
initWorkspaceName,
});
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
@@ -5,43 +5,45 @@ import { parse as parseYaml } from "yaml";
import type { PresetProvider } from "./types.js";
type RawPresetEntry = {
name: unknown;
label: unknown;
baseUrl: unknown;
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";
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;
if (cached !== null) return cached;
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
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}`);
}
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,
});
}
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;
cached = result;
return result;
}
@@ -13,6 +13,8 @@ import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
+16 -86
View File
@@ -183,63 +183,35 @@ 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 named exports (no default export)
// Required exports
export const descriptor: WorkflowDescriptor;
export const run: WorkflowFn;
export const run: WorkflowRun;
\`\`\`
## WorkflowDescriptor
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\`.
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
graph: {
edges: Array<{
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
from: string;
to: string;
condition: string;
conditionDescription: string | null;
}>;
};
};
\`\`\`
**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
## WorkflowRun
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
## 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;
};
\`\`\`
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## Role Definition
@@ -258,16 +230,15 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 2. Write your template (roles + ModeratorTable + descriptor)
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 3. Build the ESM bundle
bun run build
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 6. Test
# 5. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -275,46 +246,5 @@ 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.
`;
}
@@ -79,7 +79,7 @@ describe("validateCursorAgentConfig", () => {
});
describe("createCursorAgent", () => {
test("returns an AdapterFn with explicit workspace", () => {
test("returns an AgentFn with explicit workspace", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
@@ -90,7 +90,7 @@ describe("createCursorAgent", () => {
expect(typeof agent).toBe("function");
});
test("returns an AdapterFn with null workspace and llmProvider", () => {
test("returns an AgentFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
+1 -11
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -18,11 +14,5 @@
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
+7 -14
View File
@@ -1,11 +1,6 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { AgentFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
@@ -34,12 +29,12 @@ function resolveCursorModel(model: string | null): string {
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt) => {
return async (ctx) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
@@ -53,8 +48,7 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
const extracted = await extractWorkspacePath(ctx, 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.",
@@ -64,8 +58,7 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"-p",
fullPrompt,
@@ -86,5 +79,5 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
throwCursorSpawnError(run.error);
}
return run.value;
});
};
}
@@ -37,7 +37,7 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
+1 -11
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -14,11 +10,5 @@
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
+6 -12
View File
@@ -1,10 +1,5 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
@@ -30,17 +25,16 @@ function throwHermesSpawnError(error: SpawnCliError): never {
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt) => {
return async (ctx) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const fullPrompt = await buildAgentPrompt(ctx);
const args = [
"chat",
"-q",
@@ -61,5 +55,5 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
throwHermesSpawnError(run.error);
}
return run.value;
});
};
}
@@ -1,16 +1,9 @@
import { describe, expect, test } from "bun:test";
import {
type CasStore,
type ExtractFn,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import * as z from "zod";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
function makeCtx(userContent: string): ThreadContext {
function makeCtx(userContent: string): AgentContext {
return {
start: {
role: START,
@@ -23,34 +16,14 @@ function makeCtx(userContent: string): ThreadContext {
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 typed meta with childThread: null", async () => {
test("posts system + user (start.content) and returns assistant text", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
@@ -61,13 +34,11 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system instructions", testSchema);
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
const out = await adapter(makeCtx("trigger text"));
globalThis.fetch = originalFetch;
expect(result.meta).toEqual({ summary: "extracted" });
expect(result.childThread).toBeNull();
expect(out).toBe("model reply");
});
test("throws on non-ok fetch response", async () => {
@@ -81,9 +52,8 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
@@ -92,9 +62,8 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
+2 -16
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -12,16 +8,6 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
"@uncaged/workflow-runtime": "workspace:*"
}
}
@@ -1,5 +1,11 @@
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
import {
type AgentContext,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -91,13 +97,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: AgentContext) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: prompt },
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "user", content: ctx.start.content },
],
});
@@ -105,5 +111,5 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
});
};
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
"references": [{ "path": "../workflow-runtime" }]
}
@@ -1,211 +0,0 @@
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",
);
});
});
@@ -1,121 +0,0 @@
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");
});
});
@@ -1,31 +0,0 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"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"
}
}
@@ -1,69 +0,0 @@
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 };
};
};
}
@@ -1,4 +0,0 @@
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";
@@ -1,16 +0,0 @@
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);
}
@@ -1,6 +0,0 @@
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";
@@ -1,43 +0,0 @@
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)}`;
}
},
};
@@ -1,43 +0,0 @@
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)}`;
}
},
};
@@ -1,58 +0,0 @@
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);
}
},
};
@@ -1,8 +0,0 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
export type ToolHandler = (args: string) => Promise<string>;
export type ToolEntry = {
definition: ToolDefinition;
handler: ToolHandler;
};
@@ -1,32 +0,0 @@
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)}`;
}
},
};
@@ -1,10 +0,0 @@
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;
};
@@ -1,14 +0,0 @@
{
"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 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"scripts": {
"test": "bun test"
-5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"files": [
"dist",
"package.json"
],
"private": true,
"type": "module",
"scripts": {
@@ -15,7 +11,6 @@
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.2",
"elkjs": "^0.11.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
@@ -70,6 +70,7 @@ 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)" }}>
@@ -26,6 +26,53 @@ function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
return null;
}
type GraphPanelProps = {
descriptor: WorkflowDescriptor;
workflowName: string | null;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: 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}
onNodeClick={onNodeClick}
/>
</div>
)}
</div>
);
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>();
const roleRecords = records.filter(
@@ -180,85 +227,46 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p>
)}
<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>
)}
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<GraphPanel
descriptor={descriptor}
workflowName={workflowName}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
)}
<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>
)}
{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>
);
}
@@ -2,6 +2,7 @@ import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
getSmoothStepPath,
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
@@ -24,24 +25,27 @@ export function ConditionEdge(props: EdgeProps) {
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
const [path, labelX, labelY] = isSelfLoop
? getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 20,
})
: getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
const strokeDasharray = isFallback ? "5 4" : undefined;
const label = edgeData?.condition ?? "";
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
return (
<>
@@ -51,21 +55,19 @@ export function ConditionEdge(props: EdgeProps) {
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
/>
{label !== "" && (
{edgeData && !isFallback && edgeData.condition !== "" && (
<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-surface)",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
whiteSpace: "nowrap",
zIndex: 10,
color: "var(--color-text)",
}}
title={edgeData?.conditionDescription ?? undefined}
title={edgeData.conditionDescription ?? undefined}
>
{label}
{edgeData.condition}
</div>
</EdgeLabelRenderer>
)}
@@ -21,8 +21,6 @@ export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
elkLabelX: number | null;
elkLabelY: number | null;
[key: string]: unknown;
};
@@ -1,6 +1,6 @@
import Dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
import { useEffect, useState } from "react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
@@ -37,10 +37,6 @@ 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}`;
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
@@ -72,24 +68,14 @@ function buildTerminalNode(
};
}
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
const isFallback = e.condition === "FALLBACK";
const key = edgeKey(e);
const elkEdge = elkEdgeMap.get(key);
// Extract ELK's computed label position
let labelX: number | null = null;
let labelY: number | null = null;
if (elkEdge?.labels && elkEdge.labels.length > 0) {
const label = elkEdge.labels[0];
if (label.x !== undefined && label.y !== undefined) {
labelX = label.x + (label.width ?? 0) / 2;
labelY = label.y + (label.height ?? 0) / 2;
}
}
return {
id: key,
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
@@ -97,118 +83,45 @@ function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
elkLabelX: labelX,
elkLabelY: labelY,
},
};
}
const elk = new ELK();
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
const ids = collectNodeIds(input.edges);
const elkNodes: ElkNode[] = [];
for (const id of ids) {
const size = nodeSize(id);
elkNodes.push({ id, width: size.width, height: size.height });
}
const elkEdges: ElkExtendedEdge[] = input.edges
.filter((e) => e.from !== e.to)
.map((e) => ({
id: edgeKey(e),
sources: [e.from],
targets: [e.to],
labels: e.condition !== ""
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
: [],
}));
const graph: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
// Node spacing
"elk.spacing.nodeNode": "30",
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
// Edge spacing — keep edges apart from each other and from nodes
"elk.spacing.edgeNode": "25",
"elk.spacing.edgeEdge": "15",
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
// Edge routing
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.mergeEdges": "false",
// Node placement
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
// Edge label placement
"elk.edgeLabels.placement": "CENTER",
// Crossing minimization
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
// Compaction
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
// Cycle breaking — keep main flow top-to-bottom
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
},
children: elkNodes,
edges: elkEdges,
};
const laid = await elk.layout(graph);
// Build map of ELK edge results for label positions
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
for (const e of laid.edges ?? []) {
elkEdgeMap.set(e.id, e);
}
const nodes: Node[] = [];
for (const child of laid.children ?? []) {
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
const state = input.nodeStates.get(child.id) ?? "default";
if (child.id === START_ID || child.id === END_ID) {
nodes.push(buildTerminalNode(child.id, pos, state));
} else {
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
return { nodes, edges };
}
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
export function useLayout(input: LayoutInput): LayoutResult {
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
return useMemo(() => {
const ids = collectNodeIds(input.edges);
const edgeJson = JSON.stringify(input.edges);
const roleJson = JSON.stringify(input.roles);
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
useEffect(() => {
let cancelled = false;
const parsed = {
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
nodeStates: input.nodeStates,
};
computeLayout(parsed)
.then((result) => {
if (!cancelled) setLayout(result);
})
.catch((err: unknown) => {
if (!cancelled) {
// biome-ignore lint/suspicious/noConsole: layout error reporting
console.error("ELK layout failed:", err);
}
});
return () => {
cancelled = true;
};
}, [edgeJson, roleJson, input.nodeStates]);
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));
}
return layout;
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]);
}
@@ -6,11 +6,9 @@ import {
type NodeTypes,
type OnNodeClick,
ReactFlow,
ReactFlowProvider,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx";
@@ -39,30 +37,12 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node
onRoleClick(node.id);
}
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const { fitView } = useReactFlow();
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
// Re-fit when layout changes (ELK is async)
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
useEffect(() => {
if (layout.nodes.length > 0) {
let cancelled = false;
requestAnimationFrame(() => {
if (cancelled) return;
setTimeout(() => {
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
}, 300);
});
return () => {
cancelled = true;
};
}
}, [layout.nodes, layout.edges, fitView]);
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
@@ -77,25 +57,15 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
[layout.edges],
);
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
const layoutKey = useMemo(
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
[layout.nodes],
);
return (
<ReactFlow
key={layoutKey}
nodes={layout.nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
minZoom={0.1}
maxZoom={1.5}
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
fitViewOptions={{ padding: 0.15 }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -107,11 +77,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
</ReactFlow>
);
}
export function WorkflowGraph(props: Props) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}
@@ -45,45 +45,38 @@ function ExpandedWorkflowBody({
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" : ""}
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</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>
<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>
{hasGraph ? (
<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>
{descriptor !== null && edgeCount > 0 ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
@@ -94,7 +87,7 @@ function ExpandedWorkflowBody({
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div style={{ height: 600, width: "100%" }}>
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
@@ -155,17 +148,18 @@ export function WorkflowList({ agent }: Props) {
);
function toggleExpanded(name: string) {
const wasExpanded = expanded.has(name);
let shouldLoad = false;
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
return next;
}
next.add(name);
shouldLoad = true;
return next;
});
if (!wasExpanded) {
if (shouldLoad) {
ensureDetailLoaded(name);
}
}
@@ -14,7 +14,7 @@ import type {
} from "@uncaged/workflow-runtime";
import { executeThread } from "../src/engine/engine.js";
import type { ExecuteThreadOptions } from "../src/engine/types.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
const TEST_REGISTRY_YAML = `config:
maxDepth: 3
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
+1 -31
View File
@@ -299,37 +299,7 @@ async function driveWorkflowGenerator(params: {
});
}
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",
});
}
const iterResult = await gen.next();
if (iterResult.done) {
logger("F3HN8QKP", `thread ${threadId} generator finished`);
-3
View File
@@ -42,7 +42,4 @@ 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";
@@ -1,165 +0,0 @@
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,8 +1,127 @@
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;
}
/**
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
* This module is kept for backward compatibility and will be removed in a future release.
* 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.
*/
export {
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
workflowAdapter as workflowAsAgent,
} from "./workflow-adapter.js";
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 { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
}
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
}
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 { output: `ERROR: ${message}`, childThread: null };
}
};
}
+2 -9
View File
@@ -1,15 +1,8 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./ws-protocol": "./src/ws-protocol.ts"
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
+15 -123
View File
@@ -1,111 +1,29 @@
/** 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);
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const sockets = this.ctx.getWebSockets();
const connected = sockets.length > 0;
return new Response(JSON.stringify({ connected, connectedCount: sockets.length }), {
headers: { "Content-Type": "application/json" },
});
}
if (request.headers.get("Upgrade") !== "websocket") {
@@ -123,40 +41,14 @@ export class AgentSocket extends DurableObject<AgentSocketEnv> {
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 webSocketMessage(_ws: WebSocket, _message: string | ArrayBuffer): Promise<void> {}
async webSocketClose(
_ws: WebSocket,
_code: number,
_reason: string,
_wasClean: boolean,
): Promise<void> {
this.rejectAllPending("agent websocket closed");
}
): Promise<void> {}
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 }));
}
}
async webSocketError(_ws: WebSocket, _error: unknown): Promise<void> {}
}
+15 -130
View File
@@ -1,12 +1,7 @@
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";
import { AGENT_SOCKET_INTERNAL_STATUS_PATH, AgentSocket } from "./agent-socket.js";
export { AgentSocket };
@@ -52,97 +47,6 @@ function isLocalAgentUrl(url: string): boolean {
}
}
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,
@@ -277,7 +181,7 @@ gateway.get("/endpoints", async (c) => {
app.route("/api/gateway", gateway);
// ── API proxy: /api/agents/:agent/* → WebSocket (preferred) or agent tunnel URL (dashboard auth) ──
// ── API proxy: /api/agents/:agent/* → agent's 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");
@@ -287,45 +191,26 @@ 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 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 = 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 headers = buildDashboardProxyHeaders(c.req.raw.headers, token);
try {
const resp = await fetchAgentWithDashboardHeaders(targetUrl, method, headers, c.req.raw.body);
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
return new Response(resp.body, {
status: resp.status,
headers: resp.headers,
@@ -1,93 +0,0 @@
/** 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 };
}
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
+3 -4
View File
@@ -9,10 +9,11 @@ export type {
} from "./cas-types.js";
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -26,10 +27,8 @@ export type {
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
+5 -9
View File
@@ -143,17 +143,13 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type AgentFnResult = string | { output: string; childThread: string | null };
export type RoleResult<T> = { meta: T; childThread: string | null };
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
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;
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
+1 -11
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -20,11 +16,5 @@
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
@@ -3,9 +3,11 @@ 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,
@@ -37,7 +39,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]) {
@@ -49,18 +51,28 @@ function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[
return out;
}
function adapterForRole(binding: AdapterBinding, roleName: string): AdapterFn {
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 {
const overrides = binding.overrides;
const overrideFn: AdapterFn | undefined =
const overrideFn: AgentFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.adapter;
return overrideFn !== undefined ? overrideFn : binding.agent;
}
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles"> & {
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
},
binding: AdapterBinding,
binding: AgentBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
@@ -82,27 +94,37 @@ async function advanceOneRound<M extends RoleMeta>(
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const adapter = adapterForRole(binding, next);
const roleFn = adapter(
roleDef.systemPrompt,
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(
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>>,
meta,
extracted.meta,
);
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
const contentPayload = JSON.stringify(meta);
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
const refs = refsFromMeta.length === 0 ? [contentHash] : [...refsFromMeta, contentHash];
const contentHash =
artifactRefs.length === 0
? agentContentHash
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
const step = {
role: next,
contentHash,
meta,
meta: extracted.meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
@@ -114,22 +136,22 @@ async function advanceOneRound<M extends RoleMeta>(
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
childThread: result.childThread,
childThread: agentResult.childThread,
},
step,
};
}
/**
* Binds pure role definitions + moderator table to an adapter.
* Binds pure role definitions + moderator table to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* The adapter is responsible for returning typed meta directly — no separate
* extract call is needed.
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
binding: AdapterBinding,
binding: AgentBinding,
): WorkflowFn {
const pickNext = tableToModerator(def.table);
const loopDef = { roles: def.roles, pickNext };
+3 -4
View File
@@ -2,9 +2,10 @@ export { buildThreadContext } from "./build-context.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AdapterBinding,
AdapterFn,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -16,10 +17,8 @@ export type {
ModeratorTransition,
Result,
RoleDefinition,
RoleFn,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
+3 -4
View File
@@ -3,10 +3,11 @@
// imports from "@uncaged/workflow-runtime" continues to work.
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -20,10 +21,8 @@ export type {
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
@@ -5,20 +5,33 @@
*/
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", "set WORKFLOW_LLM_API_KEY for meta extraction"),
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"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
};
const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
const agent = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
@@ -27,7 +40,7 @@ const adapter = createCursorAgent({
llmProvider,
});
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
export const descriptor = buildDevelopDescriptor();
export const run = wf;
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
@@ -7,17 +7,12 @@ 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";
@@ -26,6 +21,86 @@ 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,
@@ -93,6 +168,17 @@ 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",
@@ -109,35 +195,6 @@ function makeThread(prompt: string) {
};
}
/** 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");
@@ -170,6 +227,8 @@ 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([
@@ -183,16 +242,19 @@ 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("adapter yields preparer meta directly", async () => {
test("structured extraction yields preparer meta from mocked chat completions", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
@@ -204,18 +266,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, {
adapter,
overrides: null,
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
@@ -226,7 +288,41 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("per-role adapter overrides default", async () => {
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 () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
@@ -243,22 +339,35 @@ 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, {
adapter: createTrackingAdapter("default", calls, PREPARER_META),
agent: async () => {
calls.push("default");
return "";
},
overrides: {
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
preparer: async () => {
calls.push("preparer");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
},
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
extract: createStubExtract(casDir),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -2,25 +2,34 @@
* solve-issue bundle entry — 小橘 🍊
*
* preparer + submitter → hermes agent
* developer → workflow adapter (delegates to "develop" workflow)
* developer → workflow-as-agent (delegates to "develop" workflow)
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAdapter } from "@uncaged/workflow-execute";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { optionalEnv } from "@uncaged/workflow-util";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
const adapter = createHermesAgent({
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const hermesAgent = 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, {
adapter,
agent: hermesAgent,
overrides: {
developer: workflowAdapter("develop"),
developer: developerAgent,
},
});
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
+2 -8
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -18,8 +14,6 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"zod": "^4.0.0"
"@uncaged/workflow-runtime": "workspace:*"
}
}
@@ -1,11 +1,10 @@
import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime";
import type { AgentContext } from "@uncaged/workflow-runtime";
/**
* 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> {
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
const lines: string[] = [];
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
if (ctx.start.parentState !== null) {
lines.push("## Parent Context");
@@ -59,12 +58,3 @@ export async function buildThreadInput(ctx: ThreadContext): 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}`;
}
@@ -1,51 +0,0 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
/**
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
*/
export type TextAdapterResult = {
output: string;
childThread: string | null;
};
/**
* A function that produces raw text output given the thread context and
* the system prompt for the current role.
*/
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
) => Promise<string | TextAdapterResult>;
/**
* Creates an {@link AdapterFn} from a text-producing function.
*
* The adapter:
* 1. Calls the producer with thread context + system prompt
* 2. Stores output in CAS
* 3. Runs the extract phase to produce typed meta
* 4. Returns `{ meta, childThread }`
*/
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread };
};
};
}
+1 -3
View File
@@ -1,5 +1,3 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
export { createTextAdapter } from "./create-text-adapter.js";
export { buildAgentPrompt } from "./build-agent-prompt.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-cas" }]
"references": [{ "path": "../workflow-runtime" }]
}
@@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test";
import { optionalEnv, requireEnv } from "../src/env.js";
describe("requireEnv", () => {
test("returns value when set", () => {
process.env.TEST_REQ = "hello";
expect(requireEnv("TEST_REQ", "missing")).toBe("hello");
delete process.env.TEST_REQ;
});
test("throws with message when missing", () => {
expect(() => requireEnv("TEST_MISSING_XYZ", "need this")).toThrow("need this");
});
test("throws when empty string", () => {
process.env.TEST_EMPTY = "";
expect(() => requireEnv("TEST_EMPTY", "cannot be empty")).toThrow("cannot be empty");
delete process.env.TEST_EMPTY;
});
});
describe("optionalEnv", () => {
test("returns value when set", () => {
process.env.TEST_OPT = "world";
expect(optionalEnv("TEST_OPT")).toBe("world");
expect(optionalEnv("TEST_OPT", "default")).toBe("world");
delete process.env.TEST_OPT;
});
test("returns null when missing and no fallback", () => {
expect(optionalEnv("TEST_MISSING_ABC")).toBeNull();
});
test("returns fallback when missing", () => {
expect(optionalEnv("TEST_MISSING_ABC", "fallback")).toBe("fallback");
});
test("returns fallback when empty string", () => {
process.env.TEST_EMPTY2 = "";
expect(optionalEnv("TEST_EMPTY2", "fb")).toBe("fb");
expect(optionalEnv("TEST_EMPTY2")).toBeNull();
delete process.env.TEST_EMPTY2;
});
});
+1 -5
View File
@@ -1,10 +1,6 @@
{
"name": "@uncaged/workflow-util",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"version": "0.3.5",
"type": "module",
"exports": {
".": {
-23
View File
@@ -1,23 +0,0 @@
/**
* Read a required environment variable. Throws with `message` if missing or empty.
*/
export function requireEnv(name: string, message: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(message);
}
return value;
}
/**
* Read an optional environment variable. Returns `fallback` if missing or empty.
*/
export function optionalEnv(name: string, fallback: string): string;
export function optionalEnv(name: string): string | null;
export function optionalEnv(name: string, fallback?: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return fallback ?? null;
}
return value;
}
-1
View File
@@ -6,7 +6,6 @@ export {
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { optionalEnv, requireEnv } from "./env.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
+1 -11
View File
@@ -1,11 +1,6 @@
#!/usr/bin/env bash
# Publish all public @uncaged/* packages to Gitea npm registry.
#
# PITFALL: After bumping versions in package.json, bun pm pack still reads the
# old bun.lock and resolves workspace:* to the previous (stale) versions.
# This script deletes bun.lock and runs bun install before packing to force
# correct resolution of workspace:* dependencies.
#
# Usage:
# ./scripts/publish-all.sh # Publish all packages
# ./scripts/publish-all.sh --dry-run # Show what would be published
@@ -22,7 +17,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
REGISTRY="https://git.shazhou.work/api/packages/shazhou/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
@@ -100,11 +95,6 @@ for name in result:
print(name_to_dir[name])
")
# Regenerate lockfile so bun pm pack resolves workspace:* to freshly-bumped versions
cd "$MONOREPO_ROOT"
rm -f bun.lock
bun install
ok=0
fail=0
+1 -2
View File
@@ -15,7 +15,7 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"types": ["bun-types", "node"]
"types": ["bun-types"]
},
"references": [
{ "path": "packages/workflow-runtime" },
@@ -29,7 +29,6 @@
{ "path": "packages/workflow-agent-cursor" },
{ "path": "packages/workflow-agent-hermes" },
{ "path": "packages/workflow-util-agent" },
{ "path": "packages/workflow-agent-react" },
{ "path": "packages/cli-workflow" },
{ "path": "packages/workflow-template-solve-issue" },
{ "path": "packages/workflow-template-develop" }