Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-10
116 changes: 116 additions & 0 deletions openspec/changes/archive/2026-05-10-multi-agent-pipeline/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
## Context

当前项目已有稳定的 Agent 架构:

- **Web Worker** (`agent.worker.ts`) — 独立线程跑 LLM 调用,不阻塞 UI
- **ReAct 循环** — 最多 5 步:LLM 决定是否调工具 → 主线程执行 → 结果注入 → 继续
- **工具 Round-trip** — Worker `postMessage` 请求主线程执行 `readMindmap` / `generateMindmapOps`
- **两种模式** — ENHANCE(对话生成了新内容,自动更新脑图)、MEDIATE(用户对 Agent 说话,更新脑图 + 流式回答)
- **`useMindmapAgent` hook** — 统一入口,外部只调 `initialize / enhanceMessage / mediateMessage`

以上架构通过 30+ 轮迭代验证,稳定有效。问题出在工程实现层面:335 行单文件、零数据校验、prompt 内联。

## Goals / Non-Goals

**Goals:**
- 提取 `BaseAgent` 抽象类,使 Agent 逻辑可单独测试
- 从 `agent.worker.ts` 提取 `ReActRunner`,解耦消息路由和推理循环
- System prompt 从 hook 移到独立文件
- `applyOperations()` 前加 Zod 校验
- 保持所有现有接口和行为不变

**Non-Goals:**
- 不改 ReAct 循环为多 Agent Pipeline(过度设计)
- 不拆包、不加 monorepo
- 不换 LLM 框架(继续用 Vercel AI SDK)
- 不引入新外部依赖
- 不改变 postMessage 协议
- 不改变 `useMindmapAgent` 对外的三个方法签名

## Decisions

### Decision 1: 保持 ReAct 循环,提取到类

**选择**:`ReActRunner` 类封装循环逻辑,`agent.worker.ts` 只做消息路由。

```typescript
class ReActRunner {
constructor(private agent: BaseAgent, private tools: ToolSet) {}

async run(userPrompt: string, options?: ReActOptions): Promise<string> {
const messages = [{ role: 'user', content: userPrompt }]
for (let step = 0; step < options?.maxSteps ?? 5; step++) {
const result = await this.agent.callLLM({ messages, tools: this.tools })
// ... 处理 toolCalls / toolResults
if (result.toolResults.length === 0) return result.text ?? ''
}
}
}
```

**不拆分 Planner/Writer/Reflector**:当前的 ReAct 循环只有 2-3 步,拆成 3 个独立 Agent 会增加 2 次额外 LLM 调用,且 Planner 看不到 Writer 的执行结果。一张专门化的 system prompt 在单次上下文里做完"计划→执行→回答"更高效。

### Decision 2: Zod 校验放在 applyOperations 前

**选择**:在 `agent-tools.ts` 的 `generateMindmapOps` 处理器中,`applyOperations()` 调用前加 `safeParse`。

```typescript
const OperationsArraySchema = z.array(z.object({
type: z.enum(['add_child', 'update', 'delete_leaf', 'add_root']),
parentId: z.string().optional(),
nodeId: z.string().optional(),
label: z.string().optional(),
summary: z.string().optional(),
// ... 等
})).max(10)

export async function generateMindmapOps(input: { operations: unknown }) {
const parsed = OperationsArraySchema.safeParse(input.operations)
if (!parsed.success) {
return { error: `操作校验失败: ${parsed.error.message}`, success: false }
}
const newTree = applyOperations(tree, parsed.data)
// ...
}
```

LLM 输出不可信,这是数据完整性的最后一道防线。

### Decision 3: BaseAgent 是薄封装

**选择**:`BaseAgent` 只封装 `callLLM()` 和 `callTool()` 两个方法,不定义 `process()` 抽象方法。

当前只有一个 Agent,不需要复杂的多 Agent 继承体系。BaseAgent 提供:
- `this.ctx` — 统一的 AgentContext(model, endpoint, logger)
- `this.callLLM()` — 封装 generateText 调用
- `this.callTool()` — 封装工具 round-trip

未来如果需要加第二个 Agent,直接继承即可。

## File Structure

