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>
This commit is contained in:
@@ -2,16 +2,10 @@
|
|||||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": "@changesets/cli/changelog",
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"fixed": [
|
"fixed": [["@uncaged/*"]],
|
||||||
[
|
|
||||||
"@uncaged/*"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"linked": [],
|
"linked": [],
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": [
|
"ignore": ["@uncaged/workflow-dashboard"]
|
||||||
"@uncaged/workflow-dashboard"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -2,24 +2,11 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
describe("validateCursorAgentConfig", () => {
|
||||||
test("accepts valid config with explicit workspace", () => {
|
test("accepts valid config", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
command: "/usr/local/bin/cursor-agent",
|
||||||
model: null,
|
model: null,
|
||||||
timeout: 0,
|
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);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -29,8 +16,6 @@ describe("validateCursorAgentConfig", () => {
|
|||||||
command: "cursor-agent",
|
command: "cursor-agent",
|
||||||
model: null,
|
model: null,
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
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", () => {
|
test("rejects negative timeout", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
command: "/usr/local/bin/cursor-agent",
|
||||||
model: null,
|
model: null,
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
workspace: "/tmp/test-project",
|
|
||||||
llmProvider: null,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createCursorAgent", () => {
|
describe("createCursorAgent", () => {
|
||||||
test("returns an AdapterFn with explicit workspace", () => {
|
test("returns an AdapterFn", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
command: "/usr/local/bin/cursor-agent",
|
||||||
model: null,
|
model: null,
|
||||||
timeout: 0,
|
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");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
@@ -106,19 +48,6 @@ describe("createCursorAgent", () => {
|
|||||||
command: "/usr/local/bin/cursor-agent",
|
command: "/usr/local/bin/cursor-agent",
|
||||||
model: null,
|
model: null,
|
||||||
timeout: -1,
|
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");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:^",
|
|
||||||
"@uncaged/workflow-runtime": "workspace:^",
|
"@uncaged/workflow-runtime": "workspace:^",
|
||||||
"@uncaged/workflow-util": "workspace:^",
|
"@uncaged/workflow-util": "workspace:^",
|
||||||
"@uncaged/workflow-util-agent": "workspace:^",
|
"@uncaged/workflow-util-agent": "workspace:^",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import type { LogFn } from "@uncaged/workflow-util";
|
import type { LogFn } from "@uncaged/workflow-util";
|
||||||
import * as z from "zod/v4";
|
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"),
|
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
|
function buildExtractionInput(ctx: ThreadContext): 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 {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("## Task");
|
lines.push("## Task");
|
||||||
lines.push(ctx.start.content);
|
lines.push(ctx.start.content);
|
||||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
|||||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractWorkspacePath(
|
export async function extractWorkspacePath(
|
||||||
ctx: AgentContext,
|
ctx: ThreadContext,
|
||||||
provider: LlmProvider,
|
runtime: WorkflowRuntime,
|
||||||
logger: LogFn,
|
logger: LogFn,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const reactor = createThreadReactor<null>({
|
const input = buildExtractionInput(ctx);
|
||||||
llm: createLlmFn(provider),
|
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||||
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 result = await reactor({
|
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||||
thread: null,
|
const workspace = result.meta.workspace.trim();
|
||||||
input: buildExtractionInput(ctx),
|
|
||||||
schema: workspaceSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = result.value.workspace.trim();
|
|
||||||
if (!workspace.startsWith("/")) {
|
if (!workspace.startsWith("/")) {
|
||||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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 { createLogger } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
@@ -33,34 +33,23 @@ function resolveCursorModel(model: string | null): string {
|
|||||||
return model === null ? "auto" : model;
|
return model === null ? "auto" : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
|
||||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
export function createCursorAgent(config: CursorAgentConfig) {
|
||||||
const modelFlag = resolveCursorModel(config.model);
|
const modelFlag = resolveCursorModel(config.model);
|
||||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||||
const validated = validateCursorAgentConfig(config);
|
const validated = validateCursorAgentConfig(config);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
throw new Error(validated.error);
|
throw new Error(validated.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace: string;
|
const workspace = await extractWorkspacePath(ctx, runtime, logger);
|
||||||
|
if (workspace === null) {
|
||||||
if (config.workspace !== null) {
|
throw new Error(
|
||||||
workspace = config.workspace;
|
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
|
||||||
|
|
||||||
export type CursorAgentConfig = {
|
export type CursorAgentConfig = {
|
||||||
/** Absolute path to the cursor-agent CLI binary. */
|
/** Absolute path to the cursor-agent CLI binary. */
|
||||||
command: string;
|
command: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
timeout: number;
|
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)) {
|
if (!isAbsolute(config.command)) {
|
||||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
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) {
|
if (config.timeout < 0) {
|
||||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
|||||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||||
const validated = validateHermesAgentConfig(config);
|
const validated = validateHermesAgentConfig(config);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
throw new Error(validated.error);
|
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. */
|
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||||
return createTextAdapter(async (ctx, prompt) => {
|
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
||||||
const result = await chatCompletionText({
|
const result = await chatCompletionText({
|
||||||
provider,
|
provider,
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ function ExpandedWorkflowBody({
|
|||||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
|
||||||
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 className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||||
@@ -83,7 +80,11 @@ function ExpandedWorkflowBody({
|
|||||||
{hasGraph ? (
|
{hasGraph ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border overflow-hidden flex-1"
|
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
|
<div
|
||||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
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.",
|
description: "Breaks the task into sequential phases for the coder.",
|
||||||
systemPrompt: PLANNER_SYSTEM,
|
systemPrompt: PLANNER_SYSTEM,
|
||||||
schema: plannerMetaSchema,
|
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";
|
} from "@uncaged/workflow-runtime";
|
||||||
import type * as z from "zod/v4";
|
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.).
|
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
|
||||||
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
|
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
|
||||||
@@ -23,6 +25,7 @@ export type TextAdapterResult = {
|
|||||||
export type TextProducerFn = (
|
export type TextProducerFn = (
|
||||||
ctx: ThreadContext,
|
ctx: ThreadContext,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
runtime: WorkflowRuntime,
|
||||||
) => Promise<string | TextAdapterResult>;
|
) => Promise<string | TextAdapterResult>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +40,7 @@ export type TextProducerFn = (
|
|||||||
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
|
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
|
||||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<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 output = typeof result === "string" ? result : result.output;
|
||||||
const childThread = typeof result === "string" ? null : result.childThread;
|
const childThread = typeof result === "string" ? null : result.childThread;
|
||||||
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
|
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user