diff --git a/docs/plans/2026-05-12-react-agent.md b/docs/plans/2026-05-12-react-agent.md index d9d5858..e15c96d 100644 --- a/docs/plans/2026-05-12-react-agent.md +++ b/docs/plans/2026-05-12-react-agent.md @@ -1,6 +1,6 @@ # workflow-agent-react — ReAct Agent Package -**Status**: RFC +**Status**: RFC v2 **Author**: 小橘 🍊 ## Problem @@ -15,160 +15,225 @@ 缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。 -用途: -1. **Smoke test 闭环** — setup → bundle → add → run → show,用 workflow.yaml 里配置的 provider 直接跑,不需要装 hermes/cursor -2. **轻量 agent** — 只需要读写文件 + 跑命令的场景,不需要启动完整的 CLI agent +## 核心设计变更:AdapterFn 替代 AgentFn -## 现有 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 -// reactor 的终止条件:拿到符合 schema 的 structured output -ThreadReactorFn = (args: { - thread: TThread; - input: string; - schema: z.ZodType; // ← 强制要求 -}) => Promise> +type RoleFn = (ctx: ThreadContext) => Promise; + +type AdapterFn = (prompt: string, schema: z.ZodType) => RoleFn; ``` -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 不再需要 -依赖: -- `@uncaged/workflow-protocol` — `AgentFn`, `AgentContext`, `LlmProvider` 类型 -- `@uncaged/workflow-reactor` — `LlmFn`, `createLlmFn`, `ChatMessage`, `ToolDefinition`, `ToolCall` 类型 +现有 `AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,context 只需要 thread 信息,因此 `AgentContext` 可以删除。 -``` -packages/workflow-agent-react/ - src/ - types.ts - react-loop.ts # ReAct 循环核心 - create-react-agent.ts # AgentFn 工厂 - index.ts - package.json -``` - -### 类型定义 (`types.ts`) +### createWorkflow 签名变更 ```typescript -import type { LlmProvider } from "@uncaged/workflow-protocol"; -import type { ToolDefinition } from "@uncaged/workflow-reactor"; +// Before +type AgentBinding = { + agent: AgentFn; + overrides: Partial> | null; +}; +function createWorkflow(def, binding: AgentBinding): WorkflowFn; -/** - * Tool handler: receives tool name + JSON arguments string, - * returns tool output as string. - */ +// After +type AdapterBinding = { + adapter: AdapterFn; + overrides: Partial> | 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; -type ReactAgentConfig = { +type ReactAdapterConfig = { provider: LlmProvider; tools: readonly ToolDefinition[]; toolHandler: ReactToolHandler; 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`,执行时: + - 用 `prompt` 作为 system message + - 用 thread history 构造 user message + - 进入 ReAct 循环:LLM 调用工具 → 执行 → 继续 + - 当 LLM 调用 resolve tool → 校验 schema → 返回 `T` + - 纯文本回复视为错误(prompt 要求 agent 最终调用 resolve) + +### 2. `agentToAdapter`(向后兼容包装器) + +把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`: ```typescript -import type { AgentFn } from "@uncaged/workflow-protocol"; -import type { ReactAgentConfig } from "./types.js"; - -function createReactAgent(config: ReactAgentConfig): AgentFn; +function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn { + return (prompt: string, schema: z.ZodType): RoleFn => { + return async (ctx: ThreadContext): Promise => { + // 重建 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`。 +这样 hermes/cursor agent 无需改动,只是在 bundle-entry 层多包一层。 -执行流程: -1. 从 `ctx.currentRole.systemPrompt` 取 system prompt -2. 用 `buildAgentPrompt(ctx)` 构造完整 user message(含 thread history) -3. 进入 ReAct 循环 - -### ReAct 循环 (`react-loop.ts`) +### 3. `createLlmAdapter`(单轮 chat) ```typescript -import type { LlmFn, ChatMessage, ToolDefinition, ToolCall } from "@uncaged/workflow-reactor"; -import type { ReactToolHandler } from "./types.js"; - -type ReactLoopConfig = { - llm: LlmFn; - 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; +function createLlmAdapter(provider: LlmProvider): AdapterFn { + return (prompt: string, schema: z.ZodType): RoleFn => { + return async (ctx: ThreadContext): Promise => { + // 单轮 chat,要求 JSON output + // 用 schema 做 response_format 或 parse 校验 + }; + }; +} ``` -**循环逻辑:** +## ReAct 循环细节 + +### 终止条件 + +与 reactor(structured extraction)不同,react adapter 的终止条件是 **agent 调用 resolve tool**: ``` -messages = [system, user] +messages = [system(prompt), user(threadHistory)] for round in 0..maxRounds: - response = llm({ messages, tools }) + response = llm({ messages, tools: [...userTools, resolveTool] }) assistant = parseAssistantMessage(response) - if assistant has tool_calls: - messages.push(assistant) - for each tool_call: + for each tool_call in assistant.tool_calls: + if tool_call.name == "resolve": + validate(tool_call.arguments, schema) + if valid: return parsed_value + else: push error feedback, continue loop + else: result = toolHandler(name, arguments) - messages.push({ role: "tool", tool_call_id, content: result }) - else: - return assistant.content // ← 终止:纯文本回复 = 任务完成 + push tool result + if no tool_calls: + // 纯文本回复 → 提醒 agent 必须调用 resolve + push correction message, continue loop throw Error("max rounds exceeded") ``` -### 需要从 reactor 导出的公共函数 - -reactor 内部的 assistant message 解析逻辑是私有的。react-agent 需要相同的解析能力。两个方案: - -**方案 A:从 reactor 导出解析函数** +### resolve tool 生成 ```typescript -// workflow-reactor/src/index.ts 新增导出 -export { firstAssistantMessage } from "./thread-reactor.js"; -export { normalizeToolCalls } from "./thread-reactor.js"; +function buildResolveTool(schema: z.ZodType): ToolDefinition { + return { + 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 | | `search_files` | grep / find | 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 闭环