```
改动前 改动后

src/ src/
├── workers/ ├── workers/
│ └── agent.worker.ts (335行) │ └── agent.worker.ts (~120行) ← 只做消息路由
├── hooks/ ├── hooks/
│ └── useMindmapAgent.ts (324行) │ └── useMindmapAgent.ts (~280行) ← 移除内联 prompt
├── lib/ ├── lib/
│ ├── agent/ │ ├── agent/
│ │ ├── types.ts │ │ ├── types.ts
│ │ └── agent-tools.ts │ │ ├── agent-tools.ts (+ Zod)
│ └── llm-client.ts │ │ ├── system-prompt.ts ← 新增
│ │ │ ├── base-agent.ts ← 新增
│ │ │ └── schema.ts ← 新增
│ │ └── llm-client.ts
```

## Risks / Trade-offs

| Risk | Mitigation |
|------|-----------|
| 提取 ReActRunner 引入 bug | 保留原有逻辑 100%,只做提取不改变行为;全部回归测试 |
| Zod 校验误拒绝合法操作 | 用 `safeParse` 不抛异常;校验失败返回 descriptive error,不影响系统 |
| 新增文件增加复杂度 | 3 个新文件,每个 50-150 行,工程化的必要成本 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Why

当前 Agent 系统的骨架是对的(Web Worker + ReAct 循环 + 工具系统),但存在三个工程问题:

1. **代码耦合**:`agent.worker.ts` 335 行,消息路由、ReAct 循环、工具注册、状态报告全部胶合在一个文件中,无法单独测试任何一部分
2. **缺少数据校验**:LLM 输出的 operations 直接 `applyOperations()`,没有 Zod schema 验证。格式错误(缺字段、类型不对、ID 用 "1")会直接污染脑图数据
3. **System prompt 硬编码**:176-198 行的 prompt 字符串嵌在 `useMindmapAgent.ts` hook 中,无法测试、无法切换、无法复用

不改变架构(不改 ReAct 循环、不改 Worker、不改两种模式),只做工程解耦 + 数据安全。

## What Changes

- 新增 `BaseAgent` 类,封装 LLM 调用和工具交互
- 从 `agent.worker.ts` 提取 `ReActRunner` 类,解耦消息路由和 ReAct 循环
- System prompt 从 `useMindmapAgent.ts` 移到独立文件 `src/lib/agent/system-prompt.ts`
- `applyOperations()` 前加 **Zod schema 校验**,拒绝坏数据
- `AgentStatus` 状态类型不变,ENHANCE_MESSAGE / MEDIATE_MESSAGE 两种模式不变
- 不改 Worker、不改 useMindmapAgent 接口、不改 agent-tools.ts 工具处理器

## Capabilities

### New Capabilities
- `agent-core`: BaseAgent 基类 + ReActRunner 循环提取,Agent 核心逻辑可测试化
- `agent-schema`: Zod schema 校验层,在 operations 应用前做结构验证

### Modified Capabilities
- 无

## Impact

- **新增文件**:`src/lib/agent/system-prompt.ts`、`src/lib/agent/base-agent.ts`、`src/lib/agent/schema.ts`
- **修改文件**:`src/workers/agent.worker.ts`(提取 ReAct 循环到类)、`src/hooks/useMindmapAgent.ts`(移除内联 prompt)、`src/lib/agent/agent-tools.ts`(加 Zod 校验)
- **无外部依赖变更**
- **删除文件**:无
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## ADDED Requirements

### Requirement: BaseAgent provides unified LLM calling

The system SHALL provide a `BaseAgent` class that encapsulates LLM invocation and tool interaction for all agent implementations.

```typescript
abstract class BaseAgent {
protected ctx: AgentContext
abstract get name(): string
protected async callLLM(params: CallLLMParams): Promise<LLMResponse>
protected async callTool(name: string, args: unknown): Promise<unknown>
}

interface AgentContext {
model: string
systemPrompt: string
providerConfig: { apiEndpoint: string; apiKey: string }
onToolCall: (name: string, args: unknown) => Promise<unknown>
onStatusReport: (status: AgentStatus, message?: string) => void
}
```

#### Scenario: Agent calls LLM with system prompt
- **WHEN** an agent invokes `this.callLLM({ messages, tools })`
- **THEN** the system SHALL send the messages + system prompt to the LLM
- **THEN** the response SHALL include text content and any tool calls

#### Scenario: Agent calls a tool
- **WHEN** an agent invokes `this.callTool('readMindmap', {})`
- **THEN** the system SHALL forward the call to the main thread via `onToolCall`
- **THEN** the result SHALL be returned to the agent

### Requirement: ReActRunner encapsulates reasoning loop

