Compare commits

..

6 Commits

Author SHA1 Message Date
xiaoju bb6b309efa chore(util-agent): remove dead createTextAdapter / TextProducerFn
All adapters migrated to createAgentAdapter in #261. No consumers remain.

Refs #252
2026-05-14 12:23:01 +00:00
xiaomo 56db22a908 Merge pull request 'refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter' (#262) from feat/261-adapter-migration into main 2026-05-14 12:19:37 +00:00
xiaoju 2a1b7b0aeb refactor(agents): migrate LLM/Hermes/Cursor to createAgentAdapter
- LLM: AgentFn<{prompt}> + createAgentAdapter, chatCompletionText unchanged
- Hermes: AgentFn<{prompt}> + createAgentAdapter, config validation in extract
- Cursor: AgentFn<{prompt, workspace}> + createAgentAdapter, workspace
  extraction moved to extract fn, AgentFn itself only receives resolved options

All public API signatures preserved. createTextAdapter/TextProducerFn retained.

Closes #261, Phase 2 of #252
2026-05-14 10:22:37 +00:00
xiaomo 07f52594d1 Merge pull request 'feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge' (#256) from feat/252-agent-fn into main 2026-05-14 08:53:30 +00:00
xiaoju 4582274ba4 feat(protocol): AgentFn<Opt> type + createAgentAdapter bridge
Add AgentFn<Opt = void> as the formal agent boundary type:
- Input: ThreadContext (fixed), Output: string (fixed)
- Opt: agent-specific structured options (e.g. { workspace } for Cursor)

Add createAgentAdapter<Opt>(agent, extract) → AdapterFn bridge in
workflow-util-agent, plus createSimpleAgentAdapter for Opt = void.

Also fixes: workflow-cas composite flag + cursor tsconfig reference.

Refs #252
2026-05-14 08:41:22 +00:00
xiaomo d140801337 Merge pull request 'feat(dashboard): graph node click improvements' (#255) from feat/graph-interactions into main 2026-05-14 08:29:29 +00:00
17 changed files with 179 additions and 118 deletions
+1
View File
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
@@ -1,21 +1,25 @@
import { describe, expect, test } from "bun:test";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
const baseConfig = {
command: "/usr/local/bin/cursor-agent",
model: null as string | null,
timeout: 0,
workspace: null as string | null,
};
describe("validateCursorAgentConfig", () => {
test("accepts valid config", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
...baseConfig,
});
expect(r.ok).toBe(true);
});
test("rejects non-absolute command", () => {
const r = validateCursorAgentConfig({
...baseConfig,
command: "cursor-agent",
model: null,
timeout: 0,
});
expect(r.ok).toBe(false);
if (!r.ok) {
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
test("rejects negative timeout", () => {
const r = validateCursorAgentConfig({
command: "/usr/local/bin/cursor-agent",
model: null,
...baseConfig,
timeout: -1,
});
expect(r.ok).toBe(false);
});
test("rejects non-absolute workspace when set", () => {
const r = validateCursorAgentConfig({
...baseConfig,
workspace: "relative/path",
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("workspace");
}
});
});
describe("createCursorAgent", () => {
test("returns an AdapterFn", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
timeout: 0,
...baseConfig,
});
expect(typeof agent).toBe("function");
});
test("defers validation to call time (invalid config does not throw at construction)", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
...baseConfig,
timeout: -1,
});
expect(typeof agent).toBe("function");
+40 -22
View File
@@ -1,8 +1,8 @@
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger, type LogFn } from "@uncaged/workflow-util";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -33,25 +33,15 @@ function resolveCursorModel(model: string | null): string {
return model === null ? "auto" : model;
}
/** 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, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
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.",
);
}
type CursorAgentOpt = { prompt: string; workspace: string };
function createCursorAgentFn(
config: CursorAgentConfig,
modelFlag: string,
timeoutMs: number | null,
logger: LogFn,
): AgentFn<CursorAgentOpt> {
return async (ctx, { prompt, workspace }) => {
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
@@ -75,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
throwCursorSpawnError(run.error);
}
return run.value;
});
};
}
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return createAgentAdapter(
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
async (ctx, prompt, runtime: WorkflowRuntime) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const workspace =
config.workspace !== null
? config.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.",
);
}
return { prompt, workspace };
},
);
}
@@ -3,4 +3,9 @@ export type CursorAgentConfig = {
command: string;
model: string | null;
timeout: number;
/**
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
* from the thread via runtime extraction.
*/
workspace: string | null;
};
@@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
if (config.timeout < 0) {
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
}
if (config.workspace !== null && !isAbsolute(config.workspace)) {
return err("workspace must be an absolute filesystem path when set");
}
return ok(undefined);
}
+5 -1
View File
@@ -6,5 +6,9 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
"references": [
{ "path": "../workflow-cas" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" }
]
}
+17 -10
View File
@@ -1,7 +1,7 @@
import type { AdapterFn } from "@uncaged/workflow-runtime";
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
throw new Error("hermes: unknown spawn error");
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
const timeoutMs = config.timeout;
return createTextAdapter(async (ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return async (ctx, { prompt }) => {
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
});
}
@@ -1,5 +1,12 @@
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
import {
type AdapterFn,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt, _runtime) => {
type LlmAgentOpt = { prompt: string };
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
return async (ctx, { prompt }) => {
const result = await chatCompletionText({
provider,
messages: [
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
});
};
}
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
prompt,
}));
}
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"composite": true
},
"include": ["src"],
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
+1
View File
@@ -13,6 +13,7 @@ export type {
AdapterFn,
AdvanceOutcome,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
+9
View File
@@ -151,6 +151,15 @@ export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promis
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
/**
* Core agent function. Input is always {@link ThreadContext}, output is always string.
* `Opt` captures agent-specific structured options.
* Agents with no extra options use `AgentFn` (Opt defaults to void).
*/
export type AgentFn<Opt = void> = Opt extends void
? (ctx: ThreadContext) => Promise<string>
: (ctx: ThreadContext, options: Opt) => Promise<string>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
+1
View File
@@ -5,6 +5,7 @@ export type {
AdapterBinding,
AdapterFn,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
+1
View File
@@ -7,6 +7,7 @@ export type {
AdapterFn,
AdvanceOutcome,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
@@ -8,15 +8,6 @@ import { createWorkflow } from "@uncaged/workflow-runtime";
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
const llmProvider = {
baseUrl: optionalEnv(
"WORKFLOW_LLM_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
};
const adapter = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
@@ -24,7 +15,6 @@ const adapter = createCursorAgent({
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
: 0,
workspace: null,
llmProvider,
});
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
@@ -0,0 +1,48 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
AgentFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
export type ExtractOptionsFn<Opt> = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<Opt>;
/**
* Bridges {@link AgentFn} to {@link AdapterFn}.
*
* 1. extract(ctx, prompt, runtime) → Opt
* 2. agent(ctx, options) → raw string
* 3. Store raw string in CAS
* 4. runtime.extract(schema, contentHash) → typed meta T
*/
export function createAgentAdapter<Opt>(
agent: AgentFn<Opt>,
extract: ExtractOptionsFn<Opt>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const options = await extract(ctx, prompt, runtime);
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
ctx,
options,
);
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread: null };
};
};
}
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
}
@@ -1,54 +0,0 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} 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.
*/
export type TextAdapterResult = {
output: string;
childThread: string | null;
};
/**
* A function that produces raw text output given the thread context and
* the system prompt for the current role.
*/
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
runtime: WorkflowRuntime,
) => Promise<string | TextAdapterResult>;
/**
* Creates an {@link AdapterFn} from a text-producing function.
*
* The adapter:
* 1. Calls the producer with thread context + system prompt
* 2. Stores output in CAS
* 3. Runs the extract phase to produce typed meta
* 4. Returns `{ meta, childThread }`
*/
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, runtime);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread };
};
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
export { createTextAdapter } from "./create-text-adapter.js";
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";