Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 |
@@ -3,3 +3,4 @@ dist/
|
||||
bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type Roles = {
|
||||
greeter: { greeting: string };
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
greeting: z.string(),
|
||||
});
|
||||
|
||||
export const descriptor = {
|
||||
description: "A simple hello world workflow",
|
||||
roles: {
|
||||
greeter: {
|
||||
description: "Generates a greeting",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
description: "Generates a greeting",
|
||||
systemPrompt: "You greet the user briefly.",
|
||||
extractPrompt: "Extract the greeting string produced for the user.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
export const run = createWorkflow<Roles>(
|
||||
{
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
},
|
||||
{
|
||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||
},
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-examples",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,10 +2,10 @@
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
getGlobalCasDir,
|
||||
getRegisteredWorkflow,
|
||||
readWorkflowRegistry,
|
||||
serializeMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -402,21 +412,23 @@ export const run = async function* (input, options) {
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const put = await cmdCasPut(storageRoot, "phase doc");
|
||||
const raw = "phase doc";
|
||||
const stored = casStoredForm(raw);
|
||||
const put = await cmdCasPut(storageRoot, raw);
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
||||
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||
|
||||
const got = await cmdCasGet(storageRoot, hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe("phase doc");
|
||||
expect(got.value).toBe(stored);
|
||||
|
||||
const listed = await cmdCasList(storageRoot);
|
||||
expect(listed.ok).toBe(true);
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("init template", () => {
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("serve /healthz", () => {
|
||||
test("returns ok", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/workflows", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/workflows");
|
||||
// Registry file won't exist, should return error
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing thread", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/nonexistent-id");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads/running", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/running");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/cas", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { hashes: unknown[] };
|
||||
expect(body.hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing hash", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas/nonexistent-hash");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve CAS round-trip", () => {
|
||||
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
|
||||
|
||||
test("put then get", async () => {
|
||||
const { fetch } = buildApp(tmpDir);
|
||||
|
||||
const putRes = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "hello world" }),
|
||||
});
|
||||
expect(putRes.status).toBe(201);
|
||||
const putBody = (await putRes.json()) as { hash: string };
|
||||
expect(typeof putBody.hash).toBe("string");
|
||||
|
||||
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { content: string };
|
||||
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||
|
||||
// cleanup
|
||||
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -71,6 +72,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -57,6 +57,17 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||
lines.push(
|
||||
|
||||
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
|
||||
}
|
||||
|
||||
export function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
}
|
||||
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import { createCasRoutes } from "./routes-cas.js";
|
||||
import { createThreadRoutes } from "./routes-thread.js";
|
||||
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
const hashes = await cas.list();
|
||||
return c.json({ hashes });
|
||||
});
|
||||
|
||||
app.get("/:hash", async (c) => {
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
const content = await cas.get(c.req.param("hash"));
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
return c.json({ hash: c.req.param("hash"), content });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await c.req.json<{ content: string }>();
|
||||
if (typeof body.content !== "string") {
|
||||
return c.json({ error: "content field required" }, 400);
|
||||
}
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
const hash = await cas.put(body.content);
|
||||
return c.json({ hash }, 201);
|
||||
});
|
||||
|
||||
app.delete("/:hash", async (c) => {
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
const hash = c.req.param("hash");
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
await cas.delete(hash);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/gc", async (c) => {
|
||||
const result = await garbageCollectCas(storageRoot);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
}
|
||||
return c.json(result.value);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadDataPath,
|
||||
} from "../../thread-scan.js";
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||
}
|
||||
const lines = text.trim().split("\n");
|
||||
const records = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
});
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const names = listRegisteredWorkflowNames(reg.value);
|
||||
const workflows = names.map((name) => {
|
||||
const entry = reg.value.workflows[name];
|
||||
return {
|
||||
name,
|
||||
hash: entry?.hash ?? null,
|
||||
timestamp: entry?.timestamp ?? null,
|
||||
};
|
||||
});
|
||||
return c.json({ workflows });
|
||||
});
|
||||
|
||||
app.get("/:name", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, history: entry.history });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||
const app = createApp(storageRoot);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: options.port,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
|
||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
|
||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
if (value === undefined) {
|
||||
return err("--port requires a value");
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||
return err(`invalid port: ${value}`);
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port" || arg === "-p") {
|
||||
const portResult = parsePortValue(argv[i + 1]);
|
||||
if (!portResult.ok) {
|
||||
return portResult;
|
||||
}
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} else if (arg === "--host") {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err("--host requires a value");
|
||||
}
|
||||
hostname = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
startServer(storageRoot, parsed.value);
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
getGlobalCasDir,
|
||||
tryParseRoleStepRecord,
|
||||
tryParseWorkflowResultRecord,
|
||||
type WorkflowCompletion,
|
||||
} from "@uncaged/workflow";
|
||||
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [{ "path": "../workflow" }],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
model: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HermesAgentConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { createCasStore } from "@uncaged/workflow";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,6 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
@@ -0,0 +1,13 @@
|
||||
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
|
||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return err("descriptor must be a non-array object");
|
||||
}
|
||||
const root = value as Record<string, unknown>;
|
||||
const description = root.description;
|
||||
if (typeof description !== "string") {
|
||||
return err("descriptor.description must be a string");
|
||||
}
|
||||
const rolesRaw = root.roles;
|
||||
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
|
||||
return err("descriptor.roles must be a non-array object");
|
||||
}
|
||||
|
||||
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
}
|
||||
|
||||
return ok({ description, roles });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { CasStore } from "./types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { CasStore } from "../cas/types.js";
|
||||
import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ModeratorContext,
|
||||
type ResolveRoleMetaFn,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadInput,
|
||||
type WorkflowCompletion,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
} from "../types.js";
|
||||
import { mergeRefsWithContentHash } from "../util/index.js";
|
||||
|
||||
function isRoleNext<M extends RoleMeta>(
|
||||
next: (keyof M & string) | typeof END,
|
||||
): next is keyof M & string {
|
||||
return next !== END;
|
||||
}
|
||||
|
||||
function resolveExtractedRefs(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
meta: unknown,
|
||||
): string[] {
|
||||
const extractRefsFn = roleDef.extractRefs;
|
||||
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||
return [];
|
||||
}
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
|
||||
return store.put(raw);
|
||||
}
|
||||
|
||||
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||
const overrides = binding.overrides;
|
||||
const overrideFn: AgentFn | undefined =
|
||||
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
|
||||
return overrideFn !== undefined ? overrideFn : binding.agent;
|
||||
}
|
||||
|
||||
type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
|
||||
async function advanceOneRound<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||
params: {
|
||||
start: ModeratorContext<M>["start"];
|
||||
steps: RoleStep<M>[];
|
||||
options: WorkflowFnOptions;
|
||||
},
|
||||
): Promise<AdvanceOutcome<M>> {
|
||||
const { start, steps, options } = params;
|
||||
const modCtx: ModeratorContext<M> = {
|
||||
threadId: options.threadId,
|
||||
depth: options.depth,
|
||||
start,
|
||||
steps,
|
||||
};
|
||||
|
||||
const next = def.moderator(modCtx);
|
||||
if (!isRoleNext(next)) {
|
||||
return {
|
||||
kind: "complete",
|
||||
completion: { returnCode: 0, summary: "completed: moderator returned END" },
|
||||
};
|
||||
}
|
||||
|
||||
const roleDef = def.roles[next];
|
||||
if (roleDef === undefined) {
|
||||
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
|
||||
}
|
||||
|
||||
const agentCtx: AgentContext<M> = {
|
||||
...modCtx,
|
||||
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||
cas: options.cas,
|
||||
};
|
||||
|
||||
const agent = agentForRole(binding, next);
|
||||
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||
|
||||
const extractCtx: ExtractContext<M> = {
|
||||
...agentCtx,
|
||||
agentContent: raw,
|
||||
};
|
||||
|
||||
const meta = await resolveRoleMeta(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx,
|
||||
options,
|
||||
);
|
||||
|
||||
const contentHash = await putContentBlob(options.cas, raw);
|
||||
const refs = mergeRefsWithContentHash(
|
||||
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
|
||||
contentHash,
|
||||
);
|
||||
|
||||
const step = {
|
||||
role: next,
|
||||
contentHash,
|
||||
meta,
|
||||
refs,
|
||||
timestamp: Date.now(),
|
||||
} as RoleStep<M>;
|
||||
|
||||
return {
|
||||
kind: "yield",
|
||||
output: {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
},
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
* Assign with `export const run = createWorkflow(def, binding)` via `@uncaged/workflow-runtime`,
|
||||
* which supplies {@link ResolveRoleMetaFn}.
|
||||
*/
|
||||
export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||
): WorkflowFn {
|
||||
return async function* workflowLoop(
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const nowMs = Date.now();
|
||||
const start: ModeratorContext<M>["start"] = {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
timestamp: nowMs,
|
||||
};
|
||||
|
||||
const baseTs = Date.now();
|
||||
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: baseTs + i,
|
||||
})) as RoleStep<M>[];
|
||||
|
||||
while (true) {
|
||||
if (steps.length >= options.maxRounds) {
|
||||
return {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await advanceOneRound(def, binding, resolveRoleMeta, {
|
||||
start,
|
||||
steps,
|
||||
options,
|
||||
});
|
||||
|
||||
if (outcome.kind === "complete") {
|
||||
return outcome.completion;
|
||||
}
|
||||
|
||||
yield outcome.output;
|
||||
steps = [...steps, outcome.step];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
@@ -0,0 +1 @@
|
||||
export type { ExtractFn } from "./types.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { ExtractContext } from "../types.js";
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
) => Promise<T>;
|
||||
@@ -0,0 +1,35 @@
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/types.js";
|
||||
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
|
||||
export type { CasStore } from "./cas/index.js";
|
||||
export { createWorkflow } from "./engine/index.js";
|
||||
export type { ExtractFn } from "./extract/index.js";
|
||||
export type {
|
||||
AgentBinding,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
ExtractContext,
|
||||
ExtractMode,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorContext,
|
||||
ResolveRoleMetaFn,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
RoleOutput,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
export type { Result } from "./util/index.js";
|
||||
export { err, ok } from "./util/index.js";
|
||||
@@ -36,7 +36,7 @@ export type WorkflowCompletion = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
|
||||
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
|
||||
export type WorkflowResult = WorkflowCompletion & {
|
||||
rootHash: string;
|
||||
};
|
||||
@@ -115,10 +115,10 @@ export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
|
||||
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
/** Runtime agent assignment (optional per-role overrides). */
|
||||
/** Runtime agent assignment (explicit null when no per-role overrides). */
|
||||
export type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides?: Partial<Record<string, AgentFn>>;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
/** Role wiring: prompts, schema, and human-readable description. */
|
||||
@@ -148,3 +148,10 @@ export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
/** Engine-injected meta extraction for workflow loops (single + react modes). */
|
||||
export type ResolveRoleMetaFn<M extends RoleMeta = RoleMeta> = (
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
options: WorkflowFnOptions,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { mergeRefsWithContentHash } from "./refs-field.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type { Result } from "./types.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
|
||||
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
|
||||
const out = [...refs];
|
||||
if (!out.includes(contentHash)) {
|
||||
out.push(contentHash);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
export function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RoleStep,
|
||||
START,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||
import { developModerator } from "../src/index.js";
|
||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
} from "@uncaged/workflow";
|
||||
import { createWorkflow } from "@uncaged/workflow";
|
||||
import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||
|
||||
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, createExtract } from "@uncaged/workflow";
|
||||
import {
|
||||
createCasStore,
|
||||
createExtract,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleStep,
|
||||
START,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import type { DeveloperMeta } from "../src/developer.js";
|
||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const developerMetaSchema = z.object({
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
workflowAsAgent,
|
||||
} from "@uncaged/workflow";
|
||||
import { createWorkflow, workflowAsAgent } from "@uncaged/workflow";
|
||||
import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Moderator } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
import type { Moderator } from "@uncaged/workflow-runtime";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const toolchainSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||
|
||||
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { buildAgentPrompt } from "../src/index.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-util-agent",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentContext } from "@uncaged/workflow";
|
||||
import { getContentMerklePayload } from "@uncaged/workflow";
|
||||
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||
|
||||
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
|
||||
const text = await getContentMerklePayload(ctx.cas, contentHash);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type SpawnCliError =
|
||||
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||
import { END } from "../src/types.js";
|
||||
|
||||
describe("buildDescriptor", () => {
|
||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||
|
||||
@@ -39,6 +39,16 @@ export const run = async function* (_input, options) {
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("allows static import of @uncaged/workflow-runtime", () => {
|
||||
const source = `${minimalDescriptor}import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const run = createWorkflow({ description: "x", roles: {}, moderator: () => "END" }, {});
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects wrong filename suffix", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.js",
|
||||
|
||||
@@ -5,6 +5,11 @@ import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashString } from "../src/cas/hash.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("createCasStore", () => {
|
||||
let casDir: string;
|
||||
@@ -19,25 +24,30 @@ describe("createCasStore", () => {
|
||||
|
||||
test("put returns consistent hash for same content", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("hello world");
|
||||
const h2 = await cas.put("hello world");
|
||||
const raw = "hello world";
|
||||
const stored = casStoredForm(raw);
|
||||
const h1 = await cas.put(raw);
|
||||
const h2 = await cas.put(raw);
|
||||
expect(h1).toBe(h2);
|
||||
expect(h1).toBe(hashString(stored));
|
||||
expect(h1).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("put returns hash matching hashString", async () => {
|
||||
test("put returns hash matching hashString of merkle-stored form", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "some content to store";
|
||||
const stored = casStoredForm(content);
|
||||
const h = await cas.put(content);
|
||||
expect(h).toBe(hashString(content));
|
||||
expect(h).toBe(hashString(stored));
|
||||
});
|
||||
|
||||
test("get returns stored content", async () => {
|
||||
test("get returns merkle-serialized blob for raw puts", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "line1\nline2\nline3";
|
||||
const stored = casStoredForm(content);
|
||||
const h = await cas.put(content);
|
||||
const retrieved = await cas.get(h);
|
||||
expect(retrieved).toBe(content);
|
||||
expect(retrieved).toBe(stored);
|
||||
});
|
||||
|
||||
test("get returns null for missing hash", async () => {
|
||||
@@ -76,11 +86,13 @@ describe("createCasStore", () => {
|
||||
|
||||
test("put is idempotent — same content written twice causes no error", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("idempotent");
|
||||
const h2 = await cas.put("idempotent");
|
||||
const raw = "idempotent";
|
||||
const stored = casStoredForm(raw);
|
||||
const h1 = await cas.put(raw);
|
||||
const h2 = await cas.put(raw);
|
||||
expect(h1).toBe(h2);
|
||||
const content = await cas.get(h1);
|
||||
expect(content).toBe("idempotent");
|
||||
expect(content).toBe(stored);
|
||||
});
|
||||
|
||||
test("different content produces different hashes", async () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../src/cas/merkle.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
@@ -96,6 +95,98 @@ async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||
}
|
||||
|
||||
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
extract: stub/model
|
||||
supervisor: stub/supervisor-cheap
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 10
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
extract: stub/model
|
||||
supervisor: stub/supervisor-cheap
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
|
||||
}
|
||||
|
||||
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
|
||||
function installMockExtractThenSupervisor(params: {
|
||||
extractArgs: ReadonlyArray<Record<string, unknown>>;
|
||||
supervisorContent: string;
|
||||
onSupervisorCall?: () => void;
|
||||
}): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let extractI = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const hasTools = Array.isArray(tools) && tools.length > 0;
|
||||
if (hasTools) {
|
||||
const args =
|
||||
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
|
||||
}
|
||||
extractI += 1;
|
||||
const firstTool = tools[0] as Record<string, unknown>;
|
||||
const fn = firstTool.function as Record<string, unknown> | undefined;
|
||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
params.onSupervisorCall?.();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: params.supervisorContent } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
globalThis.fetch = Object.assign(mockFetch, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
@@ -577,7 +668,7 @@ describe("executeThread", () => {
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
},
|
||||
{ agent: async () => dagRootHash },
|
||||
{ agent: async () => dagRootHash, overrides: null },
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -623,4 +714,102 @@ describe("executeThread", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("supervisor stops thread when interval elapses and model returns stop", async () => {
|
||||
restoreFetch = installMockExtractThenSupervisor({
|
||||
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||
supervisorContent: "stop",
|
||||
});
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-stop-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeRegistryYaml(root, SUPERVISOR_INTERVAL_REGISTRY_YAML);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
{ prompt: "supervisor-stop-case", steps: [] },
|
||||
{
|
||||
maxRounds: 20,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(result.summary).toBe("completed: supervisor stopped thread");
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("supervisor is not invoked before supervisorInterval rounds", async () => {
|
||||
let supervisorCalls = 0;
|
||||
restoreFetch = installMockExtractThenSupervisor({
|
||||
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||
supervisorContent: "stop",
|
||||
onSupervisorCall: () => {
|
||||
supervisorCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-skip-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
await writeRegistryYaml(root, SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
{ prompt: "no-supervisor-yet", steps: [] },
|
||||
{
|
||||
maxRounds: 20,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(supervisorCalls).toBe(0);
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(result.summary).toBe("completed: moderator returned END");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||
import { reactExtract } from "../src/extract/react-extract.js";
|
||||
import type { LlmProvider } from "../src/types.js";
|
||||
|
||||
const metaSchema = z.object({ seen: z.string() });
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
@@ -102,6 +101,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
},
|
||||
{
|
||||
agent: async () => "plan-output",
|
||||
overrides: null,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -132,6 +132,65 @@ workflows:
|
||||
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
||||
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
||||
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
||||
expect(r.value.config.supervisorInterval).toBe(3);
|
||||
});
|
||||
|
||||
test("defaults supervisorInterval to 3 when omitted", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok || r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.supervisorInterval).toBe(3);
|
||||
});
|
||||
|
||||
test("parses explicit supervisorInterval", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
supervisorInterval: 7
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok || r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.supervisorInterval).toBe(7);
|
||||
});
|
||||
|
||||
test("parse errors when supervisorInterval is negative", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
supervisorInterval: -1
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parses config apiKey env: prefix from process.env", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { WorkflowConfig } from "../src/registry/index.js";
|
||||
function sampleConfig(): WorkflowConfig {
|
||||
return {
|
||||
maxDepth: 3,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
dashscope: {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
@@ -50,6 +51,7 @@ describe("resolveModel", () => {
|
||||
test("errs when scene missing and no default", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
@@ -69,6 +71,7 @@ describe("resolveModel", () => {
|
||||
test("errs when provider is unknown", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
@@ -87,6 +90,7 @@ describe("resolveModel", () => {
|
||||
test("errs on invalid model reference shape", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseSupervisorDecisionText, runSupervisor } from "../src/engine/supervisor.js";
|
||||
import type { WorkflowConfig } from "../src/registry/index.js";
|
||||
import type { LogFn } from "../src/util/index.js";
|
||||
|
||||
const noopLogger: LogFn = () => {};
|
||||
|
||||
function supervisorOnlyConfig(): WorkflowConfig {
|
||||
return {
|
||||
maxDepth: 3,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
extract: "stub/extract-model",
|
||||
supervisor: "stub/supervisor-model",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseSupervisorDecisionText", () => {
|
||||
test("reads continue and stop case-insensitively", () => {
|
||||
expect(parseSupervisorDecisionText("continue")).toBe("continue");
|
||||
expect(parseSupervisorDecisionText("CONTINUE")).toBe("continue");
|
||||
expect(parseSupervisorDecisionText("stop")).toBe("stop");
|
||||
expect(parseSupervisorDecisionText("STOP.")).toBe("stop");
|
||||
});
|
||||
|
||||
test("finds token inside a sentence", () => {
|
||||
expect(parseSupervisorDecisionText("Answer: continue")).toBe("continue");
|
||||
expect(parseSupervisorDecisionText("I recommend stop now")).toBe("stop");
|
||||
});
|
||||
|
||||
test("when both appear, earlier token wins", () => {
|
||||
expect(parseSupervisorDecisionText("continue then stop")).toBe("continue");
|
||||
expect(parseSupervisorDecisionText("stop then continue")).toBe("stop");
|
||||
});
|
||||
|
||||
test("defaults to continue when unclear", () => {
|
||||
expect(parseSupervisorDecisionText("maybe later")).toBe("continue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runSupervisor", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("returns continue when supervisor model cannot be resolved (no fetch)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async () => {
|
||||
throw new Error("fetch should not run when supervisor is not configured");
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
extract: "stub/m",
|
||||
},
|
||||
};
|
||||
|
||||
const r = await runSupervisor({
|
||||
config,
|
||||
prompt: "task",
|
||||
recentSteps: [{ role: "planner", summary: "{}" }],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value).toBe("continue");
|
||||
});
|
||||
|
||||
test("returns stop from chat/completions assistant content", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: "stop" } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const r = await runSupervisor({
|
||||
config: supervisorOnlyConfig(),
|
||||
prompt: "do X",
|
||||
recentSteps: [{ role: "a", summary: "{}" }],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value).toBe("stop");
|
||||
});
|
||||
|
||||
test("returns err on invalid JSON body", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(async () => new Response("not-json", { status: 200 }), {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
|
||||
const r = await runSupervisor({
|
||||
config: supervisorOnlyConfig(),
|
||||
prompt: "p",
|
||||
recentSteps: [],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry/registry.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
@@ -153,7 +152,7 @@ describe("workflowAsAgent integration", () => {
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
},
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||
import { parseMerkleNode } from "../src/cas/merkle.js";
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry/registry.js";
|
||||
import { type AgentContext, START } from "../src/types.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
function makeAgentCtx(params: {
|
||||
@@ -155,6 +154,7 @@ workflows: {}
|
||||
...reg.value,
|
||||
config: {
|
||||
maxDepth: 2,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
local: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -8,6 +8,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"acorn": "^8.16.0",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.8.4"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { RoleMeta, WorkflowDefinition } from "../types.js";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
|
||||
@@ -38,7 +38,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (spec === "@uncaged/workflow") {
|
||||
if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkflowFn } from "../types.js";
|
||||
import type { WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import type { WorkflowFn } from "../types.js";
|
||||
import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
|
||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
};
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
export type WorkflowBundleValidationInput = {
|
||||
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
||||
|
||||
@@ -1,40 +1 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return err("descriptor must be a non-array object");
|
||||
}
|
||||
const root = value as Record<string, unknown>;
|
||||
const description = root.description;
|
||||
if (typeof description !== "string") {
|
||||
return err("descriptor.description must be a string");
|
||||
}
|
||||
const rolesRaw = root.roles;
|
||||
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
|
||||
return err("descriptor.roles must be a non-array object");
|
||||
}
|
||||
|
||||
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
}
|
||||
|
||||
return ok({ description, roles });
|
||||
}
|
||||
export { validateWorkflowDescriptor } from "@uncaged/workflow-runtime";
|
||||
|
||||
@@ -2,8 +2,19 @@ import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/pro
|
||||
import { join } from "node:path";
|
||||
|
||||
import { hashString } from "./hash.js";
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
|
||||
import type { CasStore } from "./types.js";
|
||||
|
||||
/** Raw strings become content merkle YAML; already-valid merkle documents pass through. */
|
||||
function normalizeCasPutContent(content: string): string {
|
||||
try {
|
||||
parseMerkleNode(content);
|
||||
return content;
|
||||
} catch {
|
||||
return serializeMerkleNode(createContentMerkleNode(content));
|
||||
}
|
||||
}
|
||||
|
||||
export function createCasStore(casDir: string): CasStore {
|
||||
async function ensureDir(): Promise<void> {
|
||||
await mkdir(casDir, { recursive: true });
|
||||
@@ -15,11 +26,12 @@ export function createCasStore(casDir: string): CasStore {
|
||||
|
||||
return {
|
||||
async put(content: string): Promise<string> {
|
||||
const hash = hashString(content);
|
||||
const toStore = normalizeCasPutContent(content);
|
||||
const hash = hashString(toStore);
|
||||
await ensureDir();
|
||||
const target = filePath(hash);
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await writeFile(tmp, content, "utf8");
|
||||
await writeFile(tmp, toStore, "utf8");
|
||||
await rename(tmp, target);
|
||||
return hash;
|
||||
},
|
||||
|
||||
@@ -77,10 +77,9 @@ export async function putThreadMerkleNode(
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Serializes a content Merkle node and stores it in CAS; returns its hash. */
|
||||
/** Stores agent/content text via CAS; {@link createCasStore} wraps raw strings as merkle content nodes. */
|
||||
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
|
||||
const yamlText = serializeMerkleNode(createContentMerkleNode(content));
|
||||
return store.put(yamlText);
|
||||
return store.put(content);
|
||||
}
|
||||
|
||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export type CasStore = {
|
||||
put(content: string): Promise<string>;
|
||||
get(hash: string): Promise<string | null>;
|
||||
delete(hash: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
};
|
||||
export type { CasStore } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type MerkleNodeType = "content" | "step" | "thread";
|
||||
|
||||
|
||||
@@ -1,73 +1,12 @@
|
||||
import { putContentMerkleNode } from "../cas/index.js";
|
||||
import { buildExtractUserContent, reactExtract } from "../extract/index.js";
|
||||
import {
|
||||
type AgentBinding,
|
||||
type AgentContext,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadInput,
|
||||
type WorkflowCompletion,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
type WorkflowFnOptions,
|
||||
} from "../types.js";
|
||||
import { mergeRefsWithContentHash } from "../util/index.js";
|
||||
import type {
|
||||
AgentBinding,
|
||||
RoleMeta,
|
||||
WorkflowDefinition,
|
||||
WorkflowFn,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createWorkflow as createWorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
|
||||
function isRoleNext<M extends RoleMeta>(
|
||||
next: (keyof M & string) | typeof END,
|
||||
): next is keyof M & string {
|
||||
return next !== END;
|
||||
}
|
||||
|
||||
function resolveExtractedRefs(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
meta: unknown,
|
||||
): string[] {
|
||||
const extractRefsFn = roleDef.extractRefs;
|
||||
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||
return [];
|
||||
}
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function resolveRoleMeta<M extends RoleMeta>(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
options: WorkflowFnOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (roleDef.extractMode === "react") {
|
||||
if (options.llmProvider === null) {
|
||||
throw new Error(
|
||||
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
|
||||
);
|
||||
}
|
||||
const text = await buildExtractUserContent(
|
||||
extractCtx as unknown as ExtractContext,
|
||||
roleDef.extractPrompt,
|
||||
);
|
||||
const reactResult = await reactExtract({
|
||||
text,
|
||||
schema: roleDef.schema,
|
||||
provider: options.llmProvider,
|
||||
cas: options.cas,
|
||||
});
|
||||
if (!reactResult.ok) {
|
||||
throw new Error(`react extract failed: ${reactResult.error}`);
|
||||
}
|
||||
return reactResult.value as Record<string, unknown>;
|
||||
}
|
||||
return (await options.extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
import { resolveRoleMeta } from "./resolve-role-meta.js";
|
||||
|
||||
/**
|
||||
* Binds pure role definitions + moderator to runtime agents.
|
||||
@@ -78,98 +17,5 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||
binding: AgentBinding,
|
||||
): WorkflowFn {
|
||||
return async function* workflowLoop(
|
||||
input: ThreadInput,
|
||||
options: WorkflowFnOptions,
|
||||
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||
const nowMs = Date.now();
|
||||
const start: ModeratorContext<M>["start"] = {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
timestamp: nowMs,
|
||||
};
|
||||
|
||||
const baseTs = Date.now();
|
||||
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: baseTs + i,
|
||||
})) as RoleStep<M>[];
|
||||
|
||||
while (true) {
|
||||
if (steps.length >= options.maxRounds) {
|
||||
return {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
};
|
||||
}
|
||||
|
||||
const modCtx: ModeratorContext<M> = {
|
||||
threadId: options.threadId,
|
||||
depth: options.depth,
|
||||
start,
|
||||
steps,
|
||||
};
|
||||
|
||||
const next = def.moderator(modCtx);
|
||||
|
||||
if (!isRoleNext(next)) {
|
||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||
}
|
||||
|
||||
const roleDef = def.roles[next];
|
||||
if (roleDef === undefined) {
|
||||
return { returnCode: 1, summary: `unknown role: ${next}` };
|
||||
}
|
||||
|
||||
const agentCtx: AgentContext<M> = {
|
||||
...modCtx,
|
||||
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||
cas: options.cas,
|
||||
};
|
||||
|
||||
const agent = binding.overrides?.[next] ?? binding.agent;
|
||||
|
||||
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||
|
||||
const extractCtx: ExtractContext<M> = {
|
||||
...agentCtx,
|
||||
agentContent: raw,
|
||||
};
|
||||
|
||||
const meta = await resolveRoleMeta(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx,
|
||||
options,
|
||||
);
|
||||
|
||||
const contentHash = await putContentMerkleNode(options.cas, raw);
|
||||
|
||||
const refs = mergeRefsWithContentHash(
|
||||
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
|
||||
contentHash,
|
||||
);
|
||||
|
||||
const ts = Date.now();
|
||||
const step = {
|
||||
role: next,
|
||||
contentHash,
|
||||
meta,
|
||||
refs,
|
||||
timestamp: ts,
|
||||
} as RoleStep<M>;
|
||||
|
||||
yield {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: step.refs,
|
||||
};
|
||||
|
||||
steps = [...steps, step];
|
||||
}
|
||||
};
|
||||
return createWorkflowRuntime(def, binding, resolveRoleMeta);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type {
|
||||
LlmProvider,
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
getContentMerklePayload,
|
||||
@@ -9,23 +16,21 @@ import {
|
||||
} from "../cas/index.js";
|
||||
import { resolveModel } from "../config/index.js";
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { readWorkflowRegistry } from "../registry/index.js";
|
||||
import type {
|
||||
LlmProvider,
|
||||
ThreadInput,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowFnOptions,
|
||||
WorkflowResult,
|
||||
} from "../types.js";
|
||||
import { readWorkflowRegistry, type WorkflowConfig } from "../registry/index.js";
|
||||
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
|
||||
|
||||
async function resolveExtractRuntime(
|
||||
storageRoot: string,
|
||||
): Promise<
|
||||
Result<{ extract: ReturnType<typeof createExtract>; llmProvider: LlmProvider }, string>
|
||||
async function resolveEngineRegistryRuntime(storageRoot: string): Promise<
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
llmProvider: LlmProvider;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
>
|
||||
> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
@@ -45,7 +50,7 @@ async function resolveExtractRuntime(
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({ extract: createExtract(llmProvider), llmProvider });
|
||||
return ok({ extract: createExtract(llmProvider), llmProvider, workflowConfig: cfg });
|
||||
}
|
||||
|
||||
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
@@ -79,9 +84,66 @@ async function finalizeThreadResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeAbortedThread(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: string[];
|
||||
logger: LogFn;
|
||||
abortLogTag: string;
|
||||
}): Promise<WorkflowResult> {
|
||||
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeSupervisorHaltsThread(params: {
|
||||
workflowConfig: WorkflowConfig;
|
||||
input: ThreadInput;
|
||||
written: number;
|
||||
recentSupervisorSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
threadId: string;
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
stepMerkleHashes: string[];
|
||||
}): Promise<WorkflowResult | null> {
|
||||
const interval = params.workflowConfig.supervisorInterval;
|
||||
if (interval <= 0 || params.written % interval !== 0) {
|
||||
return null;
|
||||
}
|
||||
const sup = await runSupervisor({
|
||||
config: params.workflowConfig,
|
||||
prompt: params.input.prompt,
|
||||
recentSteps: params.recentSupervisorSteps,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!sup.ok) {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "stop") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
});
|
||||
}
|
||||
|
||||
async function driveWorkflowGenerator(params: {
|
||||
fn: WorkflowFn;
|
||||
workflowName: string;
|
||||
workflowConfig: WorkflowConfig;
|
||||
input: ThreadInput;
|
||||
bundleOptions: WorkflowFnOptions;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
@@ -94,6 +156,7 @@ async function driveWorkflowGenerator(params: {
|
||||
const {
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig,
|
||||
input,
|
||||
bundleOptions,
|
||||
executeOptions,
|
||||
@@ -105,16 +168,20 @@ async function driveWorkflowGenerator(params: {
|
||||
} = params;
|
||||
const gen = fn(input, bundleOptions);
|
||||
let written = 0;
|
||||
const recentSupervisorSteps: { role: string; summary: string }[] = input.steps.map((s) => ({
|
||||
role: s.role,
|
||||
summary: JSON.stringify(s.meta),
|
||||
}));
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP2", `thread ${threadId} aborted`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP2",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,6 +239,11 @@ async function driveWorkflowGenerator(params: {
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
recentSupervisorSteps.push({
|
||||
role: step.role,
|
||||
summary: JSON.stringify(step.meta),
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
executeOptions.awaitAfterEachYield(),
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -184,15 +256,30 @@ async function driveWorkflowGenerator(params: {
|
||||
]);
|
||||
|
||||
if (executeOptions.signal.aborted) {
|
||||
logger("V8JX4NP4", `thread ${threadId} aborted`);
|
||||
return await finalizeThreadResult({
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP4",
|
||||
});
|
||||
}
|
||||
|
||||
const supervised = await maybeSupervisorHaltsThread({
|
||||
workflowConfig,
|
||||
input,
|
||||
written,
|
||||
recentSupervisorSteps,
|
||||
logger,
|
||||
threadId,
|
||||
cas,
|
||||
workflowName,
|
||||
stepMerkleHashes,
|
||||
});
|
||||
if (supervised !== null) {
|
||||
return supervised;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,9 +367,9 @@ export async function executeThread(
|
||||
});
|
||||
}
|
||||
|
||||
const extractRuntime = await resolveExtractRuntime(options.storageRoot);
|
||||
if (!extractRuntime.ok) {
|
||||
throw new Error(extractRuntime.error);
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
}
|
||||
|
||||
const bundleOptions: WorkflowFnOptions = {
|
||||
@@ -290,13 +377,14 @@ export async function executeThread(
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
cas: io.cas,
|
||||
extract: extractRuntime.value.extract,
|
||||
llmProvider: extractRuntime.value.llmProvider,
|
||||
extract: registryRuntime.value.extract,
|
||||
llmProvider: registryRuntime.value.llmProvider,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig: registryRuntime.value.workflowConfig,
|
||||
input,
|
||||
bundleOptions,
|
||||
executeOptions: options,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkflowCompletion } from "../types.js";
|
||||
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||
import { err, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
GcResult,
|
||||
ParsedThreadStartRecord,
|
||||
PrefilledDiskStep,
|
||||
SupervisorDecision,
|
||||
ThreadPauseGate,
|
||||
} from "./types.js";
|
||||
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
ExtractContext,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
WorkflowFnOptions,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import { buildExtractUserContent } from "../extract/extract-fn.js";
|
||||
import { reactExtract } from "../extract/react-extract.js";
|
||||
|
||||
export async function resolveRoleMeta<M extends RoleMeta>(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
extractCtx: ExtractContext<M>,
|
||||
options: WorkflowFnOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (roleDef.extractMode === "react") {
|
||||
if (options.llmProvider === null) {
|
||||
throw new Error(
|
||||
'createWorkflow: WorkflowFnOptions.llmProvider is required when a role uses extractMode "react"',
|
||||
);
|
||||
}
|
||||
const text = await buildExtractUserContent(
|
||||
extractCtx as unknown as ExtractContext,
|
||||
roleDef.extractPrompt,
|
||||
);
|
||||
const reactResult = await reactExtract({
|
||||
text,
|
||||
schema: roleDef.schema,
|
||||
provider: options.llmProvider,
|
||||
cas: options.cas,
|
||||
});
|
||||
if (!reactResult.ok) {
|
||||
throw new Error(`react extract failed: ${reactResult.error}`);
|
||||
}
|
||||
return reactResult.value as Record<string, unknown>;
|
||||
}
|
||||
return (await options.extract(
|
||||
roleDef.schema,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { resolveModel } from "../config/index.js";
|
||||
import type { WorkflowConfig } from "../registry/index.js";
|
||||
import { err, type LogFn, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { SupervisorDecision } from "./types.js";
|
||||
|
||||
const SUPERVISOR_RECENT_STEP_LIMIT = 12;
|
||||
|
||||
function chatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readAssistantContent(parsed: unknown): string | null {
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const first = choices[0];
|
||||
if (!isRecord(first)) {
|
||||
return null;
|
||||
}
|
||||
const messageObj = first.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return null;
|
||||
}
|
||||
const content = messageObj.content;
|
||||
if (typeof content !== "string") {
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Lenient: accepts STOP/stop/stop. as prose; prefers {@link SupervisorDecision.stop} when both tokens appear. */
|
||||
export function parseSupervisorDecisionText(text: string): SupervisorDecision {
|
||||
const lower = text.toLowerCase();
|
||||
const stopWord = /\bstop\b/.test(lower);
|
||||
const continueWord = /\bcontinue\b/.test(lower);
|
||||
if (stopWord && continueWord) {
|
||||
const si = lower.search(/\bstop\b/);
|
||||
const ci = lower.search(/\bcontinue\b/);
|
||||
return si <= ci ? "stop" : "continue";
|
||||
}
|
||||
if (stopWord) {
|
||||
return "stop";
|
||||
}
|
||||
if (continueWord) {
|
||||
return "continue";
|
||||
}
|
||||
if (lower.includes("stop")) {
|
||||
return "stop";
|
||||
}
|
||||
if (lower.includes("continue")) {
|
||||
return "continue";
|
||||
}
|
||||
return "continue";
|
||||
}
|
||||
|
||||
type RunSupervisorArgs = {
|
||||
config: WorkflowConfig;
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
};
|
||||
|
||||
/** Calls the `supervisor` scene LLM; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */
|
||||
export async function runSupervisor(
|
||||
args: RunSupervisorArgs,
|
||||
): Promise<Result<SupervisorDecision, string>> {
|
||||
const resolved = resolveModel(args.config, "supervisor");
|
||||
if (!resolved.ok) {
|
||||
return ok("continue");
|
||||
}
|
||||
const provider = resolved.value;
|
||||
const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT);
|
||||
const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n");
|
||||
|
||||
const body = {
|
||||
model: provider.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system" as const,
|
||||
content:
|
||||
'You supervise a multi-step workflow. Decide if the thread should keep running or halt.\n\nReply with exactly one token: either "continue" (progress toward the goal, not obviously stuck) or "stop" (done, looping, or no progress). Do not add explanation.',
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content: `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatCompletionsUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
args.logger("R9CW4PLM", `supervisor request failed: ${message}`);
|
||||
return err(`supervisor network error: ${message}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
args.logger("T3HN8VKQ", `supervisor HTTP ${response.status}: ${responseText.slice(0, 200)}`);
|
||||
return err(`supervisor HTTP ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
args.logger("W7BQ2NXM", `supervisor response is not JSON: ${message}`);
|
||||
return err(`supervisor invalid JSON: ${message}`);
|
||||
}
|
||||
|
||||
const content = readAssistantContent(parsed);
|
||||
if (content === null || content.trim() === "") {
|
||||
args.logger("Y4JX9PKW", "supervisor returned empty assistant content");
|
||||
return err("supervisor empty assistant content");
|
||||
}
|
||||
|
||||
const decision = parseSupervisorDecisionText(content);
|
||||
args.logger("Z8KM5QWT", `supervisor says ${decision}`);
|
||||
return ok(decision);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
import type { RoleOutput } from "../types.js";
|
||||
import type { Result } from "../util/index.js";
|
||||
|
||||
export type SupervisorDecision = "continue" | "stop";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
|
||||
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
|
||||
import { createCasStore } from "../cas/index.js";
|
||||
import type { RoleOutput, WorkflowFn, WorkflowResult } from "../types.js";
|
||||
import {
|
||||
createLogger,
|
||||
err,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { getContentMerklePayload } from "../cas/index.js";
|
||||
import type { ExtractContext, LlmProvider } from "../types.js";
|
||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import type { ExtractFn } from "./types.js";
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { CasStore, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
import type { LlmProvider } from "../types.js";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user