The system SHALL provide a `ReActRunner` class that orchestrates the tool-use reasoning loop independently of message routing.

```typescript
class ReActRunner {
constructor(agent: BaseAgent, tools: ToolDefinition[])
async run(userPrompt: string, options?: { maxSteps?: number }): Promise<string>
}
```

The ReActRunner SHALL:
- Accept a max of 5 steps per run
- Process tool calls and inject tool results back into the conversation
- Return the final text response when no more tool calls are needed
- Report status updates (thinking, reading_mindmap, generating_mindmap) at appropriate steps

#### Scenario: ReActRunner completes with tool calls
- **WHEN** the LLM calls `readMindmap` then `generateMindmapOps`
- **THEN** the ReActRunner SHALL execute both tools in sequence
- **THEN** the ReActRunner SHALL return the final LLM text response

#### Scenario: ReActRunner handles invalid tool call
- **WHEN** the LLM produces a tool call with invalid JSON parameters
- **THEN** the ReActRunner SHALL inject an error message into the conversation
- **THEN** the ReActRunner SHALL continue the loop (up to maxSteps)

### Requirement: System prompt lives in a separate module

The system prompt for the mindmap agent SHALL be defined in `src/lib/agent/system-prompt.ts` as an exported function.

```typescript
export function buildMindmapAgentPrompt(): string
```

The prompt SHALL include:
- Tool descriptions (readMindmap, generateMindmapOps)
- Workflow guidance (read → think → execute → respond)
- Operational rules (editedByUser protection, ID naming rules, 10 ops max, summary ≤ 50 chars)

#### Scenario: Prompt is imported by Worker and hook
- **WHEN** `agent.worker.ts` initializes the agent
- **THEN** it SHALL call `buildMindmapAgentPrompt()` to get the system prompt
- **THEN** `useMindmapAgent.ts` SHALL NOT contain inline prompt strings

### Requirement: Worker file only handles message routing

The `src/workers/agent.worker.ts` SHALL be reduced to message routing and orchestration only:

```
onmessage → switch(msg.type)
INIT → create BaseAgent + ReActRunner
ENHANCE → build user prompt → ReActRunner.run()
MEDIATE → build user prompt → ReActRunner.run()
```

The file SHALL NOT contain:
- ReAct loop logic (moved to ReActRunner)
- System prompt text (moved to system-prompt.ts)
- Tool definitions (already in agent.worker.ts as AI SDK tool objects, kept here)

#### Scenario: Worker routes ENHANCE_MESSAGE
- **WHEN** Worker receives ENHANCE_MESSAGE
- **THEN** it SHALL construct a user prompt from messages → call `runner.run(prompt)` → post AGENT_COMPLETE

#### Scenario: Worker routes MEDIATE_MESSAGE
- **WHEN** Worker receives MEDIATE_MESSAGE
- **THEN** it SHALL construct a user prompt → call `runner.run(prompt)` → post STREAM_TOKEN / STREAM_DONE
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
## ADDED Requirements

### Requirement: Operations are validated before application

The system SHALL validate all mindmap operations using Zod schema BEFORE calling `applyOperations()`.

```typescript
import { z } from 'zod'

export const MindmapOperationSchema = z.object({
type: z.enum(['add_child', 'update', 'delete_leaf', 'add_root']),
parentId: z.string().optional(),
nodeId: z.string().optional(),
id: z.string().optional(),
label: z.string().optional(),
summary: z.string().optional(),
patch: z.object({
label: z.string().optional(),
summary: z.string().optional(),
}).optional(),
})

export const OperationsArraySchema = z
.array(MindmapOperationSchema)
.max(10, '每次最多 10 个操作')
```

The validation SHALL be performed in the `generateMindmapOps` tool handler in `agent-tools.ts`.

#### Scenario: Valid operations pass validation
- **WHEN** `generateMindmapOps` receives `{ operations: [{ type: 'add_child', parentId: 'n1a2b3c', label: 'TypeScript' }] }`
- **THEN** `OperationsArraySchema.safeParse()` SHALL return `success: true`
- **THEN** the operations SHALL be applied to the mindmap

#### Scenario: Invalid type is rejected
- **WHEN** `generateMindmapOps` receives `{ operations: [{ type: 'invalid_type' }] }`
- **THEN** `safeParse()` SHALL return `success: false`
- **THEN** the tool SHALL return `{ error: '操作校验失败: ...', success: false }`
- **THEN** the mindmap SHALL NOT be modified

