docs: RFC v2 — AdapterFn replaces AgentFn, schema-aware resolve
小橘 🍊
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# workflow-agent-react — ReAct Agent Package
|
# workflow-agent-react — ReAct Agent Package
|
||||||
|
|
||||||
**Status**: RFC
|
**Status**: RFC v2
|
||||||
**Author**: 小橘 🍊
|
**Author**: 小橘 🍊
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -15,160 +15,225 @@
|
|||||||
|
|
||||||
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
||||||
|
|
||||||
用途:
|
## 核心设计变更:AdapterFn 替代 AgentFn
|
||||||
1. **Smoke test 闭环** — setup → bundle → add → run → show,用 workflow.yaml 里配置的 provider 直接跑,不需要装 hermes/cursor
|
|
||||||
2. **轻量 agent** — 只需要读写文件 + 跑命令的场景,不需要启动完整的 CLI agent
|
|
||||||
|
|
||||||
## 现有 reactor 的局限
|
### 现状的问题
|
||||||
|
|
||||||
`workflow-reactor` 已有 ReAct 循环,但它是为 **structured extraction** 设计的:
|
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
对于内置 ReAct agent,我们完全可以把 schema 作为 resolve tool 注入循环,agent 直接按 schema 输出结构化结果,**零额外 LLM 调用**。
|
||||||
|
|
||||||
|
### 新抽象:AdapterFn
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// reactor 的终止条件:拿到符合 schema 的 structured output
|
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
|
||||||
ThreadReactorFn<TThread> = <T>(args: {
|
|
||||||
thread: TThread;
|
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||||
input: string;
|
|
||||||
schema: z.ZodType<T>; // ← 强制要求
|
|
||||||
}) => Promise<Result<T, string>>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
agent 需要的是:**循环调用工具直到任务完成,返回自由文本**。终止条件不同,不适合硬套。
|
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
|
||||||
|
- **`schema`** — role 的 meta schema,定义输出格式
|
||||||
|
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
|
||||||
|
|
||||||
## Design
|
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
|
||||||
|
|
||||||
### 新包 `@uncaged/workflow-agent-react`
|
### AgentContext 不再需要
|
||||||
|
|
||||||
依赖:
|
现有 `AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,context 只需要 thread 信息,因此 `AgentContext` 可以删除。
|
||||||
- `@uncaged/workflow-protocol` — `AgentFn`, `AgentContext`, `LlmProvider` 类型
|
|
||||||
- `@uncaged/workflow-reactor` — `LlmFn`, `createLlmFn`, `ChatMessage`, `ToolDefinition`, `ToolCall` 类型
|
|
||||||
|
|
||||||
```
|
### createWorkflow 签名变更
|
||||||
packages/workflow-agent-react/
|
|
||||||
src/
|
|
||||||
types.ts
|
|
||||||
react-loop.ts # ReAct 循环核心
|
|
||||||
create-react-agent.ts # AgentFn 工厂
|
|
||||||
index.ts
|
|
||||||
package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 类型定义 (`types.ts`)
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
// Before
|
||||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
type AgentBinding = {
|
||||||
|
agent: AgentFn;
|
||||||
|
overrides: Partial<Record<string, AgentFn>> | null;
|
||||||
|
};
|
||||||
|
function createWorkflow(def, binding: AgentBinding): WorkflowFn;
|
||||||
|
|
||||||
/**
|
// After
|
||||||
* Tool handler: receives tool name + JSON arguments string,
|
type AdapterBinding = {
|
||||||
* returns tool output as string.
|
adapter: AdapterFn;
|
||||||
*/
|
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||||
|
};
|
||||||
|
function createWorkflow(def, binding: AdapterBinding): WorkflowFn;
|
||||||
|
```
|
||||||
|
|
||||||
|
`createWorkflow` 对每个 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## AdapterFn 实现
|
||||||
|
|
||||||
|
### 1. `createReactAdapter`(本 RFC 核心)
|
||||||
|
|
||||||
|
```typescript
|
||||||
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||||
|
|
||||||
type ReactAgentConfig = {
|
type ReactAdapterConfig = {
|
||||||
provider: LlmProvider;
|
provider: LlmProvider;
|
||||||
tools: readonly ToolDefinition[];
|
tools: readonly ToolDefinition[];
|
||||||
toolHandler: ReactToolHandler;
|
toolHandler: ReactToolHandler;
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
command: string | null; // 保持与其他 agent 包一致,此包忽略
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createReactAdapter(config: ReactAdapterConfig): AdapterFn;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 工厂函数 (`create-react-agent.ts`)
|
内部实现:
|
||||||
|
1. 接收 `(prompt, schema)` → 生成 resolve tool(schema → JSON Schema → tool definition)
|
||||||
|
2. 返回 `RoleFn<T>`,执行时:
|
||||||
|
- 用 `prompt` 作为 system message
|
||||||
|
- 用 thread history 构造 user message
|
||||||
|
- 进入 ReAct 循环:LLM 调用工具 → 执行 → 继续
|
||||||
|
- 当 LLM 调用 resolve tool → 校验 schema → 返回 `T`
|
||||||
|
- 纯文本回复视为错误(prompt 要求 agent 最终调用 resolve)
|
||||||
|
|
||||||
|
### 2. `agentToAdapter`(向后兼容包装器)
|
||||||
|
|
||||||
|
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { AgentFn } from "@uncaged/workflow-protocol";
|
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
|
||||||
import type { ReactAgentConfig } from "./types.js";
|
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||||
|
return async (ctx: ThreadContext): Promise<T> => {
|
||||||
function createReactAgent(config: ReactAgentConfig): AgentFn;
|
// 重建 AgentContext 给旧 agent 用
|
||||||
|
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
|
||||||
|
const output = typeof result === "string" ? result : result.output;
|
||||||
|
const result = await agent(agentCtx);
|
||||||
|
// 走 extract 流程(保持现有行为)
|
||||||
|
return extract(output, schema, extractProvider);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`AgentFn` 签名是 `(ctx: AgentContext) => Promise<AgentFnResult>`。
|
这样 hermes/cursor agent 无需改动,只是在 bundle-entry 层多包一层。
|
||||||
|
|
||||||
执行流程:
|
### 3. `createLlmAdapter`(单轮 chat)
|
||||||
1. 从 `ctx.currentRole.systemPrompt` 取 system prompt
|
|
||||||
2. 用 `buildAgentPrompt(ctx)` 构造完整 user message(含 thread history)
|
|
||||||
3. 进入 ReAct 循环
|
|
||||||
|
|
||||||
### ReAct 循环 (`react-loop.ts`)
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { LlmFn, ChatMessage, ToolDefinition, ToolCall } from "@uncaged/workflow-reactor";
|
function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||||
import type { ReactToolHandler } from "./types.js";
|
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||||
|
return async (ctx: ThreadContext): Promise<T> => {
|
||||||
type ReactLoopConfig = {
|
// 单轮 chat,要求 JSON output
|
||||||
llm: LlmFn;
|
// 用 schema 做 response_format 或 parse 校验
|
||||||
tools: readonly ToolDefinition[];
|
|
||||||
toolHandler: ReactToolHandler;
|
|
||||||
maxRounds: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReactLoopInput = {
|
|
||||||
systemPrompt: string;
|
|
||||||
userMessage: string;
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Returns the assistant's final text reply (the first reply without tool calls).
|
|
||||||
*/
|
|
||||||
function runReactLoop(config: ReactLoopConfig, input: ReactLoopInput): Promise<string>;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**循环逻辑:**
|
## ReAct 循环细节
|
||||||
|
|
||||||
|
### 终止条件
|
||||||
|
|
||||||
|
与 reactor(structured extraction)不同,react adapter 的终止条件是 **agent 调用 resolve tool**:
|
||||||
|
|
||||||
```
|
```
|
||||||
messages = [system, user]
|
messages = [system(prompt), user(threadHistory)]
|
||||||
for round in 0..maxRounds:
|
for round in 0..maxRounds:
|
||||||
response = llm({ messages, tools })
|
response = llm({ messages, tools: [...userTools, resolveTool] })
|
||||||
assistant = parseAssistantMessage(response)
|
assistant = parseAssistantMessage(response)
|
||||||
if assistant has tool_calls:
|
for each tool_call in assistant.tool_calls:
|
||||||
messages.push(assistant)
|
if tool_call.name == "resolve":
|
||||||
for each tool_call:
|
validate(tool_call.arguments, schema)
|
||||||
result = toolHandler(name, arguments)
|
if valid: return parsed_value
|
||||||
messages.push({ role: "tool", tool_call_id, content: result })
|
else: push error feedback, continue loop
|
||||||
else:
|
else:
|
||||||
return assistant.content // ← 终止:纯文本回复 = 任务完成
|
result = toolHandler(name, arguments)
|
||||||
|
push tool result
|
||||||
|
if no tool_calls:
|
||||||
|
// 纯文本回复 → 提醒 agent 必须调用 resolve
|
||||||
|
push correction message, continue loop
|
||||||
throw Error("max rounds exceeded")
|
throw Error("max rounds exceeded")
|
||||||
```
|
```
|
||||||
|
|
||||||
### 需要从 reactor 导出的公共函数
|
### resolve tool 生成
|
||||||
|
|
||||||
reactor 内部的 assistant message 解析逻辑是私有的。react-agent 需要相同的解析能力。两个方案:
|
|
||||||
|
|
||||||
**方案 A:从 reactor 导出解析函数**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// workflow-reactor/src/index.ts 新增导出
|
function buildResolveTool<T>(schema: z.ZodType<T>): ToolDefinition {
|
||||||
export { firstAssistantMessage } from "./thread-reactor.js";
|
return {
|
||||||
export { normalizeToolCalls } from "./thread-reactor.js";
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "resolve",
|
||||||
|
description: "Submit the final structured output for this role. Call this when the task is complete.",
|
||||||
|
parameters: zodToJsonSchema(schema),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**方案 B:react-agent 自己实现解析(~30 行)**
|
### 与 reactor 的关系
|
||||||
|
|
||||||
考虑到解析逻辑简单且 reactor 的实现和 react-agent 的需求略有不同(reactor 需要处理 plain JSON fallback,react-agent 不需要),**倾向方案 B**,避免 reactor 为了外部消费调整内部结构。
|
- **复用**:`LlmFn` / `createLlmFn`、`ToolDefinition` / `ToolCall` / `ChatMessage` 类型
|
||||||
|
- **不复用**:reactor 的 ReAct 循环(终止条件不同)、assistant 消息解析(reactor 有 plain JSON fallback 等多余逻辑)
|
||||||
|
- **不修改 reactor**:react-agent 自己实现解析(~30 行),保持 reactor 专注 structured extraction
|
||||||
|
|
||||||
### bundle-entry 用法
|
## 包结构
|
||||||
|
|
||||||
```typescript
|
|
||||||
// workflows/develop/entry.ts(smoke test 用)
|
|
||||||
import { createReactAgent } from "@uncaged/workflow-agent-react";
|
|
||||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
|
||||||
import { developWorkflowDefinition, buildDevelopDescriptor } from "@uncaged/workflow-template-develop";
|
|
||||||
|
|
||||||
const agent = createReactAgent({
|
|
||||||
provider: { baseUrl: "...", apiKey: "...", model: "..." },
|
|
||||||
tools: [readFileTool, writeFileTool, shellExecTool],
|
|
||||||
toolHandler: handleTool,
|
|
||||||
maxRounds: 30,
|
|
||||||
command: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const descriptor = buildDevelopDescriptor();
|
|
||||||
export const run = createWorkflow(developWorkflowDefinition, { agent, overrides: null });
|
|
||||||
```
|
```
|
||||||
|
packages/workflow-agent-react/
|
||||||
|
src/
|
||||||
|
types.ts # ReactAdapterConfig, ReactToolHandler
|
||||||
|
resolve-tool.ts # buildResolveTool (zod → tool definition)
|
||||||
|
parse-assistant.ts # assistant message 解析
|
||||||
|
react-loop.ts # ReAct 循环核心
|
||||||
|
create-react-adapter.ts # AdapterFn 工厂
|
||||||
|
index.ts
|
||||||
|
__tests__/
|
||||||
|
react-loop.test.ts
|
||||||
|
package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
- `@uncaged/workflow-protocol` — `ThreadContext`, `LlmProvider`
|
||||||
|
- `@uncaged/workflow-reactor` — `LlmFn`, `createLlmFn`, `ChatMessage`, `ToolDefinition`, `ToolCall`
|
||||||
|
- `zod` — schema
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
| 改动 | 影响 |
|
||||||
|
|------|------|
|
||||||
|
| `AgentBinding` → `AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
|
||||||
|
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)需改为接收 `ThreadContext` |
|
||||||
|
| extract 从 engine 下沉到 adapter | `workflow-execute` 的 engine 简化 |
|
||||||
|
|
||||||
|
### 需修改的包
|
||||||
|
|
||||||
|
1. `workflow-protocol` — 删除 `AgentContext`,新增 `AdapterFn` / `RoleFn` / `AdapterBinding`
|
||||||
|
2. `workflow-runtime` — 更新 re-export
|
||||||
|
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx)` + `extract`
|
||||||
|
4. `workflow-util-agent` — `buildAgentPrompt` 改为接收 `ThreadContext`
|
||||||
|
5. `workflow-agent-hermes` / `workflow-agent-cursor` — 不改内部,在 util 层提供 `agentToAdapter`
|
||||||
|
6. 所有 bundle-entry — `agent:` → `adapter:`
|
||||||
|
|
||||||
|
### 不受影响
|
||||||
|
|
||||||
|
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
1. **Phase 1**: protocol 层类型定义 + `createWorkflow` 签名变更 + `agentToAdapter` 兼容包装
|
||||||
|
2. **Phase 2**: `workflow-agent-react` 包 — ReAct 循环 + resolve tool + 测试
|
||||||
|
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
|
||||||
|
|
||||||
## 工具集(后续讨论)
|
## 工具集(后续讨论)
|
||||||
|
|
||||||
最小闭环需要的工具待定,候选参考 hermes builtin:
|
最小闭环候选,参考 hermes builtin:
|
||||||
|
|
||||||
| 工具 | 说明 | 优先级 |
|
| 工具 | 说明 | 优先级 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
@@ -178,17 +243,3 @@ export const run = createWorkflow(developWorkflowDefinition, { agent, overrides:
|
|||||||
| `shell_exec` | 执行 shell 命令 | P0 |
|
| `shell_exec` | 执行 shell 命令 | P0 |
|
||||||
| `search_files` | grep / find | P1 |
|
| `search_files` | grep / find | P1 |
|
||||||
| `list_files` | ls | P1 |
|
| `list_files` | ls | P1 |
|
||||||
|
|
||||||
工具实现放在 react-agent 包内还是独立包,取决于复用需求。
|
|
||||||
|
|
||||||
## 不做的事
|
|
||||||
|
|
||||||
- **不泛化 reactor** — reactor 的 structured extraction 循环和 agent 的自由文本循环是两个不同的关注点,不强行统一
|
|
||||||
- **不处理 childThread** — react-agent 返回纯文本 `string`,不支持嵌套 workflow(那是 `workflowAsAgent` 的事)
|
|
||||||
- **不内置 system prompt** — 直接用 role definition 里的 `systemPrompt`,不额外包装
|
|
||||||
|
|
||||||
## Phases
|
|
||||||
|
|
||||||
1. **Phase 1**: 包骨架 + ReAct 循环 + `createReactAgent` + 测试(mock LLM)
|
|
||||||
2. **Phase 2**: 工具集实现(read/write/patch/shell)
|
|
||||||
3. **Phase 3**: bundle-entry 集成 + smoke test 闭环
|
|
||||||
|
|||||||
Reference in New Issue
Block a user