Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe87efd79d | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 |
@@ -4,3 +4,4 @@ bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,7 +34,7 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let maxRounds = 5;
|
||||
let maxRounds = 10;
|
||||
|
||||
let i = 0;
|
||||
const first = argv[0];
|
||||
|
||||
@@ -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,21 @@ ${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) |
|
||||
|
||||
## Defaults
|
||||
|
||||
| Setting | CLI | HTTP API |
|
||||
|---------|-----|----------|
|
||||
| \`maxRounds\` | 10 | 10 |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -103,7 +124,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 +223,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");
|
||||
|
||||
@@ -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 {
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
} 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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type {
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
LlmProvider,
|
||||
@@ -31,7 +30,7 @@ const CAS_GET_TOOL_DEFINITION = {
|
||||
},
|
||||
};
|
||||
|
||||
export type ExtractThreadContext = {
|
||||
type ExtractThreadContext = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
@@ -39,41 +38,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 +66,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 +88,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 +105,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,
|
||||
|
||||
@@ -108,7 +108,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}`;
|
||||
|
||||
@@ -33,7 +33,10 @@ app.use("/api/*", async (c, next) => {
|
||||
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;
|
||||
@@ -45,7 +48,12 @@ 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 }>();
|
||||
const body = await c.req.json<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
agentToken?: string;
|
||||
}>();
|
||||
const { name, url, secret, agentToken } = body;
|
||||
|
||||
if (!name || !url) {
|
||||
|
||||
@@ -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: { maxRounds: 10 },
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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> };
|
||||
|
||||
@@ -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,9 @@ 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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,72 @@ 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 hasRoundsRemaining: ModeratorCondition<DevelopMeta> = {
|
||||
name: "hasRoundsRemaining",
|
||||
description: "There are rounds remaining before hitting maxRounds",
|
||||
check: (ctx) => ctx.steps.length < ctx.start.meta.maxRounds - 1,
|
||||
};
|
||||
|
||||
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: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
],
|
||||
reviewer: [
|
||||
{ condition: reviewApproved, role: "tester" },
|
||||
{ condition: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
],
|
||||
tester: [
|
||||
{ condition: testsPassed, role: "committer" },
|
||||
{ condition: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
],
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const developModerator = tableToModerator(table);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 +27,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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user