#### Scenario: Missing required label on add_child is rejected
- **WHEN** `generateMindmapOps` receives `{ operations: [{ type: 'add_child', parentId: 'n1a2b3c' }] }` (no label)
- **THEN** validation SHALL succeed (label is optional in schema, the apply engine handles default)
- **THEN** the node SHALL be created with default label

#### Scenario: Numeric IDs are rejected
- **WHEN** `generateMindmapOps` receives `{ operations: [{ type: 'add_child', id: '1', label: 'X' }] }`
- **THEN** validation SHALL succeed (Zod doesn't check semantics — the apply engine rejects id='1')
- **THEN** the operation SHALL be applied with the ID as-is (id validation is a future enhancement)

#### Scenario: Operations exceed maximum
- **WHEN** `generateMindmapOps` receives 15 operations in one call
- **THEN** `OperationsArraySchema.safeParse()` SHALL return `success: false`
- **THEN** the error message SHALL indicate the operations count exceeded 10

### Requirement: Validation errors are surfaced to the Agent

When Zod validation fails, the error SHALL be returned to the Worker so the ReAct loop can retry with corrected input.

#### Scenario: Agent retries after validation error
- **WHEN** the ReActRunner receives a validation error from `generateMindmapOps`
- **THEN** the error SHALL be injected into the LLM conversation as a tool result
- **THEN** the LLM SHALL have the opportunity to correct its output in the next ReAct step
43 changes: 43 additions & 0 deletions openspec/changes/archive/2026-05-10-multi-agent-pipeline/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## 1. System Prompt Extraction

- [x] 1.1 Create `src/lib/agent/system-prompt.ts` — export `buildMindmapAgentPrompt()` function
- [x] 1.2 Copy the inline prompt from `useMindmapAgent.ts` (lines 176-198) into the new function
- [x] 1.3 Update `useMindmapAgent.ts` to import from system-prompt.ts instead of inline string
- [x] 1.4 Verify agent behavior is identical after extraction

## 2. Zod Schema Validation

- [x] 2.1 Create `src/lib/agent/schema.ts` — define `MindmapOperationSchema` and `OperationsArraySchema`
- [x] 2.2 Add `safeParse` call in `agent-tools.ts` `generateMindmapOps` handler before `applyOperations()`
- [x] 2.3 Return descriptive error on validation failure (with field path and message)
- [x] 2.4 Write unit tests for valid operations passing and invalid operations being rejected
- [x] 2.5 Write unit test for operations exceeding max count (10)

## 3. BaseAgent Class

- [x] 3.1 Create `src/lib/agent/base-agent.ts` — abstract BaseAgent with `callLLM()` and `callTool()`
- [x] 3.2 Define `AgentContext` interface (model, providerConfig, systemPrompt, onToolCall, onStatusReport)
- [x] 3.3 Write unit tests for BaseAgent (callLLM mock, callTool dispatch)

## 4. ReActRunner Extraction

- [x] 4.1 Create `src/lib/agent/ReActRunner.ts` — class that encapsulates the ReAct loop
- [x] 4.2 Extract the loop logic from `agent.worker.ts` lines 92-175 into ReActRunner
- [x] 4.3 Keep the same behavior: 5 max steps, tool result injection, invalid call retry
- [x] 4.4 Update `agent.worker.ts` to instantiate ReActRunner and call `.run()` from message handlers
- [x] 4.5 Verify ENHANCE_MESSAGE mode works identically
- [x] 4.6 Verify MEDIATE_MESSAGE mode works identically (streaming still functional)

## 5. Worker File Cleanup

- [x] 5.1 Verify `agent.worker.ts` is reduced to message routing only (< ~150 lines)
- [x] 5.2 Confirm all postMessage protocols (AGENT_STATUS, AGENT_COMPLETE, STREAM_TOKEN, etc.) unchanged
- [x] 5.3 Confirm tool definitions (AI SDK `tool()` objects) remain in worker (they are worker-specific)

## 6. Regression Testing

- [x] 6.1 Run existing test suite — verify no regressions
- [x] 6.2 Test ENHANCE_MESSAGE: AI responds → agent auto-updates mindmap
- [x] 6.3 Test MEDIATE_MESSAGE: user sends agent message → mindmap updates + answer streams
- [x] 6.4 Test Zod validation path: inject bad operations → verify rejection
- [x] 6.5 Test editedByUser protection: manually edit node → verify AI respects it
Loading
Loading