Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoju 94f725c50b refactor: cursor-agent uses runtime.extract for workspace detection
- Remove llmProvider and workspace from CursorAgentConfig (now just command/model/timeout)
- extractWorkspacePath uses runtime.extract + runtime.cas instead of standalone reactor
- TextProducerFn signature gains runtime parameter: (ctx, prompt, runtime)
- develop-entry.ts hardcodes cursor-agent path, no more env var dependency
- Drop @uncaged/workflow-reactor dep from workflow-agent-cursor
- Update tests for simplified config

小橘 <xiaoju@shazhou.work>
2026-05-13 15:51:43 +00:00
xiaomo e14643a50b Merge pull request 'chore: add output rules to develop roles — suppress verbose diffs' (#244) from chore/slim-role-output into main 2026-05-13 15:01:02 +00:00
xingyue 90a388f5ab refactor(serve): remove tunnel/cloudflared, simplify to WS-only gateway
- Delete tunnel.ts (startTunnel/cloudflared), rename to gateway.ts
- Remove --no-tunnel, --tunnel-url flags
- ServeOptions: drop noTunnel, tunnelUrl fields
- Two modes: gateway (with WORKFLOW_GATEWAY_SECRET) or local-only
- WS reverse connection is the only gateway transport
2026-05-13 22:46:48 +08:00
20 changed files with 312 additions and 279 deletions
+2 -8
View File
@@ -2,16 +2,10 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@uncaged/*"
]
],
"fixed": [["@uncaged/*"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [
"@uncaged/workflow-dashboard"
]
"ignore": ["@uncaged/workflow-dashboard"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
+15
View File
@@ -0,0 +1,15 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
@@ -1,43 +1,9 @@
import { printCliLine } from "../../cli-output.js";
type TunnelHandle = {
process: ReturnType<typeof Bun.spawn>;
url: string;
};
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
stdout: "pipe",
stderr: "pipe",
});
// cloudflared prints the URL to stderr
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
let buffer = "";
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (match) {
// Release the reader so stderr keeps flowing without backpressure
reader.releaseLock();
return { process: proc, url: match[0] };
}
}
reader.releaseLock();
proc.kill();
return null;
}
export async function registerWithGateway(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
): Promise<boolean> {
@@ -45,7 +11,7 @@ export async function registerWithGateway(
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
body: JSON.stringify({ name, url: localUrl, secret, agentToken }),
});
if (!resp.ok) {
const body = await resp.text();
@@ -77,12 +43,12 @@ export async function unregisterFromGateway(
export function startHeartbeat(
gatewayUrl: string,
name: string,
tunnelUrl: string,
localUrl: string,
secret: string,
agentToken: string,
intervalMs: number,
): ReturnType<typeof setInterval> {
return setInterval(() => {
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
registerWithGateway(gatewayUrl, name, localUrl, secret, agentToken).catch(() => {});
}, intervalMs);
}
@@ -6,7 +6,7 @@ import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./tunnel.js";
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
import type { ServeOptions } from "./types.js";
import { startGatewayWsClient } from "./ws-client.js";
@@ -52,8 +52,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let port = 7860;
let hostname = "127.0.0.1";
let name = osHostname().split(".")[0].toLowerCase();
let noTunnel = false;
let tunnelUrl: string | null = null;
let gatewayUrl = DEFAULT_GATEWAY_URL;
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
const stringFlags: Record<string, (v: string) => void> = {
@@ -66,9 +64,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
"--gateway": (v) => {
gatewayUrl = v;
},
"--tunnel-url": (v) => {
tunnelUrl = v;
},
};
for (let i = 0; i < argv.length; i++) {
@@ -78,8 +73,6 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
if (!portResult.ok) return portResult;
port = portResult.value;
i++;
} else if (arg === "--no-tunnel") {
noTunnel = true;
} else if (arg in stringFlags) {
const r = requireNextArg(argv, i, arg);
if (!r.ok) return r;
@@ -88,7 +81,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
}
}
return ok({ port, hostname, name, noTunnel, tunnelUrl, gatewayUrl, gatewaySecret });
return ok({ port, hostname, name, gatewayUrl, gatewaySecret });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
@@ -99,81 +92,62 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
}
const options = parsed.value;
const agentToken = options.noTunnel ? null : randomUUID();
startServer(storageRoot, options, agentToken);
if (options.noTunnel) {
printCliLine("tunnel disabled (--no-tunnel)");
if (options.gatewaySecret === "") {
// No gateway — local-only mode
startServer(storageRoot, options, null);
printCliLine("no WORKFLOW_GATEWAY_SECRET — running in local-only mode");
await new Promise(() => {});
return 0;
}
let resolvedTunnelUrl: string;
let stopWsClient: (() => void) | null = null;
const agentToken = randomUUID();
startServer(storageRoot, options, agentToken);
if (options.tunnelUrl !== null) {
resolvedTunnelUrl = options.tunnelUrl;
printCliLine(`using tunnel URL: ${resolvedTunnelUrl}`);
} else {
if (options.gatewaySecret === "") {
printCliLine(
"WORKFLOW_GATEWAY_SECRET not set — cannot use WebSocket gateway connection (set env or pass --tunnel-url)",
);
await new Promise(() => {});
return 0;
}
resolvedTunnelUrl = `http://127.0.0.1:${options.port}`;
const log = createLogger({ sink: { kind: "stderr" } });
stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
localPort: options.port,
log,
});
printCliLine("gateway WebSocket reverse connection (no cloudflared)");
// Start WebSocket reverse connection to gateway
const log = createLogger({ sink: { kind: "stderr" } });
const stopWsClient = startGatewayWsClient({
gatewayUrl: options.gatewayUrl,
name: options.name,
secret: options.gatewaySecret,
localPort: options.port,
log,
});
printCliLine("connected to gateway via WebSocket");
// Register with gateway for discovery
const localUrl = `http://127.0.0.1:${options.port}`;
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
localUrl,
options.gatewaySecret,
agentToken,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
if (options.gatewaySecret) {
if (agentToken === null) {
printCliLine("internal error: agent token missing");
await new Promise(() => {});
return 1;
}
const token = agentToken;
const registered = await registerWithGateway(
options.gatewayUrl,
options.name,
resolvedTunnelUrl,
options.gatewaySecret,
token,
);
if (registered) {
printCliLine(`registered with gateway as "${options.name}"`);
}
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
localUrl,
options.gatewaySecret,
agentToken,
HEARTBEAT_INTERVAL_MS,
);
const heartbeatTimer = startHeartbeat(
options.gatewayUrl,
options.name,
resolvedTunnelUrl,
options.gatewaySecret,
token,
HEARTBEAT_INTERVAL_MS,
);
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
process.exit(0);
};
const cleanup = async () => {
clearInterval(heartbeatTimer);
stopWsClient?.();
printCliLine("unregistering from gateway...");
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
} else {
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
await new Promise(() => {});
return 0;
@@ -2,8 +2,6 @@ export type ServeOptions = {
port: number;
hostname: string;
name: string;
noTunnel: boolean;
tunnelUrl: string | null;
gatewayUrl: string;
gatewaySecret: string;
};
+1 -1
View File
@@ -90,7 +90,7 @@ ${commandSections.join("\n\n")}
| Command | Args | Description |
|---------|------|-------------|
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with WebSocket gateway connection. \`--name\` registers with the gateway. |
## Typical Workflow
@@ -2,24 +2,11 @@ import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
describe("validateCursorAgentConfig", () => {
test("accepts valid config with explicit workspace", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(true);
});
test("accepts valid config with null workspace and llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(r.ok).toBe(true);
});
@@ -29,8 +16,6 @@ describe("validateCursorAgentConfig", () => {
command: "cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -38,65 +23,22 @@ describe("validateCursorAgentConfig", () => {
}
});
test("rejects empty workspace string", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "",
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
test("rejects null workspace without llmProvider", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("llmProvider");
}
});
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(r.ok).toBe(false);
});
});
describe("createCursorAgent", () => {
test("returns an AdapterFn with explicit workspace", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("returns an AdapterFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
});
expect(typeof agent).toBe("function");
});
@@ -106,19 +48,6 @@ describe("createCursorAgent", () => {
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: -1,
workspace: "/tmp/test-project",
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
llmProvider: null,
});
expect(typeof agent).toBe("function");
});
+1 -1
View File
@@ -12,8 +12,8 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
@@ -1,5 +1,5 @@
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { LogFn } from "@uncaged/workflow-util";
import * as z from "zod/v4";
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
});
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
function buildExtractionInput(ctx: AgentContext): string {
function buildExtractionInput(ctx: ThreadContext): string {
const lines: string[] = [];
lines.push("## Task");
lines.push(ctx.start.content);
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
}
lines.push("");
lines.push(
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
);
return lines.join("\n");
}
export async function extractWorkspacePath(
ctx: AgentContext,
provider: LlmProvider,
ctx: ThreadContext,
runtime: WorkflowRuntime,
logger: LogFn,
): Promise<string | null> {
const reactor = createThreadReactor<null>({
llm: createLlmFn(provider),
maxRounds: 2,
staticTools: [],
structuredToolFromSchema: (schema) => {
const jsonSchema = z.toJSONSchema(schema);
return {
name: "set_workspace",
tool: {
type: "function" as const,
function: {
name: "set_workspace",
description: "Set the extracted workspace path",
parameters: jsonSchema as Record<string, unknown>,
},
},
};
},
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
toolHandler: async () => "unknown tool",
});
const input = buildExtractionInput(ctx);
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
const result = await reactor({
thread: null,
input: buildExtractionInput(ctx),
schema: workspaceSchema,
});
const result = await runtime.extract(workspaceSchema, contentHash);
const workspace = result.meta.workspace.trim();
if (!result.ok) {
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
return null;
}
const workspace = result.value.workspace.trim();
if (!workspace.startsWith("/")) {
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
return null;
+9 -20
View File
@@ -1,4 +1,4 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import {
buildThreadInput,
@@ -33,34 +33,23 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig) {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createTextAdapter(async (ctx, prompt) => {
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
let workspace: string;
if (config.workspace !== null) {
workspace = config.workspace;
} else {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
);
}
workspace = extracted;
const workspace = await extractWorkspacePath(ctx, runtime, logger);
if (workspace === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
);
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
@@ -1,12 +1,6 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
export type CursorAgentConfig = {
/** Absolute path to the cursor-agent CLI binary. */
command: string;
model: string | null;
timeout: number;
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
workspace: string | null;
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
llmProvider: LlmProvider | null;
};
@@ -8,12 +8,6 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
if (!isAbsolute(config.command)) {
return err("command must be an absolute path to the cursor-agent CLI binary");
}
if (config.workspace !== null && config.workspace.length === 0) {
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
}
if (config.workspace === null && config.llmProvider === null) {
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
}
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
+1 -1
View File
@@ -33,7 +33,7 @@ function throwHermesSpawnError(error: SpawnCliError): never {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt) => {
return createTextAdapter(async (ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
@@ -93,7 +93,7 @@ export async function chatCompletionText(options: {
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
return createTextAdapter(async (ctx, prompt, _runtime) => {
const result = await chatCompletionText({
provider,
messages: [
@@ -48,10 +48,7 @@ function ExpandedWorkflowBody({
const hasGraph = descriptor !== null && edgeCount > 0;
return (
<div
className="pt-3 border-t flex gap-4"
style={{ borderColor: "var(--color-border)" }}
>
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
@@ -83,7 +80,11 @@ function ExpandedWorkflowBody({
{hasGraph ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
minHeight: 500,
}}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
@@ -0,0 +1,101 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);
@@ -63,5 +63,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
schema: plannerMetaSchema,
extractRefs: (meta) => meta.status === "planned" ? meta.phases.map((p) => p.hash) : [],
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
};
@@ -7,6 +7,8 @@ import type {
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type { WorkflowRuntime } from "@uncaged/workflow-runtime";
/**
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
@@ -23,6 +25,7 @@ export type TextAdapterResult = {
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<string | TextAdapterResult>;
/**
@@ -37,7 +40,7 @@ export type TextProducerFn = (
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt);
const result = await producer(ctx, prompt, runtime);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
+101
View File
@@ -0,0 +1,101 @@
/**
* greet workflow — smoke test entry
* Single role: greeter takes a prompt and returns a structured greeting.
* 小橘 🍊
*/
import type {
AdapterFn,
ModeratorTable,
RoleFn,
RoleResult,
ThreadContext,
WorkflowDefinition,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
type GreetMeta = {
greeter: { greeting: string; language: string };
};
const greeterSchema = z.object({
greeting: z.string().describe("A friendly greeting message"),
language: z.string().describe("The language of the greeting"),
});
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
greeter: {
description: "Generates a friendly greeting",
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
const table: ModeratorTable<GreetMeta> = {
[START]: [{ condition: "FALLBACK", role: "greeter" }],
greeter: [{ condition: "FALLBACK", role: END }],
};
export const descriptor = {
name: "greet",
description: "A simple greeting workflow for smoke testing",
graph: { [START]: ["greeter"], greeter: [END] },
roles: { greeter: { description: "Generates a friendly greeting" } },
};
function createLazyAdapter(): AdapterFn {
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
function getProvider() {
if (cached !== null) return cached;
const apiKey = process.env.DASHSCOPE_API_KEY;
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
cached = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey,
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
};
return cached;
}
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const provider = getProvider();
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages: [
{ role: "system", content: prompt },
{
role: "user",
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
},
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
}
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
const text = data.choices[0]?.message?.content;
if (!text) throw new Error("Empty LLM response");
const parsed = schema.parse(JSON.parse(text));
return { meta: parsed, childThread: null };
};
}) as AdapterFn;
}
export const run = createWorkflow<GreetMeta>(
{ roles, table },
{ adapter: createLazyAdapter(), overrides: null },
);