Compare commits

...

9 Commits

Author SHA1 Message Date
xiaoju bd110b76e1 chore: remove accidental self-referencing symlinks
小橘 🍊
2026-05-13 02:44:24 +00:00
xiaoju dc10ccceaa test: add react adapter unit tests (#222)
小橘 🍊
2026-05-13 02:40:22 +00:00
xiaoju c040a90a8f feat: add @uncaged/workflow-agent-react package (#222) 2026-05-13 02:38:38 +00:00
xiaoju ec4599a230 refactor: extract wrapAgentAsAdapter to util-agent, support childThread in RoleFn (#222) 2026-05-13 02:37:32 +00:00
xiaomo 1f4bd3f431 Merge pull request 'feat(protocol): AdapterFn replaces AgentBinding in createWorkflow (#222 Phase 1)' (#224) from feat/222-adapter-fn-phase1 into main 2026-05-13 02:30:29 +00:00
xiaoju bebf4aad45 feat(protocol): add AdapterFn/RoleFn/AdapterBinding, refactor createWorkflow to use AdapterBinding (#222)
- Add RoleFn<T>, AdapterFn, AdapterBinding types to workflow-protocol
- Mark AgentFn, AgentFnResult, AgentBinding as @deprecated
- Refactor createWorkflow to accept AdapterBinding instead of AgentBinding
- Adapter returns typed meta directly — no more extract call in workflow loop
- Add buildThreadInput (ThreadContext-based), keep buildAgentPrompt as deprecated wrapper
- Update template bundle-entries to wrap AgentFn as AdapterFn
- Update solve-issue tests to use AdapterFn directly
2026-05-13 02:27:36 +00:00
xiaoju 11ba185fef docs: RFC v3 — react adapter as thin wrapper over reactor
小橘 🍊
2026-05-13 02:19:12 +00:00
xiaoju 730340d123 docs: RFC v2 — AdapterFn replaces AgentFn, schema-aware resolve
小橘 🍊
2026-05-13 02:15:21 +00:00
xiaoju c848216396 docs: RFC for workflow-agent-react package
小橘 🍊
2026-05-13 01:55:14 +00:00
21 changed files with 682 additions and 210 deletions
+191
View File
@@ -0,0 +1,191 @@
# workflow-agent-react — ReAct Agent Package
**Status**: RFC v3
**Author**: 小橘 🍊
## Problem
现有的 agent 包都依赖外部 CLI 进程:
| Package | 机制 | 能力 |
|---------|------|------|
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
## 核心设计变更:AdapterFn 替代 AgentFn
### 现状的问题
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
```
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
```
### 新抽象:AdapterFn
```typescript
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
```
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
- **`schema`** — role 的 meta schema,定义输出格式
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
### AgentContext 不再需要
`AgentContext``ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
### createWorkflow 签名变更
```typescript
// Before
type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// After
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
engine 对每个 role 的执行逻辑:
```typescript
// Before
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
// After
const roleFn = adapter(role.systemPrompt, role.metaSchema);
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
```
## `createReactAdapter` — 复用 workflow-reactor
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor``ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
```typescript
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
import type { ToolDefinition } from "@uncaged/workflow-reactor";
type ReactToolHandler = (name: string, args: string) => Promise<string>;
type ReactAdapterConfig = {
provider: LlmProvider;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: createLlmFn(config.provider),
staticTools: config.tools,
structuredToolFromSchema: (s) => buildStructuredTool(s),
systemPromptForStructuredTool: () => prompt,
toolHandler: (call, ctx) =>
config.toolHandler(call.function.name, call.function.arguments),
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext): Promise<T> => {
const input = buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return result.value;
};
};
}
```
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
## `agentToAdapter` — 向后兼容
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`
```typescript
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
return async (ctx: ThreadContext): Promise<T> => {
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agent(agentCtx);
const output = typeof result === "string" ? result : result.output;
return extract(output, schema, extractProvider);
};
};
}
```
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
## 包结构
```
packages/workflow-agent-react/
src/
types.ts # ReactAdapterConfig, ReactToolHandler
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
thread-input.ts # ThreadContext → user message string
index.ts
__tests__/
create-react-adapter.test.ts
package.json
```
依赖:
- `@uncaged/workflow-protocol``ThreadContext`, `LlmProvider`
- `@uncaged/workflow-reactor``createLlmFn`, `createThreadReactor`, types
## 影响范围
### Breaking Changes
| 改动 | 影响 |
|------|------|
| `AgentBinding``AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
### 需修改的包
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
2. `workflow-runtime` — 更新 re-export
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
4. `workflow-util-agent``buildAgentPrompt``buildThreadInput`,接收 `ThreadContext`
5. 所有 bundle-entry — `agent:``adapter:`
### 不受影响
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
## Phases
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
## 工具集(后续讨论)
| 工具 | 说明 | 优先级 |
|------|------|--------|
| `read_file` | 读文件 | P0 |
| `write_file` | 写文件 | P0 |
| `patch_file` | find-and-replace 编辑 | P0 |
| `shell_exec` | 执行 shell 命令 | P0 |
| `search_files` | grep / find | P1 |
| `list_files` | ls | P1 |
@@ -0,0 +1,208 @@
import { describe, expect, test } from "bun:test";
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
import { ok } from "@uncaged/workflow-protocol";
import { START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import { createReactAdapter } from "../src/create-react-adapter.js";
import type { ReactAdapterConfig } from "../src/types.js";
// ── Helpers ─────────────────────────────────────────────────────────
function makeThread(prompt: string): ThreadContext {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: {
role: START,
content: prompt,
meta: {},
timestamp: Date.now(),
parentState: null,
},
steps: [],
};
}
const STUB_RUNTIME: WorkflowRuntime = {
cas: {
put: async (_content: string) => "STUBHASH",
get: async (_hash: string) => null,
delete: async (_hash: string) => {},
list: async () => [],
},
extract: async (_schema, _contentHash) => ({
meta: {},
contentPayload: "",
refs: [],
}),
};
const TEST_SCHEMA = z.object({
summary: z.string(),
score: z.number(),
}).meta({ title: "resolve", description: "Submit the final result." });
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
const message: Record<string, unknown> = { role: "assistant" };
if (content !== null) {
message.content = content;
}
if (toolCalls !== null) {
message.tool_calls = toolCalls;
}
return JSON.stringify({ choices: [{ message }] });
}
function makeToolCallResponse(name: string, args: Record<string, unknown>, id: string): string {
return makeChatResponse(null, [
{
id,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
]);
}
// ── Tests ───────────────────────────────────────────────────────────
describe("createReactAdapter", () => {
test("direct resolve: LLM immediately calls resolve tool with valid args", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeToolCallResponse("resolve", { summary: "done", score: 42 }, "call_1"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "done", score: 42 });
expect(result.childThread).toBeNull();
});
test("tool call then resolve: LLM calls user tool first, then resolves", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
return ok(makeToolCallResponse("search", { query: "test" }, "call_1"));
}
return ok(makeToolCallResponse("resolve", { summary: "found it", score: 99 }, "call_2"));
};
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search for information",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
};
const toolResults: string[] = [];
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async (name, args) => {
toolResults.push(`${name}:${args}`);
return "search result: found the answer";
},
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "found it", score: 99 });
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toContain("search:");
});
test("plain JSON response accepted", async () => {
const llm: LlmFn = async (_input) => {
return ok(makeChatResponse(JSON.stringify({ summary: "plain", score: 7 }), null));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "plain", score: 7 });
});
test("schema validation failure + retry: invalid args then valid args", async () => {
let callCount = 0;
const llm: LlmFn = async (_input) => {
callCount += 1;
if (callCount === 1) {
// Invalid: score should be number, not string
return ok(makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"));
}
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
};
const config: ReactAdapterConfig = {
llm,
tools: [],
toolHandler: async () => "unused",
maxRounds: 5,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
const result = await roleFn(makeThread("test task"), STUB_RUNTIME);
expect(result.meta).toEqual({ summary: "fixed", score: 10 });
expect(callCount).toBe(2);
});
test("max rounds exceeded: throws error", async () => {
const searchTool: ToolDefinition = {
type: "function",
function: {
name: "search",
description: "Search",
parameters: { type: "object", properties: {}, required: [] },
},
};
const llm: LlmFn = async (_input) => {
// Always call search, never resolve
return ok(makeToolCallResponse("search", {}, "call_n"));
};
const config: ReactAdapterConfig = {
llm,
tools: [searchTool],
toolHandler: async () => "still searching...",
maxRounds: 3,
};
const adapter = createReactAdapter(config);
const roleFn = adapter("You are a test agent.", TEST_SCHEMA);
await expect(roleFn(makeThread("test task"), STUB_RUNTIME)).rejects.toThrow(
"max_react_rounds_exceeded",
);
});
});
@@ -0,0 +1,27 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
},
"devDependencies": {
"zod": "^4.0.0"
},
"peerDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,69 @@
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
import { createThreadReactor } from "@uncaged/workflow-reactor";
import { buildThreadInput } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
import type { ReactAdapterConfig } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
const { $schema: _drop, ...rest } = json;
return rest;
}
function readToolName(parametersSchema: Record<string, unknown>): string {
const title = parametersSchema.title;
if (typeof title === "string" && title.trim().length > 0) {
return title.trim();
}
return "resolve";
}
function readToolDescription(parametersSchema: Record<string, unknown>): string {
const d = parametersSchema.description;
if (typeof d === "string" && d.trim().length > 0) {
return d.trim();
}
return "Submit the final structured result.";
}
export function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const reactor = createThreadReactor<ThreadContext>({
llm: config.llm,
staticTools: config.tools,
structuredToolFromSchema: (s) => {
const rawJsonSchema = z.toJSONSchema(s) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const name = readToolName(parameters);
return {
name,
tool: {
type: "function" as const,
function: {
name,
description: readToolDescription(parameters),
parameters,
},
},
};
},
systemPromptForStructuredTool: (_name) => prompt,
toolHandler: async (call, _thread) => {
return config.toolHandler(call.function.name, call.function.arguments);
},
maxRounds: config.maxRounds,
});
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const input = await buildThreadInput(ctx);
const result = await reactor({ thread: ctx, input, schema });
if (!result.ok) throw new Error(result.error);
return { meta: result.value, childThread: null };
};
};
}
@@ -0,0 +1,2 @@
export { createReactAdapter } from "./create-react-adapter.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,10 @@
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
export type ReactToolHandler = (name: string, args: string) => Promise<string>;
export type ReactAdapterConfig = {
llm: LlmFn;
tools: readonly ToolDefinition[];
toolHandler: ReactToolHandler;
maxRounds: number;
};
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-util-agent" }
]
}
+4
View File
@@ -9,6 +9,8 @@ export type {
} from "./cas-types.js";
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
@@ -27,6 +29,8 @@ export type {
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleStep,
+16
View File
@@ -143,15 +143,31 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFnResult = string | { output: string; childThread: string | null };
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleResult<T> = { meta: T; childThread: string | null };
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
@@ -3,11 +3,9 @@ import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js"
import type * as z from "zod/v4";
import {
type AdapterBinding,
type AdapterFn,
type AdvanceOutcome,
type AgentBinding,
type AgentContext,
type AgentFn,
type AgentFnResult,
END,
type ModeratorContext,
type RoleDefinition,
@@ -51,28 +49,18 @@ function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[]
return out;
}
function normalizeAgentResult(result: AgentFnResult): {
output: string;
childThread: string | null;
} {
if (typeof result === "string") {
return { output: result, childThread: null };
}
return result;
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
function adapterForRole(binding: AdapterBinding, roleName: string): AdapterFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
const overrideFn: AdapterFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.agent;
return overrideFn !== undefined ? overrideFn : binding.adapter;
}
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles"> & {
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
},
binding: AgentBinding,
binding: AdapterBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
@@ -94,37 +82,24 @@ async function advanceOneRound<M extends RoleMeta>(
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
};
const agent = agentForRole(binding, next);
const agentResult = normalizeAgentResult(await agent(agentCtx as unknown as AgentContext));
const agentContentHash = await putContentNodeWithRefs(runtime.cas, agentResult.output, []);
const extracted = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
agentContentHash,
);
const adapter = adapterForRole(binding, next);
const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType<Record<string, unknown>>);
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
const meta = result.meta;
const refsFromMeta = resolveExtractedRefs(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extracted.meta,
meta,
);
const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta);
const contentHash =
artifactRefs.length === 0
? agentContentHash
: await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs);
const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash];
const contentPayload = JSON.stringify(meta);
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
const refs = refsFromMeta.length === 0 ? [contentHash] : [...refsFromMeta, contentHash];
const step = {
role: next,
contentHash,
meta: extracted.meta,
meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
@@ -136,22 +111,22 @@ async function advanceOneRound<M extends RoleMeta>(
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
childThread: agentResult.childThread,
childThread: result.childThread,
},
step,
};
}
/**
* Binds pure role definitions + moderator table to runtime agents.
* Binds pure role definitions + moderator table to an adapter.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
* The adapter is responsible for returning typed meta directly — no separate
* extract call is needed.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
binding: AgentBinding,
binding: AdapterBinding,
): WorkflowFn {
const pickNext = tableToModerator(def.table);
const loopDef = { roles: def.roles, pickNext };
+4
View File
@@ -2,6 +2,8 @@ export { buildThreadContext } from "./build-context.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AdapterBinding,
AdapterFn,
AgentBinding,
AgentContext,
AgentFn,
@@ -17,6 +19,8 @@ export type {
ModeratorTransition,
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleStep,
+4
View File
@@ -3,6 +3,8 @@
// imports from "@uncaged/workflow-runtime" continues to work.
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
@@ -21,6 +23,8 @@ export type {
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleStep,
@@ -5,6 +5,7 @@
*/
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
function requireEnv(name: string): string {
@@ -40,7 +41,9 @@ const agent = createCursorAgent({
llmProvider,
});
const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
const adapter = wrapAgentAsAdapter(agent);
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
export const descriptor = buildDevelopDescriptor();
export const run = wf;
@@ -7,12 +7,17 @@ import { createExtract } from "@uncaged/workflow-execute";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import {
type AdapterFn,
createWorkflow,
END,
type ModeratorContext,
type RoleResult,
type RoleStep,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js";
import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js";
@@ -21,86 +26,6 @@ import type { SolveIssueMeta } from "../src/roles.js";
const solveIssueModerator = tableToModerator(solveIssueTable);
function jsonResponse(payload: Record<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [{ message: { content: JSON.stringify(args) } }],
});
}
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence");
}
i += 1;
return buildPlainJsonResponse(args);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
function buildToolCallResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
id: "tc_extract_1",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify(args),
},
},
],
},
},
],
});
}
function installMockToolCallCompletions(
sequence: ReadonlyArray<Record<string, unknown>>,
): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockToolCallCompletions: empty sequence");
}
i += 1;
return buildToolCallResponse(args);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
function makeStart(): ModeratorContext<SolveIssueMeta>["start"] {
return {
role: START,
@@ -168,17 +93,6 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
};
}
function createStubExtract(casDir: string) {
return createExtract(
{
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "test",
},
{ cas: createCasStore(casDir) },
);
}
function makeThread(prompt: string) {
return {
threadId: "01TEST000000000000000000TR",
@@ -195,6 +109,35 @@ function makeThread(prompt: string) {
};
}
/** Creates an AdapterFn that returns a fixed sequence of meta values. */
function createSequenceAdapter(sequence: ReadonlyArray<Record<string, unknown>>): AdapterFn {
let i = 0;
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const meta = sequence[i] ?? sequence[sequence.length - 1];
if (meta === undefined) {
throw new Error("createSequenceAdapter: empty sequence");
}
i += 1;
return { meta: meta as T, childThread: null };
};
};
}
/** Creates an AdapterFn that tracks calls and returns fixed meta. */
function createTrackingAdapter(
name: string,
calls: string[],
meta: Record<string, unknown>,
): AdapterFn {
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
calls.push(name);
return { meta: meta as T, childThread: null };
};
};
}
describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => {
expect(solveIssueModerator(makeCtx([]))).toBe("preparer");
@@ -227,8 +170,6 @@ describe("solveIssueModerator", () => {
});
test("returns END for any unexpected last step (defensive)", () => {
// A submitter step with a pseudo-unknown future status would still be
// routed to END, since the moderator is a closed switch over known roles.
expect(
solveIssueModerator(
makeCtx([
@@ -242,19 +183,16 @@ describe("solveIssueModerator", () => {
});
describe("solveIssueWorkflowDefinition + createWorkflow", () => {
let restoreFetch: (() => void) | null = null;
let casDir: string | undefined;
afterEach(async () => {
restoreFetch?.();
restoreFetch = null;
if (casDir !== undefined) {
await rm(casDir, { recursive: true, force: true }).catch(() => {});
casDir = undefined;
}
});
test("structured extraction yields preparer meta from mocked chat completions", async () => {
test("adapter yields preparer meta directly", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/test",
defaultBranch: "main",
@@ -266,18 +204,21 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
buildCommand: "bun run build",
},
};
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const adapter = createSequenceAdapter([EXPECT_PREPARER_META]);
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
adapter,
overrides: null,
});
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
});
const first = await gen.next();
expect(first.done).toBe(false);
@@ -288,41 +229,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("structured extraction also accepts tool_calls extraction path", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/tool-call",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
},
};
restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("per-role agent overrides default", async () => {
test("per-role adapter overrides default", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
@@ -339,35 +246,25 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
status: "submitted",
prUrl: "https://github.com/example/repo/pull/2",
};
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => {
calls.push("default");
return "";
},
adapter: createTrackingAdapter("default", calls, PREPARER_META),
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
preparer: createTrackingAdapter("preparer", calls, PREPARER_META),
developer: createTrackingAdapter("developer", calls, DEVELOPER_META),
submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META),
},
});
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -7,6 +7,7 @@
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
function optionalEnv(name: string): string | null {
@@ -26,10 +27,13 @@ const hermesAgent = createHermesAgent({
const developerAgent = workflowAsAgent("develop");
const adapter = wrapAgentAsAdapter(hermesAgent);
const developerAdapter = wrapAgentAsAdapter(developerAgent);
const wf = createWorkflow(solveIssueWorkflowDefinition, {
agent: hermesAgent,
adapter,
overrides: {
developer: developerAgent,
developer: developerAdapter,
},
});
+2 -1
View File
@@ -14,6 +14,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*"
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-cas": "workspace:*"
}
}
@@ -1,10 +1,11 @@
import type { AgentContext } from "@uncaged/workflow-runtime";
import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime";
/** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
/**
* Builds a user-message string from thread context: task, previous steps, and tool hints.
* Does NOT include a system prompt — that is passed separately via the adapter.
*/
export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
const lines: string[] = [];
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
if (ctx.start.parentState !== null) {
lines.push("## Parent Context");
@@ -58,3 +59,12 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
return lines.join("\n");
}
/**
* @deprecated Use {@link buildThreadInput} instead. This wrapper prepends the system prompt
* from `ctx.currentRole` for backward compatibility with existing agents.
*/
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
const threadInput = await buildThreadInput(ctx);
return `${ctx.currentRole.systemPrompt}\n\n${threadInput}`;
}
+2 -1
View File
@@ -1,3 +1,4 @@
export { buildAgentPrompt } from "./build-agent-prompt.js";
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
export { wrapAgentAsAdapter } from "./wrap-agent-as-adapter.js";
@@ -0,0 +1,31 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
AgentContext,
AgentFnResult,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
/**
* Wraps a legacy AgentFn into an AdapterFn.
* The agent produces a string (or { output, childThread }); the adapter
* stores the output in CAS, runs extract, and returns typed meta + childThread.
*/
export function wrapAgentAsAdapter(
agentFn: (ctx: AgentContext) => Promise<AgentFnResult>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const agentCtx: AgentContext = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agentFn(agentCtx);
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 };
};
};
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }]
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-cas" }]
}
+1
View File
@@ -29,6 +29,7 @@
{ "path": "packages/workflow-agent-cursor" },
{ "path": "packages/workflow-agent-hermes" },
{ "path": "packages/workflow-util-agent" },
{ "path": "packages/workflow-agent-react" },
{ "path": "packages/cli-workflow" },
{ "path": "packages/workflow-template-solve-issue" },
{ "path": "packages/workflow-template-develop" }