Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c |
@@ -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\`。
|
||||
|
||||
@@ -156,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);
|
||||
}
|
||||
|
||||
@@ -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 = 10;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -107,12 +107,6 @@ ${commandSections.join("\n\n")}
|
||||
| \`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 |
|
||||
@@ -223,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 |
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import {
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
createHighlighter,
|
||||
type HighlighterGeneric,
|
||||
} from "shiki";
|
||||
|
||||
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,16 +23,6 @@ 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"];
|
||||
@@ -46,8 +36,10 @@ function checkDashboardAuth(c: {
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Register / heartbeat ────────────────────────────────────────────
|
||||
app.post("/register", async (c) => {
|
||||
// ── 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;
|
||||
@@ -82,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);
|
||||
@@ -94,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 }> = [];
|
||||
|
||||
@@ -115,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");
|
||||
|
||||
@@ -126,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);
|
||||
|
||||
@@ -24,7 +24,7 @@ function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta>
|
||||
start: {
|
||||
role: START,
|
||||
content: "test",
|
||||
meta: { maxRounds: 10 },
|
||||
meta: {},
|
||||
timestamp: Date.now(),
|
||||
} as StartStep,
|
||||
steps,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
export type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
maxRounds: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,7 +6,6 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
|
||||
@@ -8,7 +8,6 @@ export type {
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractContext,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
FALLBACK,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -50,12 +50,6 @@ const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
@@ -81,18 +75,15 @@ const table: ModeratorTable<DevelopMeta> = {
|
||||
planner: [{ condition: "FALLBACK", role: "coder" }],
|
||||
coder: [
|
||||
{ condition: allPhasesComplete, role: "reviewer" },
|
||||
{ condition: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
reviewer: [
|
||||
{ condition: reviewApproved, role: "tester" },
|
||||
{ condition: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
tester: [
|
||||
{ condition: testsPassed, role: "committer" },
|
||||
{ condition: hasRoundsRemaining, role: "coder" },
|
||||
{ condition: "FALLBACK", role: END },
|
||||
{ condition: "FALLBACK", role: "coder" },
|
||||
],
|
||||
committer: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
@@ -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: () => [],
|
||||
};
|
||||
|
||||
@@ -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