Compare commits

..

24 Commits

Author SHA1 Message Date
xingyue e7f733c393 refactor(dashboard): replace ELK with custom spine layout
What: Replace ELK layout engine with a hand-written spine layout that
topologically sorts nodes into a vertical main path with feedback edges
routed to the right side.

Why: ELK's layered algorithm spreads the graph too wide when handling
feedback (back) edges, causing fitView to shrink nodes until text is
unreadable. Our workflow graphs are predominantly linear pipelines with
feedback loops — a custom layout handles this topology much better.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite from async ELK to synchronous spine layout — topo-sort extracts
  main path, nodes stack vertically, feedback edges get right-side routing
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  add custom SVG path for feedback edges (right-side arc with Q curves),
  use typed isFeedback/isSelfLoop fields from ConditionEdgeData
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  rename elkLabelX/Y to labelX/Y, add isFeedback and isSelfLoop fields
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  remove ReactFlowProvider/useReactFlow/useEffect fitView workaround
  (no longer needed — layout is synchronous), simplify component
- packages/workflow-dashboard/package.json: remove elkjs and dagre deps
2026-05-13 16:54:04 +08:00
xingyue d4bb4a9324 Merge pull request 'fix(cli): point bin to dist/cli.js instead of src/cli.ts' (#234) from fix/cli-bin-path into main 2026-05-13 08:43:41 +00:00
xingyue e4900b6fd6 fix(cli): keep bin pointing to src/cli.ts, add src to files
The actual issue was that 'files' only included dist/, so src/ was
excluded from the published package. bun can run .ts natively — no
need to point bin at compiled dist/cli.js.

Fix: add 'src' to files array so it ships with the package.
2026-05-13 16:43:07 +08:00
xiaomo 39540d9ae8 Merge pull request 'fix(dashboard): address ELK layout review feedback' (#233) from fix/dashboard-elk-review-feedback into main 2026-05-13 08:40:32 +00:00
xingyue 10899364d4 fix(cli): point bin to dist/cli.js instead of src/cli.ts
The bin entry pointed to src/cli.ts but only dist/ is published,
causing 'Cannot find module cli-dispatch.js' on global install.
2026-05-13 16:38:54 +08:00
xingyue dc5fdd7358 fix(dashboard): address ELK layout review feedback
What: Fix three non-blocking issues from PR #232 review.

Why: Code quality — unhandled promise rejection risk, type safety,
and project convention compliance.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  add elkLabelX/elkLabelY fields to ConditionEdgeData type (number | null,
  not optional — per project no-optional-properties rule)
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  remove 'as ConditionEdgeData' type assertion (now unnecessary),
  add .catch() to computeLayout promise
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  remove redundant inline type extension, use ConditionEdgeData directly

Ref: PR #232 review comments
2026-05-13 16:37:07 +08:00
xiaoju bb1293f6b9 fix: add exports field to 6 packages for proper type resolution
Packages without exports.types pointed main/types to src/ which
doesn't exist in published tarballs. Now all packages have:
  exports."." = { types: dist/index.d.ts, import: src/index.ts }

Bump to 0.3.18.
2026-05-13 08:29:36 +00:00
xiaomo 55b3b61498 Merge pull request 'feat(dashboard): switch graph layout from Dagre to ELK' (#232) from feat/dashboard-elk-layout into main 2026-05-13 08:28:24 +00:00
xiaoju 497f03c747 chore: bump all packages to 0.3.17 2026-05-13 08:04:32 +00:00
xiaoju cfe4543d39 refactor!: remove deprecated Agent types, introduce Adapter-first API
BREAKING CHANGES:
- Remove AgentFn, AgentFnResult, AgentBinding from workflow-protocol
- Remove wrapAgentAsAdapter from workflow-util-agent
- workflowAsAgent → workflowAdapter (old name kept as deprecated re-export)

New APIs:
- createTextAdapter(producer) — bridges text-producing functions to AdapterFn
- TextProducerFn, TextAdapterResult types
- workflowAdapter() — direct AdapterFn for child workflow delegation

All agent packages (cursor, hermes, llm) now return AdapterFn directly,
no wrapping needed. Bundle entries simplified accordingly.

小橘 🍊(NEKO Team)
2026-05-13 08:03:27 +00:00
xiaoju 399b967c59 refactor: reduce cognitive complexity in dispatch.ts and shell-exec.ts
- Extract helpers from promptSecret/onData (32→~4)
- Extract sub-functions from collectInteractiveSetup (36→~8)
- Extract classifyExecError from shell-exec handler (17→~3)
- Replace all non-null assertions with safe .at() access

0 biome errors, 0 warnings.
2026-05-13 07:37:47 +00:00
xiaoju 061926b86a chore: fix all biome lint errors
- Auto-fix string concatenation → template literals
- Remove unused imports
- Prefix unused function with underscore
- Format fixes across multiple files
2026-05-13 07:26:11 +00:00
xiaoju acb0ebed97 chore: add @types/node for node:* module declarations 2026-05-13 07:21:43 +00:00
xiaoju d5d7be6100 chore: add files field to all packages, bump to 0.3.16
Excludes tsconfig.json and source files from published packages.
Fixes TypeScript errors when consuming packages via bun.
2026-05-13 07:19:49 +00:00
xiaoju 1566a43395 chore: bump all packages to 0.3.15 2026-05-13 07:04:12 +00:00
xiaoju afbde4573a chore: add bunfig.toml to gitignore (contains registry token) 2026-05-13 06:55:16 +00:00
xiaoju 63e447fc3d chore: unify npm registry to uncaged org
publish-all.sh now targets the same org as .npmrc.

小橘 🍊
2026-05-13 06:49:30 +00:00
xiaoju 34fcbf29cb chore: bump workflow-util and workflow-util-agent to 0.3.14
小橘 🍊
2026-05-13 06:12:29 +00:00
xiaoju 256799fcfd chore: bump workflow-util and workflow-util-agent to 0.3.12
小橘 🍊
2026-05-13 06:04:53 +00:00
xiaoju 21cf3db111 feat(util): extract requireEnv/optionalEnv to workflow-util
- requireEnv(name, message) — throws with custom error message
- optionalEnv(name, fallback?) — returns fallback or null
- Update develop and solve-issue bundle entries to use shared helpers
- Remove inline requireEnv/optionalEnv and wrapAgentAsAdapter usage
- Add tests for both functions

小橘 🍊
2026-05-13 06:02:17 +00:00
xiaomo ed38543db4 Merge pull request 'docs(skill): add authoring pitfalls to skill author topic' (#231) from fix/skill-author-pitfalls into main 2026-05-13 03:59:50 +00:00
xiaomo 78771fbebc Merge pull request 'fix(publish-all): regenerate lockfile before pack' (#230) from fix/publish-lockfile-regen into main 2026-05-13 03:59:42 +00:00
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

Knowledge from smoke test discoveries — these are the most common
mistakes when writing workflow bundles.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
xiaoju 6d4bf108bb fix(publish-all): regenerate lockfile before pack
After bumping versions, bun pm pack reads the old bun.lock and resolves
workspace:* to stale versions. Now deletes bun.lock and runs bun install
before the pack loop to ensure correct resolution.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:52:10 +00:00
66 changed files with 1149 additions and 714 deletions
+1
View File
@@ -5,3 +5,4 @@ bun.lock
tsconfig.tsbuildinfo
.npmrc
bunfig.toml
-2
View File
@@ -1,2 +0,0 @@
[test]
pathIgnorePatterns = ["dist/**"]
+1
View File
@@ -18,6 +18,7 @@
},
"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",
"AgentFn",
"AdapterFn",
"ExtractFn",
"RoleMeta",
]) {
+6 -1
View File
@@ -1,6 +1,11 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
@@ -5,7 +5,6 @@ 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(
@@ -91,7 +90,7 @@ function agentsMd(): string {
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -101,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
@@ -112,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -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,6 +10,7 @@ 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";
@@ -154,11 +155,67 @@ 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);
@@ -166,46 +223,22 @@ 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 (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("*");
if (processSecretChar(c, state) === "done") return;
}
};
state.onData = onData;
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
const url = baseUrl.replace(/\/+$/, "") + "/models";
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
@@ -228,139 +261,158 @@ async function fetchAvailableModels(
.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();
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 customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
printProviderMenu(presets);
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) {
const providerResult = await selectProvider(rl, presets);
if (!providerResult.ok) {
rl.close();
return err(`invalid choice: ${choice}`);
return providerResult;
}
const { provider, baseUrl } = providerResult.value;
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);
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 modelResult = await selectModel(rl2, models);
if (!modelResult.ok) {
rl2.close();
return modelResult;
}
// Strip provider prefix if user included one (e.g. pasted "MiniMax/MiniMax-M2.7").
const bare = selectedModel.includes("/") ? selectedModel.split("/").pop()! : selectedModel;
const bare = stripProviderPrefix(modelResult.value);
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
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;
}
const initWorkspaceName = await selectWorkspace(rl2);
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,45 +5,43 @@ 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,8 +13,6 @@ import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
+86 -16
View File
@@ -183,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -230,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -246,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### Bundle import restrictions
The bundle validator only allows these import specifiers:
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
- \`@uncaged/workflow-*\` packages
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
@@ -79,7 +79,7 @@ describe("validateCursorAgentConfig", () => {
});
describe("createCursorAgent", () => {
test("returns an AgentFn with explicit workspace", () => {
test("returns an AdapterFn with explicit workspace", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
@@ -90,7 +90,7 @@ describe("createCursorAgent", () => {
expect(typeof agent).toBe("function");
});
test("returns an AgentFn with null workspace and llmProvider", () => {
test("returns an AdapterFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
+11 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -14,5 +18,11 @@
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
+14 -7
View File
@@ -1,6 +1,11 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import type { AdapterFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
@@ -29,12 +34,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): AgentFn {
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
return createTextAdapter(async (ctx, prompt) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
@@ -48,7 +53,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
@@ -58,7 +64,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const fullPrompt = await buildAgentPrompt(ctx);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"-p",
fullPrompt,
@@ -79,5 +86,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
throwCursorSpawnError(run.error);
}
return run.value;
};
});
}
@@ -37,7 +37,7 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
+11 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -10,5 +14,11 @@
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
+12 -6
View File
@@ -1,5 +1,10 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { AdapterFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
@@ -25,16 +30,17 @@ function throwHermesSpawnError(error: SpawnCliError): never {
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout;
return async (ctx) => {
return createTextAdapter(async (ctx, prompt) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const fullPrompt = await buildAgentPrompt(ctx);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"chat",
"-q",
@@ -55,5 +61,5 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
throwHermesSpawnError(run.error);
}
return run.value;
};
});
}
@@ -1,9 +1,16 @@
import { describe, expect, test } from "bun:test";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import {
type CasStore,
type ExtractFn,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import * as z from "zod";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
function makeCtx(userContent: string): AgentContext {
function makeCtx(userContent: string): ThreadContext {
return {
start: {
role: START,
@@ -16,14 +23,34 @@ function makeCtx(userContent: string): AgentContext {
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
};
}
const testSchema = z.object({ summary: z.string() });
function makeRuntime(): WorkflowRuntime {
let stored = "";
const cas: CasStore = {
put: async (content: string) => {
stored = content;
return "HASH001";
},
get: async () => stored,
delete: async () => {},
list: async () => [],
};
const extract: ExtractFn = async (_schema, _contentHash) => ({
meta: { summary: "extracted" },
contentPayload: stored,
refs: [],
});
return { cas, extract };
}
describe("createLlmAdapter", () => {
const originalFetch = globalThis.fetch;
test("posts system + user (start.content) and returns assistant text", async () => {
test("posts system + user (start.content) and returns typed meta with childThread: null", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
@@ -34,11 +61,13 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"));
const roleFn = adapter("system instructions", testSchema);
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
globalThis.fetch = originalFetch;
expect(out).toBe("model reply");
expect(result.meta).toEqual({ summary: "extracted" });
expect(result.childThread).toBeNull();
});
test("throws on non-ok fetch response", async () => {
@@ -52,8 +81,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
@@ -62,8 +92,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
+16 -2
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -8,6 +12,16 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*"
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
@@ -1,11 +1,5 @@
import {
type AgentContext,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -97,13 +91,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: AgentContext) => {
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "system", content: prompt },
{ role: "user", content: ctx.start.content },
],
});
@@ -111,5 +105,5 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
};
});
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }]
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
}
@@ -1,7 +1,6 @@
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 { ok } from "@uncaged/workflow-protocol";
import { START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import { createReactAdapter } from "../src/create-react-adapter.js";
@@ -39,10 +38,12 @@ const STUB_RUNTIME: WorkflowRuntime = {
}),
};
const TEST_SCHEMA = z.object({
summary: z.string(),
score: z.number(),
}).meta({ title: "resolve", description: "Submit the final result." });
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" };
@@ -156,7 +157,9 @@ describe("createReactAdapter", () => {
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: "bad", score: "not-a-number" }, "call_1"),
);
}
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
};
@@ -1,9 +1,9 @@
import { describe, test, expect, afterAll } from "bun:test";
import { readFileTool, writeFileTool, patchFileTool, shellExecTool } from "../src/tools/index.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
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 });
@@ -14,9 +14,17 @@ const cleanupFiles: string[] = [];
afterAll(() => {
for (const f of cleanupFiles) {
try { unlinkSync(f); } catch { /* ignore */ }
try {
unlinkSync(f);
} catch {
/* ignore */
}
}
try {
unlinkSync(TMP_DIR);
} catch {
/* ignore */
}
try { unlinkSync(TMP_DIR); } catch { /* ignore */ }
});
describe("read_file", () => {
@@ -26,7 +34,9 @@ describe("read_file", () => {
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 }));
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");
@@ -42,7 +52,9 @@ describe("read_file", () => {
});
test("returns error for missing file", async () => {
const result = await readFileTool.handler(JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }));
const result = await readFileTool.handler(
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
);
expect(result).toContain("Error:");
});
});
@@ -64,7 +76,9 @@ describe("patch_file", () => {
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" }));
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");
});
@@ -74,7 +88,9 @@ describe("patch_file", () => {
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }));
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
);
expect(result).toContain("not found");
});
@@ -83,14 +99,18 @@ describe("patch_file", () => {
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" }));
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 }));
const result = await shellExecTool.handler(
JSON.stringify({ command: "echo hello", timeout: null }),
);
expect(result.trim()).toBe("hello");
});
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+2 -2
View File
@@ -1,4 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
export { defaultTools, defaultToolHandler } from "./tools/index.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -1,9 +1,9 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
import type { ToolEntry } from "./types.js";
import { readFileTool } from "./read-file.js";
import { writeFileTool } from "./write-file.js";
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];
@@ -1,6 +1,6 @@
export { readFileTool } from "./read-file.js";
export { writeFileTool } from "./write-file.js";
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 { defaultTools, defaultToolHandler } from "./defaults.js";
export type { ToolEntry, ToolHandler } from "./types.js";
export { writeFileTool } from "./write-file.js";
@@ -30,7 +30,10 @@ export const patchFileTool: ToolEntry = {
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);
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) {
@@ -11,7 +11,10 @@ export const readFileTool: ToolEntry = {
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)" },
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"],
@@ -20,12 +23,17 @@ export const readFileTool: ToolEntry = {
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; offset: number | null; limit: number | null };
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 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) {
@@ -3,6 +3,27 @@ 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",
@@ -29,17 +50,9 @@ export const shellExecTool: ToolEntry = {
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: MAX_OUTPUT * 2,
});
return output.length > MAX_OUTPUT ? `${output.slice(0, MAX_OUTPUT)}\n...(truncated)` : output;
return truncate(output);
} catch (err: unknown) {
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 combined.length > MAX_OUTPUT ? `${combined.slice(0, MAX_OUTPUT)}\n...(truncated)` : combined || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
return classifyExecError(err);
}
},
};
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"scripts": {
"test": "bun test"
+4 -2
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"files": [
"dist",
"package.json"
],
"private": true,
"type": "module",
"scripts": {
@@ -9,9 +13,7 @@
"preview": "vite preview"
},
"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,7 +70,6 @@ export function LoginPage({ onLogin }: Props) {
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
autoFocus
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
@@ -6,6 +6,42 @@ import {
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
const FEEDBACK_OFFSET_X = 100;
// Radius for feedback edge corners
const FEEDBACK_RADIUS = 16;
/**
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
* The path goes: source right → arc → vertical up → arc → target right
*/
function feedbackPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
): string {
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
const r = FEEDBACK_RADIUS;
// Start from source right side, go right, then up, then left to target right side
const segments = [
`M ${sourceX} ${sourceY}`,
// Horizontal to the right
`L ${rightX - r} ${sourceY}`,
// Arc turning upward
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
// Vertical upward
`L ${rightX} ${targetY + r}`,
// Arc turning left
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
// Horizontal left to target
`L ${targetX} ${targetY}`,
];
return segments.join(" ");
}
export function ConditionEdge(props: EdgeProps) {
const {
id,
@@ -20,28 +56,44 @@ export function ConditionEdge(props: EdgeProps) {
data,
markerEnd,
} = props;
const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined;
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const isFeedback = edgeData?.isFeedback ?? false;
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
let path: string;
let defaultLabelX: number;
let defaultLabelY: number;
if (isFeedback) {
// Custom feedback path routed to the right
path = feedbackPath(sourceX, sourceY, targetX, targetY);
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
defaultLabelX = rightX;
defaultLabelY = (sourceY + targetY) / 2;
} else {
const result = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
path = result[0];
defaultLabelX = result[1];
defaultLabelY = result[2];
}
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
const strokeDasharray = isFallback ? "5 4" : undefined;
const label = edgeData?.condition ?? "";
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
// Use pre-computed label position if available, otherwise fall back to default
const labelX = edgeData?.labelX ?? defaultLabelX;
const labelY = edgeData?.labelY ?? defaultLabelY;
return (
<>
@@ -21,6 +21,10 @@ export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
isFeedback: boolean;
isSelfLoop: boolean;
labelX: number | null;
labelY: number | null;
[key: string]: unknown;
};
@@ -1,6 +1,5 @@
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";
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
const ROLE_NODE_HEIGHT = 60;
const TERMINAL_NODE_SIZE = 40;
// Vertical gap between nodes in the spine
const LAYER_GAP = 80;
// Horizontal offset for feedback (back) edges routed on the right side
const FEEDBACK_OFFSET_X = 100;
type LayoutInput = {
edges: readonly WorkflowGraphEdge[];
roles: Record<string, { description: string }>;
@@ -21,15 +25,6 @@ type LayoutResult = {
edges: Edge[];
};
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function nodeSize(id: string): { width: number; height: number } {
if (id === START_ID || id === END_ID) {
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
@@ -41,6 +36,75 @@ function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
/**
* Extract the linear spine from the graph using topological ordering.
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
* Self-loops are neither forward nor feedback — they're handled separately.
*/
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
// Collect all node IDs
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
// or FALLBACK if no other option.
const forwardAdj = new Map<string, string[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const existing = forwardAdj.get(e.from) ?? [];
existing.push(e.to);
forwardAdj.set(e.from, existing);
}
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
const visited = new Set<string>();
const spine: string[] = [];
// Build a set of "primary" next targets per node (non-FALLBACK first)
const primaryNext = new Map<string, string>();
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const list = edgesByFrom.get(e.from) ?? [];
list.push(e);
edgesByFrom.set(e.from, list);
}
// For each node, the "primary" next is the first non-FALLBACK target,
// or the FALLBACK target if all edges are FALLBACK
for (const [from, edgeList] of edgesByFrom) {
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
}
// Walk the spine from __start__
let current: string | null = START_ID;
while (current !== null && !visited.has(current)) {
visited.add(current);
spine.push(current);
const next = primaryNext.get(current);
if (next !== undefined && next !== "" && !visited.has(next)) {
current = next;
} else {
current = null;
}
}
// Add any remaining nodes not on the main path (shouldn't normally happen)
for (const id of ids) {
if (!visited.has(id)) {
spine.push(id);
}
}
return spine;
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
@@ -72,136 +136,95 @@ function buildTerminalNode(
};
}
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): 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;
}
function computeLayout(input: LayoutInput): LayoutResult {
const spine = extractSpine(input.edges);
const rank = new Map<string, number>();
for (let i = 0; i < spine.length; i++) {
rank.set(spine[i], i);
}
return {
id: key,
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
elkLabelX: labelX,
elkLabelY: labelY,
} as ConditionEdgeData,
};
}
// Position nodes along a vertical spine, centered horizontally
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
const elk = new ELK();
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
const ids = collectNodeIds(input.edges);
const elkNodes: ElkNode[] = [];
for (const id of ids) {
let y = 0;
for (const id of spine) {
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);
// Center-align all nodes on the spine
const x = centerX - size.width / 2;
nodePositions.set(id, { x, y, w: size.width, h: size.height });
y += size.height + LAYER_GAP;
}
// Build nodes
const nodes: Node[] = [];
for (const 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));
for (const id of spine) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
}
}
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
// Build edges with label positions
// For feedback edges (target rank < source rank), we'll compute label at midpoint
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
const edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to;
const sourceRank = rank.get(e.from) ?? 0;
const targetRank = rank.get(e.to) ?? 0;
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
const sourcePos = nodePositions.get(e.from);
const targetPos = nodePositions.get(e.to);
let labelX: number | null = null;
let labelY: number | null = null;
if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback) {
// Label on the right side of the feedback arc
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
labelX = rightX;
labelY = midY;
} else if (!isSelfLoop) {
// Forward edge: label between source bottom and target top
const midX = centerX;
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
labelX = midX;
labelY = midY;
}
// Self-loop: let ReactFlow default handle it
}
return {
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
isFeedback,
isSelfLoop,
labelX,
labelY,
},
};
});
return { nodes, edges };
}
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
export function useLayout(input: LayoutInput): LayoutResult {
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
const edgeJson = JSON.stringify(input.edges);
const roleJson = JSON.stringify(input.roles);
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);
});
return () => {
cancelled = true;
};
}, [edgeJson, roleJson, input.nodeStates]);
return layout;
return useMemo(
() => computeLayout(input),
[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,17 @@ 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 }}
minZoom={0.3}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -107,11 +79,3 @@ function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
</ReactFlow>
);
}
export function WorkflowGraph(props: Props) {
return (
<ReactFlowProvider>
<WorkflowGraphInner {...props} />
</ReactFlowProvider>
);
}
@@ -14,7 +14,7 @@ import type {
} from "@uncaged/workflow-runtime";
import { executeThread } from "../src/engine/engine.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
import type { ExecuteThreadOptions } from "../src/engine/types.js";
const TEST_REGISTRY_YAML = `config:
maxDepth: 3
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+3
View File
@@ -42,4 +42,7 @@ export {
llmErrorToCause,
llmExtract,
} from "./extract/index.js";
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
/** @deprecated Use {@link workflowAdapter} instead. */
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
@@ -0,0 +1,165 @@
import { join } from "node:path";
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import {
extractBundleExports,
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowFn,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type * as z from "zod/v4";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3;
function workflowAdapterMaxDepth(config: WorkflowConfig | null): number {
return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth;
}
export type WorkflowAdapterOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
};
function resolveStorageRoot(options: WorkflowAdapterOptions | null): string {
if (options !== null && options.storageRoot !== null) {
return options.storageRoot;
}
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(
storageRoot: string,
ctx: ThreadContext,
): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/** Resolve the workflow bundle and validate depth limits. */
async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) {
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
throw new Error(`failed to read workflow registry: ${registryResult.error.message}`);
}
const maxDepth = workflowAdapterMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`);
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
throw new Error(`workflow "${workflowName}" not found in registry`);
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
throw new Error(String(bundleExportsResult.error));
}
return { entry, run: bundleExportsResult.value.run };
}
/** Execute the child workflow thread and return a summary + root hash. */
async function runChildThread(params: {
workflowName: string;
storageRoot: string;
ctx: ThreadContext;
run: WorkflowFn;
bundleHash: string;
nextDepth: number;
}) {
const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params;
const childThreadId = generateUlid(Date.now());
const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`);
const io: ExecuteThreadIo = {
threadId: childThreadId,
hash: bundleHash,
infoJsonlPath,
cas: createCasStore(getGlobalCasDir(storageRoot)),
};
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const parentHeadState = await readParentHeadState(storageRoot, ctx);
const result = await executeThread(
run,
workflowName,
{ prompt: ctx.start.content, steps: [] },
{
depth: nextDepth,
parentStateHash: parentHeadState,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null,
forkContinuation: null,
replayTimestamps: null,
storageRoot,
},
io,
logger,
);
return {
summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`,
rootHash: result.rootHash,
};
}
/**
* Returns an {@link AdapterFn} that runs another registered workflow in a new child thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
*
* The child thread's root hash is returned as `childThread` in the result,
* enabling parent→child tracking in the CAS Merkle tree.
*/
export function workflowAdapter(
workflowName: string,
options: WorkflowAdapterOptions | null = null,
): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const storageRoot = resolveStorageRoot(options);
const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1);
try {
const { summary, rootHash } = await runChildThread({
workflowName,
storageRoot,
ctx,
run,
bundleHash: entry.hash,
nextDepth: ctx.depth + 1,
});
const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread: rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`child workflow "${workflowName}" failed: ${message}`);
}
};
};
}
@@ -1,127 +1,8 @@
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import {
extractBundleExports,
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
};
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
if (options !== null && options.storageRoot !== null) {
return options.storageRoot;
}
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/**
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
* This module is kept for backward compatibility and will be removed in a future release.
*/
export function workflowAsAgent(
workflowName: string,
options: WorkflowAsAgentOptions | null = null,
): AgentFn {
return async (ctx: AgentContext): Promise<AgentFnResult> => {
const nextDepth = ctx.depth + 1;
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
return { 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 };
}
};
}
export {
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
workflowAdapter as workflowAsAgent,
} from "./workflow-adapter.js";
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": "./src/index.ts",
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+1 -4
View File
@@ -12,10 +12,7 @@ export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -30,9 +27,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
-12
View File
@@ -143,18 +143,6 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFnResult = string | { output: string; childThread: string | null };
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleResult<T> = { meta: T; childThread: string | null };
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+11 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -16,5 +20,11 @@
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
}
}
@@ -37,7 +37,7 @@ function resolveExtractedRefs(
return extractRefsFn(meta as Record<string, unknown>);
}
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const h of [...a, ...b]) {
@@ -83,7 +83,10 @@ async function advanceOneRound<M extends RoleMeta>(
}
const adapter = adapterForRole(binding, next);
const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType<Record<string, unknown>>);
const roleFn = adapter(
roleDef.systemPrompt,
roleDef.schema as z.ZodType<Record<string, unknown>>,
);
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
const meta = result.meta;
+1 -4
View File
@@ -4,10 +4,7 @@ export { err, ok } from "./result.js";
export type {
AdapterBinding,
AdapterFn,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -20,9 +17,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
+1 -4
View File
@@ -6,10 +6,7 @@ export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -24,9 +21,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
@@ -5,34 +5,20 @@
*/
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`missing required env var: ${name}`);
}
return value;
}
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const llmProvider = {
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
baseUrl: optionalEnv(
"WORKFLOW_LLM_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
};
const agent = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND"),
const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
@@ -41,8 +27,6 @@ const agent = createCursorAgent({
llmProvider,
});
const adapter = wrapAgentAsAdapter(agent);
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
export const descriptor = buildDevelopDescriptor();
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
@@ -215,10 +215,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
});
const first = await gen.next();
expect(first.done).toBe(false);
@@ -261,10 +258,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -2,38 +2,25 @@
* solve-issue bundle entry — 小橘 🍊
*
* preparer + submitter → hermes agent
* developer → workflow-as-agent (delegates to "develop" workflow)
* developer → workflow adapter (delegates to "develop" workflow)
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { workflowAdapter } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { optionalEnv } from "@uncaged/workflow-util";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const hermesAgent = createHermesAgent({
const adapter = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const developerAgent = workflowAsAgent("develop");
const adapter = wrapAgentAsAdapter(hermesAgent);
const developerAdapter = wrapAgentAsAdapter(developerAgent);
const wf = createWorkflow(solveIssueWorkflowDefinition, {
adapter,
overrides: {
developer: developerAdapter,
developer: workflowAdapter("develop"),
},
});
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+7 -2
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -15,6 +19,7 @@
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-cas": "workspace:*"
"@uncaged/workflow-cas": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -0,0 +1,51 @@
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 };
};
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
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 type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
export { wrapAgentAsAdapter } from "./wrap-agent-as-adapter.js";
@@ -1,31 +0,0 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
AgentContext,
AgentFnResult,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
/**
* Wraps a legacy AgentFn into an AdapterFn.
* The agent produces a string (or { output, childThread }); the adapter
* stores the output in CAS, runs extract, and returns typed meta + childThread.
*/
export function wrapAgentAsAdapter(
agentFn: (ctx: AgentContext) => Promise<AgentFnResult>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const agentCtx: AgentContext = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agentFn(agentCtx);
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 };
};
};
}
@@ -0,0 +1,44 @@
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;
});
});
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-util",
"version": "0.3.11",
"version": "0.3.18",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
+23
View File
@@ -0,0 +1,23 @@
/**
* 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,6 +6,7 @@ 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";
+11 -1
View File
@@ -1,6 +1,11 @@
#!/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
@@ -17,7 +22,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REGISTRY="https://git.shazhou.work/api/packages/shazhou/npm/"
REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
@@ -95,6 +100,11 @@ 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 -1
View File
@@ -15,7 +15,7 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"types": ["bun-types"]
"types": ["bun-types", "node"]
},
"references": [
{ "path": "packages/workflow-runtime" },