diff --git a/.changeset/config.json b/.changeset/config.json index e44c868..af8a713 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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"] } diff --git a/biome.json b/biome.json index 34776b4..a85ba22 100644 --- a/biome.json +++ b/biome.json @@ -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"] }, diff --git a/develop-entry.ts b/develop-entry.ts new file mode 100644 index 0000000..f5621a0 --- /dev/null +++ b/develop-entry.ts @@ -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 }); diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index b2f6676..dd330eb 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -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"); }); diff --git a/packages/workflow-agent-cursor/package.json b/packages/workflow-agent-cursor/package.json index 5078e3a..a5b7dd1 100644 --- a/packages/workflow-agent-cursor/package.json +++ b/packages/workflow-agent-cursor/package.json @@ -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:^", diff --git a/packages/workflow-agent-cursor/src/extract-workspace.ts b/packages/workflow-agent-cursor/src/extract-workspace.ts index 56351e4..a6e030e 100644 --- a/packages/workflow-agent-cursor/src/extract-workspace.ts +++ b/packages/workflow-agent-cursor/src/extract-workspace.ts @@ -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 { - const reactor = createThreadReactor({ - 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, - }, - }, - }; - }, - 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; diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index c2b3e85..18b3e45 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -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}`); diff --git a/packages/workflow-agent-cursor/src/types.ts b/packages/workflow-agent-cursor/src/types.ts index 58735b2..03d94e3 100644 --- a/packages/workflow-agent-cursor/src/types.ts +++ b/packages/workflow-agent-cursor/src/types.ts @@ -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; }; diff --git a/packages/workflow-agent-cursor/src/validate-config.ts b/packages/workflow-agent-cursor/src/validate-config.ts index fc667ba..b0aed2b 100644 --- a/packages/workflow-agent-cursor/src/validate-config.ts +++ b/packages/workflow-agent-cursor/src/validate-config.ts @@ -8,12 +8,6 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result { + return createTextAdapter(async (ctx, prompt, _runtime) => { const validated = validateHermesAgentConfig(config); if (!validated.ok) { throw new Error(validated.error); diff --git a/packages/workflow-agent-llm/src/create-llm-adapter.ts b/packages/workflow-agent-llm/src/create-llm-adapter.ts index 4cbc86a..099c3bc 100644 --- a/packages/workflow-agent-llm/src/create-llm-adapter.ts +++ b/packages/workflow-agent-llm/src/create-llm-adapter.ts @@ -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: [ diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index 7cbe27f..a6b0896 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -48,10 +48,7 @@ function ExpandedWorkflowBody({ const hasGraph = descriptor !== null && edgeCount > 0; return ( -
+

@@ -83,7 +80,11 @@ function ExpandedWorkflowBody({ {hasGraph ? (

["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 = { + [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 ((prompt: string, schema: z.ZodType): RoleFn => { + return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + 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( + { roles, table }, + { adapter: createLazyAdapter(), overrides: null }, +); diff --git a/packages/workflow-template-develop/src/roles/planner.ts b/packages/workflow-template-develop/src/roles/planner.ts index eec6bf0..8aab2e7 100644 --- a/packages/workflow-template-develop/src/roles/planner.ts +++ b/packages/workflow-template-develop/src/roles/planner.ts @@ -63,5 +63,5 @@ export const plannerRole: RoleDefinition = { 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) : []), }; diff --git a/packages/workflow-util-agent/src/create-text-adapter.ts b/packages/workflow-util-agent/src/create-text-adapter.ts index e2b6c13..e3a3f57 100644 --- a/packages/workflow-util-agent/src/create-text-adapter.ts +++ b/packages/workflow-util-agent/src/create-text-adapter.ts @@ -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; /** @@ -37,7 +40,7 @@ export type TextProducerFn = ( export function createTextAdapter(producer: TextProducerFn): AdapterFn { return (prompt: string, schema: z.ZodType) => { return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise> => { - 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, []); diff --git a/smoke-greet-entry.ts b/smoke-greet-entry.ts new file mode 100644 index 0000000..6142d6e --- /dev/null +++ b/smoke-greet-entry.ts @@ -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["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 = { + [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 ((prompt: string, schema: z.ZodType): RoleFn => { + return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise> => { + 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( + { roles, table }, + { adapter: createLazyAdapter(), overrides: null }, +);