Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 |
@@ -4,3 +4,4 @@ bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
@@ -100,7 +100,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
@@ -135,7 +135,7 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
|
||||
@@ -50,7 +50,6 @@ const greeterMetaSchema = z.object({
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -93,18 +93,18 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
||||
## 2. 核心概念
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { statSync, watch } from "node:fs";
|
||||
import { existsSync, statSync, watch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
@@ -118,7 +118,12 @@ async function emitRecordsForHead(params: {
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({ type: "workflow-result", returnCode: wf.returnCode, content: wf.summary, timestamp: null }),
|
||||
data: JSON.stringify({
|
||||
type: "workflow-result",
|
||||
returnCode: wf.returnCode,
|
||||
content: wf.summary,
|
||||
timestamp: null,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
return true;
|
||||
@@ -307,6 +312,18 @@ export function createLiveRoutes(storageRoot: string): Hono {
|
||||
return;
|
||||
}
|
||||
|
||||
// If thread is not actively running, emit all records and close — don't keep SSE open
|
||||
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
|
||||
if (!existsSync(runningPath)) {
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "done",
|
||||
data: JSON.stringify({ reason: "not-running" }),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
@@ -36,6 +36,8 @@ async function readStartInfo(
|
||||
async function buildThreadDetailRecords(
|
||||
storageRoot: string,
|
||||
resolved: ResolvedThreadRecord,
|
||||
runningMarkerPresent: boolean,
|
||||
statusRow: HistoricalThreadRow,
|
||||
): Promise<unknown[]> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
@@ -43,13 +45,15 @@ async function buildThreadDetailRecords(
|
||||
|
||||
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
|
||||
|
||||
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
|
||||
|
||||
const records: unknown[] = [
|
||||
{
|
||||
type: "thread-start",
|
||||
workflow: workflowName ?? "unknown",
|
||||
prompt: prompt ?? null,
|
||||
threadId: resolved.threadId,
|
||||
status: resolved.source,
|
||||
status,
|
||||
timestamp: null,
|
||||
},
|
||||
];
|
||||
@@ -123,7 +127,22 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const records = await buildThreadDetailRecords(storageRoot, resolved);
|
||||
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const statusRow = {
|
||||
threadId: resolved.threadId,
|
||||
hash: resolved.bundleHash,
|
||||
workflowName: null,
|
||||
source: resolved.source,
|
||||
activityTs: 0,
|
||||
head: resolved.head,
|
||||
};
|
||||
const records = await buildThreadDetailRecords(
|
||||
storageRoot,
|
||||
resolved,
|
||||
runningMarkerPresent,
|
||||
statusRow,
|
||||
);
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
@@ -137,13 +156,12 @@ export function createThreadRoutes(storageRoot: string): Hono {
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||
const result = await cmdRun(storageRoot, name, prompt);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ import type { ServeOptions } from "./types.js";
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
export function startServer(storageRoot: string, options: ServeOptions, agentToken: string | null): void {
|
||||
export function startServer(
|
||||
storageRoot: string,
|
||||
options: ServeOptions,
|
||||
agentToken: string | null,
|
||||
): void {
|
||||
const app = createApp(storageRoot, agentToken);
|
||||
|
||||
const server = serve({
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function registerWithGateway(
|
||||
agentToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/register`, {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
||||
@@ -65,7 +65,7 @@ export async function unregisterFromGateway(
|
||||
secret: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/register/${name}`, {
|
||||
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
|
||||
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
args: "<name> [--prompt <text>]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
|
||||
@@ -10,7 +10,6 @@ export async function cmdRun(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
maxRounds: number,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
if (!nameOk.ok) {
|
||||
@@ -41,7 +40,7 @@ export async function cmdRun(
|
||||
threadId,
|
||||
workflowName: name,
|
||||
prompt,
|
||||
options: { maxRounds, depth: 0 },
|
||||
options: { depth: 0 },
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
|
||||
|
||||
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
|
||||
function parseFlagAt(
|
||||
argv: string[],
|
||||
index: number,
|
||||
): Result<{ kind: "prompt"; value: string }, string> | null {
|
||||
const flag = argv[index];
|
||||
if (flag === "--prompt") {
|
||||
const value = argv[index + 1];
|
||||
@@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
}
|
||||
return ok({ kind: "prompt", value });
|
||||
}
|
||||
if (flag === "--max-rounds") {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
return err("missing value for --max-rounds");
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
return err("--max-rounds must be a non-negative integer");
|
||||
}
|
||||
return ok({ kind: "max-rounds", value: n });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let maxRounds = 5;
|
||||
|
||||
let i = 0;
|
||||
const first = argv[0];
|
||||
@@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
}
|
||||
|
||||
const flag = parsed.value;
|
||||
if (flag.kind === "prompt") {
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
maxRounds = flag.value;
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
}
|
||||
|
||||
@@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
return err("run requires <name>");
|
||||
}
|
||||
|
||||
return ok({ name, prompt, maxRounds });
|
||||
return ok({ name, prompt });
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||
@@ -92,6 +98,15 @@ ${commandSections.join("\n\n")}
|
||||
3. \`uncaged-workflow live --latest\` — attach and watch output
|
||||
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
|
||||
|
||||
## Thread Status
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| \`running\` | Worker process is alive (\`.running\` marker + live PID) |
|
||||
| \`active\` | In \`threads.json\` but not currently running (paused or waiting) |
|
||||
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
|
||||
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -103,7 +118,9 @@ ${commandSections.join("\n\n")}
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Same as above (takes priority) |
|
||||
| \`WORKFLOW_LLM_API_KEY\` | API key for LLM calls during workflow execution |
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -200,7 +217,6 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { readWorkerCtl } from "./worker-spawn.js";
|
||||
|
||||
async function readWorkflowNameFromStartHash(
|
||||
storageRoot: string,
|
||||
@@ -217,8 +218,30 @@ export async function resolveThreadListStatus(
|
||||
return "completed";
|
||||
}
|
||||
if (runningMarkerPresent) {
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (ctlResult.ok) {
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
return "running";
|
||||
} catch {
|
||||
// Worker PID is dead but .running marker remains — crashed thread
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
// No .running marker + no __end__ + source "active" → check if worker is dead (crashed)
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (!ctlResult.ok) {
|
||||
// No ctl file means worker never registered or was already cleaned up — dead thread
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
} catch {
|
||||
// Worker PID is dead, thread never finished — crashed
|
||||
return "failed";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
|
||||
meta: { workspace: "/tmp" } as unknown as T,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
});
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
@@ -27,11 +15,11 @@ describe("validateCursorAgentConfig", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: null as unknown as ExtractFn,
|
||||
workspace: "",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("extract");
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +27,7 @@ describe("validateCursorAgentConfig", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
@@ -50,7 +38,7 @@ describe("createCursorAgent", () => {
|
||||
const agent = createCursorAgent({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
@@ -60,7 +48,7 @@ describe("createCursorAgent", () => {
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
workspace: "/tmp/test-project",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
@@ -8,12 +7,6 @@ import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
const cursorWorkspaceSchema = z.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.describe("Absolute path to the project/repository directory the agent should work in"),
|
||||
});
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
@@ -44,16 +37,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
|
||||
return async (ctx) => {
|
||||
const extractCtx: ExtractContext = {
|
||||
...ctx,
|
||||
agentContent: "",
|
||||
};
|
||||
const extracted = await config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const { workspace } = extracted.meta;
|
||||
const workspace = config.workspace;
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
extract: ExtractFn;
|
||||
workspace: string;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (typeof config.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
if (typeof config.workspace !== "string" || config.workspace.length === 0) {
|
||||
return err("workspace must be a non-empty string (absolute path)");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeCtx(userContent: string): AgentContext {
|
||||
start: {
|
||||
role: START,
|
||||
content: userContent,
|
||||
meta: { maxRounds: 10 },
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
},
|
||||
depth: 0,
|
||||
|
||||
@@ -21,7 +21,6 @@ function isStartPayload(value: unknown): value is StartNodePayload {
|
||||
return (
|
||||
typeof value.name === "string" &&
|
||||
typeof value.hash === "string" &&
|
||||
typeof value.maxRounds === "number" &&
|
||||
typeof value.depth === "number"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function authHeaders(): Record<string, string> {
|
||||
|
||||
function agentBase(agent: string): string {
|
||||
if (GATEWAY_URL) {
|
||||
return `${GATEWAY_URL}/api/${agent}`;
|
||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
||||
}
|
||||
// Local dev: proxy via vite, no agent prefix
|
||||
return "/api";
|
||||
@@ -108,7 +108,7 @@ export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord
|
||||
|
||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
||||
const url = GATEWAY_URL || "";
|
||||
return fetchJson(url, "/endpoints");
|
||||
return fetchJson(url, "/api/gateway/endpoints");
|
||||
}
|
||||
|
||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
||||
@@ -133,9 +133,8 @@ export function runThread(
|
||||
agent: string,
|
||||
workflow: string,
|
||||
prompt: string,
|
||||
maxRounds: number = 10,
|
||||
): Promise<{ threadId: string }> {
|
||||
return postJson(agentBase(agent), "/threads", { workflow, prompt, maxRounds });
|
||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
||||
}
|
||||
|
||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { hasApiKey, clearApiKey } from "./api.ts";
|
||||
import { clearApiKey, hasApiKey } from "./api.ts";
|
||||
import { LoginPage } from "./components/login.tsx";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
@@ -20,7 +20,16 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar view={view} agent={agent} onViewChange={setView} onAgentChange={setAgent} onLogout={() => { clearApiKey(); setAuthed(false); }} />
|
||||
<Sidebar
|
||||
view={view}
|
||||
agent={agent}
|
||||
onViewChange={setView}
|
||||
onAgentChange={setAgent}
|
||||
onLogout={() => {
|
||||
clearApiKey();
|
||||
setAuthed(false);
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
||||
@@ -44,7 +44,10 @@ export function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: "var(--color-bg)" }}>
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<div
|
||||
className="p-8 rounded-lg border w-full max-w-sm"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createHighlighter, type HighlighterGeneric, type BundledLanguage, type BundledTheme } from "shiki";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
} from "shiki";
|
||||
|
||||
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
|
||||
|
||||
const LANGS: BundledLanguage[] = ["typescript", "javascript", "json", "yaml", "bash", "python", "markdown"];
|
||||
const LANGS: BundledLanguage[] = [
|
||||
"typescript",
|
||||
"javascript",
|
||||
"json",
|
||||
"yaml",
|
||||
"bash",
|
||||
"python",
|
||||
"markdown",
|
||||
];
|
||||
|
||||
function getHighlighter(): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
if (highlighterPromise === null) {
|
||||
@@ -32,7 +45,9 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
|
||||
setHtml(null);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang]);
|
||||
|
||||
if (html !== null) {
|
||||
@@ -46,7 +61,10 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="rounded overflow-x-auto text-xs my-2 p-3" style={{ background: "var(--color-bg)" }}>
|
||||
<pre
|
||||
className="rounded overflow-x-auto text-xs my-2 p-3"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
@@ -100,7 +118,8 @@ export function Markdown({ content }: { content: string }) {
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ThreadStartRecord, RoleRecord, WorkflowResultRecord, ThreadRecord } from "../api.ts";
|
||||
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
@@ -93,9 +93,7 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">{success ? "✅" : "❌"}</span>
|
||||
<span className="font-semibold text-sm">
|
||||
{success ? "Completed" : "Failed"}
|
||||
</span>
|
||||
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-mono"
|
||||
style={{
|
||||
|
||||
@@ -12,7 +12,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [maxRounds, setMaxRounds] = useState(10);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -22,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(agent, workflow, prompt, maxRounds);
|
||||
const result = await runThread(agent, workflow, prompt);
|
||||
onCreated(result.threadId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -91,29 +90,6 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
||||
placeholder="Enter the task prompt..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-max-rounds"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Max Rounds
|
||||
</label>
|
||||
<input
|
||||
id="run-max-rounds"
|
||||
type="number"
|
||||
value={maxRounds}
|
||||
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-24 px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { AgentEndpoint } from "../api.ts";
|
||||
import { listAgents } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
@@ -13,9 +13,16 @@ type Props = {
|
||||
|
||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
||||
const { status, data } = useFetch(() => listAgents(), []);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
||||
|
||||
// Auto-select first agent when none is selected
|
||||
useEffect(() => {
|
||||
if (agent === null && agents.length > 0) {
|
||||
onAgentChange(agents[0].name);
|
||||
}
|
||||
}, [agent, agents, onAgentChange]);
|
||||
|
||||
const viewItems = [
|
||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
|
||||
@@ -36,49 +43,38 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
|
||||
</div>
|
||||
|
||||
{/* Agent selector */}
|
||||
<div className="border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full text-left px-4 py-2 text-xs font-medium"
|
||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<label
|
||||
className="block text-xs font-medium mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="agent-select"
|
||||
>
|
||||
{expanded ? "▾" : "▸"} Agents
|
||||
{agent && (
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--color-accent)" }}>
|
||||
({agent})
|
||||
</span>
|
||||
Agent
|
||||
</label>
|
||||
<select
|
||||
id="agent-select"
|
||||
className="w-full rounded px-2 py-1.5 text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
value={agent ?? ""}
|
||||
onChange={(e) => onAgentChange(e.target.value || null)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<option value="">Loading…</option>
|
||||
) : agents.length === 0 ? (
|
||||
<option value="">No agents online</option>
|
||||
) : (
|
||||
agents.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
{agents.length === 0 && (
|
||||
<p className="text-xs px-2 py-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{status === "loading" ? "Loading..." : "No agents online"}
|
||||
</p>
|
||||
)}
|
||||
{agents.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.name}
|
||||
onClick={() => onAgentChange(a.name)}
|
||||
className="w-full text-left px-3 py-1.5 rounded text-xs transition-colors flex items-center gap-2"
|
||||
style={{
|
||||
background: agent === a.name ? "var(--color-accent-dim)" : "transparent",
|
||||
color: agent === a.name ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
background: a.status === "online" ? "var(--color-success)" : "var(--color-error)",
|
||||
}}
|
||||
/>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View navigation */}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||
<span>{threadId}</span>
|
||||
{sse.connected && (
|
||||
{sse.connected && !sse.completed && (
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||
|
||||
@@ -81,5 +81,12 @@ export function useHashRoute(): {
|
||||
[navigate, route.agent],
|
||||
);
|
||||
|
||||
return { view: route.view, agent: route.agent, threadId: route.threadId, setView, setAgent, setThreadId };
|
||||
return {
|
||||
view: route.view,
|
||||
agent: route.agent,
|
||||
threadId: route.threadId,
|
||||
setView,
|
||||
setAgent,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +148,16 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
||||
}),
|
||||
);
|
||||
|
||||
es.addEventListener("done", () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
completedRef.current = true;
|
||||
setCompleted(true);
|
||||
setConnected(false);
|
||||
cleanupEs();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
|
||||
@@ -34,7 +34,6 @@ function noLogger(): (tag: string, content: string) => void {
|
||||
|
||||
function makeOptions(overrides: Partial<ExecuteThreadOptions>): ExecuteThreadOptions {
|
||||
return {
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: new AbortController().signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
@@ -107,7 +106,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "hello", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -127,7 +126,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
expect(startNode.type).toBe("start");
|
||||
expect((startNode.payload as Record<string, unknown>).name).toBe("demo");
|
||||
expect((startNode.payload as Record<string, unknown>).hash).toBe(bundleHash);
|
||||
expect((startNode.payload as Record<string, unknown>).maxRounds).toBe(5);
|
||||
|
||||
const refs = startNode.refs as string[];
|
||||
expect(refs.length).toBe(1);
|
||||
@@ -164,7 +162,6 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
|
||||
const opts = makeOptions({
|
||||
storageRoot,
|
||||
maxRounds: 5,
|
||||
awaitAfterEachYield: async () => {
|
||||
const text = await readFile(join(bundleDir, "threads.json"), "utf8");
|
||||
const parsed = JSON.parse(text) as Record<string, { head: string }>;
|
||||
@@ -228,7 +225,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
@@ -279,7 +276,7 @@ describe("executeThread (Phase 2 — CAS thread storage)", () => {
|
||||
wf,
|
||||
"demo",
|
||||
{ prompt: "p", steps: [] },
|
||||
makeOptions({ storageRoot, maxRounds: 5 }),
|
||||
makeOptions({ storageRoot }),
|
||||
io,
|
||||
noLogger(),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,7 @@ 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 } from "@uncaged/workflow-cas";
|
||||
import { type ExtractContext, START } from "@uncaged/workflow-runtime";
|
||||
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createExtract } from "../src/extract/extract-fn.js";
|
||||
@@ -45,21 +44,9 @@ describe("createExtract — ExtractResult shape", () => {
|
||||
);
|
||||
|
||||
const schema = z.object({ confidence: z.number() });
|
||||
const ctx: ExtractContext = {
|
||||
threadId: "01THREADTESTAAAAAAAAAAAAAA",
|
||||
depth: 0,
|
||||
start: {
|
||||
role: START,
|
||||
content: "task text",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: 100,
|
||||
},
|
||||
steps: [],
|
||||
currentRole: { name: "analyst", systemPrompt: "be precise" },
|
||||
agentContent: "model says hello",
|
||||
};
|
||||
const contentHash = await putContentNodeWithRefs(cas, "model says hello", []);
|
||||
|
||||
const out = await extract(schema, "extract fields", ctx);
|
||||
const out = await extract(schema, contentHash);
|
||||
|
||||
expect(out.meta).toEqual({ confidence: 0.9 });
|
||||
expect(out.contentPayload).toBe("model says hello");
|
||||
|
||||
@@ -45,7 +45,6 @@ describe("garbageCollectCas (mark-and-sweep)", () => {
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
|
||||
@@ -211,17 +211,17 @@ async function maybeSupervisorHaltsThread(params: {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "stop") {
|
||||
if (sup.value !== "kill") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} killed by supervisor`);
|
||||
return finalizeThread({
|
||||
cas: params.cas,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
startHash: params.startHash,
|
||||
chain: params.chain,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
completion: { returnCode: 1, summary: "killed: supervisor detected pathological behavior" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,21 +284,6 @@ async function driveWorkflowGenerator(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (written >= executeOptions.maxRounds) {
|
||||
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
|
||||
return await finalizeThread({
|
||||
cas,
|
||||
bundleDir,
|
||||
threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
|
||||
if (iterResult.done) {
|
||||
@@ -383,7 +368,7 @@ async function driveWorkflowGenerator(params: {
|
||||
* Persistence layout (RFC v3 — CAS-based thread storage):
|
||||
* - Thread chain is written as immutable CAS blobs: a single {@link StartNode}
|
||||
* plus one {@link StateNode} per role step (including a final `__end__`
|
||||
* state on completion / abort / `maxRounds`).
|
||||
* state on completion / abort).
|
||||
* - The active thread head is published in `<bundleDir>/threads.json`; on
|
||||
* completion it is removed and a record is appended to
|
||||
* `<bundleDir>/history/{YYYY-MM-DD}.jsonl`.
|
||||
@@ -433,7 +418,6 @@ export async function executeThread(
|
||||
{
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
},
|
||||
promptHash,
|
||||
@@ -475,21 +459,6 @@ export async function executeThread(
|
||||
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (options.maxRounds <= 0) {
|
||||
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||
return await finalizeThread({
|
||||
cas: io.cas,
|
||||
bundleDir,
|
||||
threadId: io.threadId,
|
||||
startHash,
|
||||
chain,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
@@ -501,7 +470,7 @@ export async function executeThread(
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
meta: {},
|
||||
timestamp: nowMs,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
|
||||
@@ -104,9 +104,7 @@ async function readPromptText(cas: CasStore, promptHash: string): Promise<Result
|
||||
async function readStartWorkflowIdentity(params: {
|
||||
cas: CasStore;
|
||||
startHash: string;
|
||||
}): Promise<
|
||||
Result<{ workflowName: string; maxRounds: number; depth: number; prompt: string }, string>
|
||||
> {
|
||||
}): Promise<Result<{ workflowName: string; depth: number; prompt: string }, string>> {
|
||||
const yamlText = await params.cas.get(params.startHash);
|
||||
if (yamlText === null) {
|
||||
return err(`start node missing in CAS: ${params.startHash}`);
|
||||
@@ -127,7 +125,6 @@ async function readStartWorkflowIdentity(params: {
|
||||
const p = parsed.node.payload;
|
||||
return ok({
|
||||
workflowName: p.name,
|
||||
maxRounds: p.maxRounds,
|
||||
depth: p.depth,
|
||||
prompt: prompt.value,
|
||||
});
|
||||
@@ -317,7 +314,7 @@ export async function prepareCasFork(params: {
|
||||
hash: params.bundleHash,
|
||||
sourceThreadId: params.sourceThreadId,
|
||||
prompt: id.value.prompt,
|
||||
runOptions: { maxRounds: id.value.maxRounds, depth: id.value.depth },
|
||||
runOptions: { depth: id.value.depth },
|
||||
steps,
|
||||
stepTimestamps,
|
||||
forkContinuation: cont.value,
|
||||
|
||||
@@ -12,12 +12,12 @@ const SUPERVISOR_MAX_REACT_ROUNDS = 4;
|
||||
|
||||
const supervisorDecisionSchema = z
|
||||
.object({
|
||||
decision: z.enum(["continue", "stop"]),
|
||||
decision: z.enum(["continue", "kill"]),
|
||||
})
|
||||
.meta({
|
||||
title: "supervisor_decision",
|
||||
description:
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress or following its normal role sequence; "kill" only when the thread is stuck in an infinite loop, producing no meaningful progress, or has gone off the rails. Normal workflow completion is handled by the moderator — the supervisor should NOT kill a thread just because it looks done.',
|
||||
});
|
||||
|
||||
type SupervisorThreadContext = Record<string, never>;
|
||||
@@ -63,7 +63,7 @@ export async function runSupervisor(
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
|
||||
`You supervise a multi-step workflow. Your job is to detect pathological situations — NOT to decide when the workflow is "done" (that is the moderator's job). Reply with "continue" when the thread is making progress or following its normal role sequence. Reply with "kill" ONLY when the thread is stuck in an infinite loop, producing repetitive/meaningless output, or has clearly gone off the rails. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"continue"}.`,
|
||||
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CasStore } from "@uncaged/workflow-cas";
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { Result } from "@uncaged/workflow-util";
|
||||
|
||||
export type SupervisorDecision = "continue" | "stop";
|
||||
export type SupervisorDecision = "continue" | "kill";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
@@ -39,7 +39,6 @@ export type PrefilledDiskStep = {
|
||||
};
|
||||
|
||||
export type ExecuteThreadOptions = {
|
||||
maxRounds: number;
|
||||
/** Passed to the bundle thread context as `ThreadContext.depth`. */
|
||||
depth: number;
|
||||
signal: AbortSignal;
|
||||
@@ -68,7 +67,7 @@ export type CasForkPlan = {
|
||||
hash: string;
|
||||
sourceThreadId: string;
|
||||
prompt: string;
|
||||
runOptions: { maxRounds: number; depth: number };
|
||||
runOptions: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
stepTimestamps: number[];
|
||||
forkContinuation: ForkContinuationOptions;
|
||||
|
||||
@@ -32,7 +32,7 @@ type RunCommand = {
|
||||
threadId: string;
|
||||
workflowName: string;
|
||||
prompt: string;
|
||||
options: { maxRounds: number; depth: number };
|
||||
options: { depth: number };
|
||||
steps: RoleOutput[];
|
||||
/** Timestamps aligned with `steps` for replay / fork restore; length must match `steps` when steps are non-empty. */
|
||||
stepTimestamps: number[] | null;
|
||||
@@ -185,10 +185,6 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
|
||||
return null;
|
||||
}
|
||||
const optRec = options as Record<string, unknown>;
|
||||
const maxRounds = optRec.maxRounds;
|
||||
if (typeof maxRounds !== "number") {
|
||||
return null;
|
||||
}
|
||||
const depthRaw = optRec.depth;
|
||||
const depth =
|
||||
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
|
||||
@@ -210,7 +206,7 @@ function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null
|
||||
threadId,
|
||||
workflowName,
|
||||
prompt,
|
||||
options: { maxRounds, depth },
|
||||
options: { depth },
|
||||
steps: parsedSteps.steps,
|
||||
stepTimestamps: parsedSteps.stepTimestamps,
|
||||
forkSourceThreadId,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type {
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type { ExtractFn, ExtractResult, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
|
||||
@@ -31,7 +26,7 @@ const CAS_GET_TOOL_DEFINITION = {
|
||||
},
|
||||
};
|
||||
|
||||
export type ExtractThreadContext = {
|
||||
type ExtractThreadContext = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
@@ -39,41 +34,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
ctx: ExtractContext,
|
||||
prompt: string,
|
||||
deps: ExtractDeps,
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Role: ${ctx.currentRole.name}`);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
const body = await getContentMerklePayload(deps.cas, step.contentHash);
|
||||
if (body === null) {
|
||||
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
|
||||
}
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(body);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Agent Output");
|
||||
lines.push(ctx.agentContent);
|
||||
lines.push("");
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
*
|
||||
@@ -102,7 +62,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, refs for content nodes or children for step/thread legacy nodes) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
`You extract structured metadata from content. The content is from a CAS node. Use cas_get to read referenced nodes if needed. When ready, call the ${structuredToolName} tool with JSON matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
toolHandler: async (call, thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `Unexpected tool routed to handler: ${call.function.name}`;
|
||||
@@ -124,10 +84,13 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
|
||||
return async <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
contentHash: string,
|
||||
): Promise<ExtractResult<T>> => {
|
||||
const text = await buildExtractUserContent(ctx, prompt, deps);
|
||||
const payload = await getContentMerklePayload(deps.cas, contentHash);
|
||||
if (payload === null) {
|
||||
throw new Error(`extract: missing CAS content node for hash ${contentHash}`);
|
||||
}
|
||||
const text = `${payload}\n\nExtract structured metadata according to the schema.`;
|
||||
const result = await reactor({
|
||||
thread: { cas: deps.cas },
|
||||
input: text,
|
||||
@@ -138,7 +101,7 @@ export function createExtract(provider: LlmProvider, deps: ExtractDeps): Extract
|
||||
}
|
||||
return {
|
||||
meta: result.value,
|
||||
contentPayload: ctx.agentContent,
|
||||
contentPayload: payload,
|
||||
refs: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
} from "./extract-fn.js";
|
||||
export { createExtract } from "./extract-fn.js";
|
||||
export {
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
|
||||
@@ -37,9 +37,7 @@ export { EMPTY_CHAIN_STATE } from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
|
||||
@@ -95,7 +95,6 @@ export function workflowAsAgent(
|
||||
workflowName,
|
||||
input,
|
||||
{
|
||||
maxRounds: ctx.start.meta.maxRounds,
|
||||
depth: nextDepth,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
@@ -108,7 +107,7 @@ export function workflowAsAgent(
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
return result.rootHash;
|
||||
return `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
|
||||
@@ -23,17 +23,10 @@ const app = new Hono<Env>();
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
// ── Dashboard API key auth (skip healthz + register) ─────────────
|
||||
app.use("/endpoints", async (c, next) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
await next();
|
||||
});
|
||||
app.use("/api/*", async (c, next) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
await next();
|
||||
});
|
||||
|
||||
function checkDashboardAuth(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env["Bindings"] }): boolean {
|
||||
function checkDashboardAuth(c: {
|
||||
req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined };
|
||||
env: Env["Bindings"];
|
||||
}): boolean {
|
||||
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||
const query = c.req.query("key");
|
||||
const key = bearer ?? query;
|
||||
@@ -43,9 +36,16 @@ function checkDashboardAuth(c: { req: { header: (n: string) => string | undefine
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Register / heartbeat ────────────────────────────────────────────
|
||||
app.post("/register", async (c) => {
|
||||
const body = await c.req.json<{ name?: string; url?: string; secret?: string; agentToken?: string }>();
|
||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
||||
const gateway = new Hono<Env>();
|
||||
|
||||
gateway.post("/register", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
agentToken?: string;
|
||||
}>();
|
||||
const { name, url, secret, agentToken } = body;
|
||||
|
||||
if (!name || !url) {
|
||||
@@ -74,8 +74,7 @@ app.post("/register", async (c) => {
|
||||
return c.json({ registered: name }, status);
|
||||
});
|
||||
|
||||
// ── Unregister ──────────────────────────────────────────────────────
|
||||
app.delete("/register/:name", async (c) => {
|
||||
gateway.delete("/register/:name", async (c) => {
|
||||
const auth = c.req.header("Authorization");
|
||||
if (auth !== `Bearer ${c.env.GATEWAY_SECRET}`) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
@@ -86,8 +85,10 @@ app.delete("/register/:name", async (c) => {
|
||||
return c.json({ unregistered: name });
|
||||
});
|
||||
|
||||
// ── List endpoints ──────────────────────────────────────────────────
|
||||
app.get("/endpoints", async (c) => {
|
||||
// endpoints requires dashboard auth
|
||||
gateway.get("/endpoints", async (c) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
|
||||
const list = await c.env.ENDPOINTS.list();
|
||||
const endpoints: Array<{ name: string; url: string; status: string; lastHeartbeat: number }> = [];
|
||||
|
||||
@@ -107,8 +108,11 @@ app.get("/endpoints", async (c) => {
|
||||
return c.json(endpoints);
|
||||
});
|
||||
|
||||
// ── API proxy: /api/:agent/* → agent's tunnel URL ───────────────────
|
||||
app.all("/api/:agent/*", async (c) => {
|
||||
app.route("/api/gateway", gateway);
|
||||
|
||||
// ── API proxy: /api/agents/:agent/* → agent's tunnel URL (dashboard auth) ──
|
||||
app.all("/api/agents/:agent/*", async (c) => {
|
||||
if (!checkDashboardAuth(c)) return c.json({ error: "unauthorized" }, 401);
|
||||
const agent = c.req.param("agent");
|
||||
const record = await c.env.ENDPOINTS.get<EndpointRecord>(agent, "json");
|
||||
|
||||
@@ -118,7 +122,7 @@ app.all("/api/:agent/*", async (c) => {
|
||||
|
||||
// Build target URL: strip /api/:agent prefix, forward the rest
|
||||
const url = new URL(c.req.url);
|
||||
const pathAfterAgent = url.pathname.replace(`/api/${agent}`, "");
|
||||
const pathAfterAgent = url.pathname.replace(`/api/agents/${agent}`, "");
|
||||
const targetUrl = `${record.url}/api${pathAfterAgent}${url.search}`;
|
||||
|
||||
const headers = new Headers(c.req.raw.headers);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { tableToModerator } from "../src/moderator-table.js";
|
||||
import type { ModeratorContext, ModeratorTable, StartStep } from "../src/types.js";
|
||||
import { END, START } from "../src/types.js";
|
||||
|
||||
type TestMeta = {
|
||||
planner: { plan: string };
|
||||
coder: { code: string };
|
||||
reviewer: { approved: boolean };
|
||||
};
|
||||
|
||||
function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta> {
|
||||
const steps = roles.map((role, i) => ({
|
||||
role,
|
||||
meta: {} as TestMeta[typeof role],
|
||||
contentHash: `hash-${i}`,
|
||||
refs: [],
|
||||
timestamp: Date.now() + i,
|
||||
}));
|
||||
return {
|
||||
threadId: "test-thread",
|
||||
depth: 0,
|
||||
start: {
|
||||
role: START,
|
||||
content: "test",
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
} as StartStep,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tableToModerator", () => {
|
||||
test("START -> role A (FALLBACK) returns A on first call", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
|
||||
test("condition true wins over FALLBACK", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "always",
|
||||
description: "always true",
|
||||
check: () => true,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
|
||||
test("condition false falls through to FALLBACK", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "never",
|
||||
description: "always false",
|
||||
check: () => false,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe("coder");
|
||||
});
|
||||
|
||||
test("no matching transitions returns END", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "never",
|
||||
description: "always false",
|
||||
check: () => false,
|
||||
},
|
||||
role: "planner",
|
||||
},
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx([]))).toBe(END);
|
||||
});
|
||||
|
||||
test("multi-step: A -> FALLBACK END returns END after A", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: END }],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
expect(mod(makeCtx(["planner"]))).toBe(END);
|
||||
});
|
||||
|
||||
test("role not in table returns END", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: "coder" }],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
// coder has empty transitions array
|
||||
expect(mod(makeCtx(["planner", "coder"]))).toBe(END);
|
||||
});
|
||||
|
||||
test("condition receives ctx", () => {
|
||||
const table: ModeratorTable<TestMeta> = {
|
||||
[START]: [
|
||||
{
|
||||
condition: {
|
||||
name: "has-steps",
|
||||
description: "checks ctx.steps",
|
||||
check: (ctx) => ctx.steps.length > 0,
|
||||
},
|
||||
role: "coder",
|
||||
},
|
||||
{ condition: "FALLBACK", role: "planner" },
|
||||
],
|
||||
planner: [],
|
||||
coder: [],
|
||||
reviewer: [],
|
||||
};
|
||||
const mod = tableToModerator(table);
|
||||
// No steps -> condition false -> FALLBACK -> planner
|
||||
expect(mod(makeCtx([]))).toBe("planner");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
export type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
maxRounds: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,12 +14,15 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
@@ -47,3 +50,7 @@ export { END, START } from "./types.js";
|
||||
// ── Constructor functions ──────────────────────────────────────────
|
||||
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
// ── Moderator Table ────────────────────────────────────────────────
|
||||
|
||||
export { tableToModerator } from "./moderator-table.js";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Moderator, ModeratorTable, RoleMeta } from "./types.js";
|
||||
import { END, START } from "./types.js";
|
||||
|
||||
export function tableToModerator<M extends RoleMeta>(table: ModeratorTable<M>): Moderator<M> {
|
||||
return (ctx) => {
|
||||
const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null;
|
||||
const currentRole: string = lastStep ? lastStep.role : START;
|
||||
|
||||
const transitions = (table as Record<string, (typeof table)[string]>)[currentRole];
|
||||
if (!transitions) {
|
||||
return END;
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
|
||||
return transition.role;
|
||||
}
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export type RoleOutput = {
|
||||
export type StartStep = {
|
||||
role: typeof START;
|
||||
content: string;
|
||||
meta: { maxRounds: number };
|
||||
meta: Record<string, never>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
@@ -76,10 +76,6 @@ export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> &
|
||||
};
|
||||
};
|
||||
|
||||
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
};
|
||||
|
||||
// ── Workflow Completion ────────────────────────────────────────────
|
||||
|
||||
export type WorkflowCompletion = {
|
||||
@@ -128,8 +124,7 @@ export type ExtractResult<T extends Record<string, unknown>> = {
|
||||
|
||||
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
contentHash: string,
|
||||
) => Promise<ExtractResult<T>>;
|
||||
|
||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
@@ -154,7 +149,6 @@ export type WorkflowFn = (
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
@@ -169,6 +163,28 @@ export type WorkflowDefinition<M extends RoleMeta> = {
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
// ── Declarative Moderator Table ────────────────────────────────────
|
||||
|
||||
export type ModeratorCondition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
description: string;
|
||||
check: (ctx: ModeratorContext<M>) => boolean;
|
||||
};
|
||||
|
||||
export type FALLBACK = "FALLBACK";
|
||||
|
||||
export type ModeratorTransition<M extends RoleMeta> = {
|
||||
condition: ModeratorCondition<M> | FALLBACK;
|
||||
role: (keyof M & string) | typeof END;
|
||||
};
|
||||
|
||||
export type ModeratorTable<M extends RoleMeta> = Record<
|
||||
(keyof M & string) | typeof START,
|
||||
ModeratorTransition<M>[]
|
||||
>;
|
||||
|
||||
// ── Advance Outcome ────────────────────────────────────────────────
|
||||
|
||||
export type AdvanceOutcome<M extends RoleMeta> =
|
||||
| { kind: "complete"; completion: WorkflowCompletion }
|
||||
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHAAAAAAAAAAA";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 99, depth: 2 },
|
||||
{ name: "demo", hash: bundleHash, depth: 2 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("buildThreadContext", () => {
|
||||
expect(ctx.depth).toBe(2);
|
||||
expect(ctx.start.role).toBe(START);
|
||||
expect(ctx.start.content).toBe("hello-task");
|
||||
expect(ctx.start.meta.maxRounds).toBe(99);
|
||||
expect(ctx.steps.map((s) => s.role)).toEqual(["planner", "coder"]);
|
||||
expect(ctx.steps[0]?.refs).toEqual([art]);
|
||||
expect(ctx.steps[1]?.refs).toEqual([]);
|
||||
@@ -72,7 +71,7 @@ describe("buildThreadContext", () => {
|
||||
const promptHash = await cas.put("only-prompt");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", maxRounds: 3, depth: 1 },
|
||||
{ name: "solo", hash: "BHBBBBBBBBBBB", depth: 1 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
@@ -80,7 +79,6 @@ describe("buildThreadContext", () => {
|
||||
expect(ctx.steps).toEqual([]);
|
||||
expect(ctx.start.content).toBe("only-prompt");
|
||||
expect(ctx.depth).toBe(1);
|
||||
expect(ctx.start.meta.maxRounds).toBe(3);
|
||||
});
|
||||
|
||||
test("omits __end__ states from steps", async () => {
|
||||
@@ -89,7 +87,7 @@ describe("buildThreadContext", () => {
|
||||
const bundleHash = "BHCCCCCCCCCCC";
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{ name: "demo", hash: bundleHash, maxRounds: 10, depth: 0 },
|
||||
{ name: "demo", hash: bundleHash, depth: 0 },
|
||||
promptHash,
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ async function threadFromStartHead<M extends RoleMeta>(
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: p.maxRounds },
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
},
|
||||
steps: [],
|
||||
@@ -116,7 +116,7 @@ async function threadFromStateHead<M extends RoleMeta>(
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: sp.maxRounds },
|
||||
meta: {},
|
||||
timestamp: firstTs,
|
||||
},
|
||||
steps,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
END,
|
||||
type ExtractContext,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
@@ -89,15 +88,11 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const agent = agentForRole(binding, next);
|
||||
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||
|
||||
const extractCtx: ExtractContext<M> = {
|
||||
...agentCtx,
|
||||
agentContent: raw,
|
||||
};
|
||||
const agentContentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
|
||||
const extracted = await runtime.extract(
|
||||
roleDef.schema as z.ZodType<Record<string, unknown>>,
|
||||
roleDef.extractPrompt,
|
||||
extractCtx as unknown as ExtractContext,
|
||||
agentContentHash,
|
||||
);
|
||||
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
@@ -106,11 +101,10 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
);
|
||||
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
|
||||
|
||||
const contentHash = await putContentNodeWithRefs(
|
||||
runtime.cas,
|
||||
extracted.contentPayload,
|
||||
artifactRefs,
|
||||
);
|
||||
const contentHash =
|
||||
artifactRefs.length === 0
|
||||
? agentContentHash
|
||||
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
|
||||
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
|
||||
|
||||
const step = {
|
||||
@@ -151,17 +145,9 @@ export function createWorkflow<M extends RoleMeta>(
|
||||
if (thread.start.role !== START) {
|
||||
throw new Error(`workflow loop expected start role to be ${START}`);
|
||||
}
|
||||
const maxRounds = thread.start.meta.maxRounds;
|
||||
let currentThread = thread as ModeratorContext<M>;
|
||||
|
||||
while (true) {
|
||||
if (currentThread.steps.length >= maxRounds) {
|
||||
return {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${maxRounds})`,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await advanceOneRound(def, binding, {
|
||||
thread: currentThread,
|
||||
runtime,
|
||||
|
||||
@@ -6,12 +6,15 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
@@ -28,4 +31,4 @@ export type {
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
export { END, START, tableToModerator } from "./types.js";
|
||||
|
||||
@@ -8,12 +8,17 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
LlmProvider,
|
||||
Moderator,
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
ProviderConfig,
|
||||
ResolvedModel,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleMeta,
|
||||
@@ -31,4 +36,4 @@ export type {
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
|
||||
|
||||
@@ -13,23 +13,20 @@ const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
},
|
||||
];
|
||||
|
||||
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
|
||||
function makeStart(): ModeratorContext<DevelopMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Implement the feature",
|
||||
meta: { maxRounds },
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
maxRounds: number,
|
||||
steps: ModeratorContext<DevelopMeta>["steps"],
|
||||
): ModeratorContext<DevelopMeta> {
|
||||
function makeCtx(steps: ModeratorContext<DevelopMeta>["steps"]): ModeratorContext<DevelopMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
start: makeStart(maxRounds),
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -90,20 +87,18 @@ function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
|
||||
|
||||
describe("developModerator", () => {
|
||||
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
|
||||
expect(developModerator(makeCtx(20, []))).toBe("planner");
|
||||
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
expect(developModerator(makeCtx([]))).toBe("planner");
|
||||
expect(developModerator(makeCtx([plannerStep()]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"tester",
|
||||
);
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
|
||||
),
|
||||
developModerator(makeCtx([plannerStep(), coderStep(), reviewerStep(true), testerStep(true)])),
|
||||
).toBe("committer");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [
|
||||
makeCtx([
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
@@ -120,16 +115,16 @@ describe("developModerator", () => {
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
||||
test("reviewer rejects → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(4, steps))).toBe(END);
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → coder retry when budget allows", () => {
|
||||
@@ -139,17 +134,17 @@ describe("developModerator", () => {
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("tester failed → END when max rounds exhausted", () => {
|
||||
test("tester failed → coder retry (supervisor controls termination)", () => {
|
||||
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
testerStep(false),
|
||||
];
|
||||
expect(developModerator(makeCtx(5, steps))).toBe(END);
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||
@@ -157,13 +152,11 @@ describe("developModerator", () => {
|
||||
{ hash: "AA000001", title: "first phase" },
|
||||
{ hash: "AA000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
||||
"coder",
|
||||
);
|
||||
expect(developModerator(makeCtx([plannerStep(phases)]))).toBe("coder");
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("AA000001")]))).toBe("coder");
|
||||
expect(
|
||||
developModerator(
|
||||
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||
makeCtx([plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||
),
|
||||
).toBe("reviewer");
|
||||
});
|
||||
@@ -175,7 +168,7 @@ describe("developModerator", () => {
|
||||
{ hash: "BB000003", title: "verify" },
|
||||
{ hash: "BB000004", title: "polish" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||
"reviewer",
|
||||
);
|
||||
});
|
||||
@@ -185,12 +178,10 @@ describe("developModerator", () => {
|
||||
{ hash: "CC000001", title: "first phase" },
|
||||
{ hash: "CC000002", title: "second phase" },
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
||||
"coder",
|
||||
);
|
||||
expect(developModerator(makeCtx([plannerStep(phases), coderStep("all-done")]))).toBe("coder");
|
||||
});
|
||||
|
||||
test("incomplete phases → END when max rounds exhausted", () => {
|
||||
test("incomplete phases → coder retry (supervisor controls termination)", () => {
|
||||
const phases: PlannerMeta["phases"] = [
|
||||
{ hash: "DD000001", title: "first phase" },
|
||||
{ hash: "DD000002", title: "second phase" },
|
||||
@@ -199,7 +190,7 @@ describe("developModerator", () => {
|
||||
plannerStep(phases),
|
||||
coderStep("DD000001"),
|
||||
];
|
||||
expect(developModerator(makeCtx(3, steps))).toBe(END);
|
||||
expect(developModerator(makeCtx(steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("committer → END for any committer meta status", () => {
|
||||
@@ -220,9 +211,9 @@ describe("developModerator", () => {
|
||||
reviewerStep(true),
|
||||
testerStep(true),
|
||||
];
|
||||
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
|
||||
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, committed]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, recoverable]))).toBe(END);
|
||||
expect(developModerator(makeCtx([...base, unrecoverable]))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* develop bundle entry — 小橘 🍊
|
||||
*/
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { createExtract } from "@uncaged/workflow-execute";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnv(name: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const provider = {
|
||||
baseUrl:
|
||||
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
|
||||
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
|
||||
};
|
||||
|
||||
const agent = createHermesAgent({
|
||||
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
|
||||
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
|
||||
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
|
||||
: null,
|
||||
});
|
||||
|
||||
const extract = createExtract(provider);
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = wf.run;
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
END,
|
||||
type ModeratorCondition,
|
||||
type ModeratorTable,
|
||||
START,
|
||||
tableToModerator,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { DevelopMeta } from "./roles.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function coderFinishedAllPlannedPhases(
|
||||
phases: ReadonlyArray<{ hash: string }>,
|
||||
coderCompletedPhases: ReadonlyArray<string>,
|
||||
@@ -22,68 +29,63 @@ function coderFinishedAllPlannedPhases(
|
||||
return false;
|
||||
}
|
||||
|
||||
function nextAfterCoder(
|
||||
ctx: ModeratorContext<DevelopMeta>,
|
||||
maxRounds: number,
|
||||
): (keyof DevelopMeta & string) | typeof END {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return "reviewer";
|
||||
}
|
||||
const phases = plannerStep.meta.phases;
|
||||
const coderCompletedPhases = ctx.steps
|
||||
.filter((s) => s.role === "coder")
|
||||
.map((s) => s.meta.completedPhase);
|
||||
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||
if (allDone) {
|
||||
return "reviewer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
// ── Conditions ─────────────────────────────────────────────────────
|
||||
|
||||
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
|
||||
const maxRounds = ctx.start.meta.maxRounds;
|
||||
|
||||
if (ctx.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") {
|
||||
return "coder";
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
return nextAfterCoder(ctx, maxRounds);
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.status === "approved") {
|
||||
return "tester";
|
||||
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
|
||||
name: "allPhasesComplete",
|
||||
description: "All planned phases have been completed by the coder",
|
||||
check: (ctx) => {
|
||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||
if (plannerStep === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
const phases = plannerStep.meta.phases;
|
||||
if (!Array.isArray(phases)) {
|
||||
return true;
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.status === "passed") {
|
||||
return "committer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
const coderCompletedPhases = ctx.steps
|
||||
.filter((s) => s.role === "coder")
|
||||
.map((s) => s.meta.completedPhase);
|
||||
return coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||
},
|
||||
};
|
||||
|
||||
const reviewApproved: ModeratorCondition<DevelopMeta> = {
|
||||
name: "reviewApproved",
|
||||
description: "The last reviewer approved the changes",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "reviewer" && last.meta.status === "approved";
|
||||
},
|
||||
};
|
||||
|
||||
const testsPassed: ModeratorCondition<DevelopMeta> = {
|
||||
name: "testsPassed",
|
||||
description: "The last tester reported tests passed",
|
||||
check: (ctx) => {
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
return last.role === "tester" && last.meta.status === "passed";
|
||||
},
|
||||
};
|
||||
|
||||
// ── Transition Table ───────────────────────────────────────────────
|
||||
|
||||
const table: ModeratorTable<DevelopMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: "coder" }],
|
||||
coder: [
|
||||
{ condition: allPhasesComplete, role: "reviewer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
reviewer: [
|
||||
{ condition: reviewApproved, role: "tester" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
tester: [
|
||||
{ condition: testsPassed, role: "committer" },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const developModerator = tableToModerator(table);
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z.string(),
|
||||
completedPhase: z
|
||||
.string()
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
@@ -27,8 +31,6 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
};
|
||||
|
||||
@@ -28,8 +28,6 @@ Do not attempt to fix failures yourself.`;
|
||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -44,8 +44,6 @@ Order phases so earlier steps unblock later ones. Cover root cause, edge cases,
|
||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
};
|
||||
|
||||
@@ -37,8 +37,6 @@ Be thorough. A false approve costs more than a false reject.`;
|
||||
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, an
|
||||
export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -98,23 +98,22 @@ function installMockToolCallCompletions(
|
||||
};
|
||||
}
|
||||
|
||||
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
meta: { maxRounds },
|
||||
meta: {},
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
maxRounds: number,
|
||||
steps: ModeratorContext<SolveIssueMeta>["steps"],
|
||||
): ModeratorContext<SolveIssueMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
start: makeStart(maxRounds),
|
||||
start: makeStart(),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -182,7 +181,7 @@ function makeThread(prompt: string) {
|
||||
start: {
|
||||
role: START,
|
||||
content: prompt,
|
||||
meta: { maxRounds: 20 },
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
steps: [],
|
||||
@@ -191,12 +190,12 @@ function makeThread(prompt: string) {
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes initial → preparer → developer → submitter → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
||||
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
|
||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
|
||||
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep()]))).toBe("developer");
|
||||
expect(solveIssueModerator(makeCtx([preparerStep(), developerStep()]))).toBe("submitter");
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({
|
||||
@@ -211,7 +210,7 @@ describe("solveIssueModerator", () => {
|
||||
test("submitter failed → END", () => {
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "failed", error: "gh not authenticated" }),
|
||||
@@ -225,7 +224,7 @@ describe("solveIssueModerator", () => {
|
||||
// routed to END, since the moderator is a closed switch over known roles.
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [
|
||||
makeCtx([
|
||||
preparerStep(),
|
||||
developerStep(),
|
||||
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
|
||||
|
||||
@@ -16,21 +16,10 @@ The actual implementation (planning → coding → reviewing → testing → com
|
||||
|
||||
Pass through the task and let the child workflow do the work.`;
|
||||
|
||||
const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary.
|
||||
|
||||
Procedure:
|
||||
1. cas_get(<rootHash>) — the root node lists all child step hashes (planner, coder, reviewer, tester, committer).
|
||||
2. Find the committer step. cas_get its hash to read the committer's meta — extract branch and commitSha from there.
|
||||
3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps.
|
||||
4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them).
|
||||
|
||||
Return: { branch, commitSha, filesChanged, summary }.`;
|
||||
|
||||
export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||
description:
|
||||
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||
systemPrompt: DEVELOPER_SYSTEM,
|
||||
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||
schema: developerMetaSchema,
|
||||
extractRefs: () => [],
|
||||
};
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import type { Moderator } from "@uncaged/workflow-runtime";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "preparer";
|
||||
}
|
||||
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
|
||||
if (last.role === "preparer") {
|
||||
return "developer";
|
||||
}
|
||||
|
||||
if (last.role === "developer") {
|
||||
return "submitter";
|
||||
}
|
||||
|
||||
if (last.role === "submitter") {
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
const table: ModeratorTable<SolveIssueMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "preparer" }],
|
||||
preparer: [{ condition: "FALLBACK", role: "developer" }],
|
||||
developer: [{ condition: "FALLBACK", role: "submitter" }],
|
||||
submitter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const solveIssueModerator = tableToModerator(table);
|
||||
|
||||
@@ -44,8 +44,6 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
description:
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -31,13 +31,9 @@ Read the thread for context:
|
||||
|
||||
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`;
|
||||
|
||||
const SUBMITTER_EXTRACT_PROMPT =
|
||||
"Extract the submission result. status='submitted' with prUrl on success, or status='failed' with a short error message on failure.";
|
||||
|
||||
export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: SUBMITTER_SYSTEM,
|
||||
extractPrompt: SUBMITTER_EXTRACT_PROMPT,
|
||||
schema: submitterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ function startTask(content: string): AgentContext["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content,
|
||||
meta: { maxRounds: 5 },
|
||||
meta: {},
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user