# 2026-05-19 #410 Agent 业务模块接入规范方向 review

工程审查 plan-review 走完 4 个 section、11 项决策，0 关键缺口、1 软缺口。
本文沉淀决策过程 + 选项对照，供未来 PR-A PoC / standards/17 起草 / #409 PRD 引用。

**链接**：

- Issue：http://43.130.59.228/FFAIWorkspace/workspace/issues/410
- Review 评论：http://43.130.59.228/FFAIWorkspace/workspace/issues/410#issuecomment-4528
- 关联 #409（AI-first 重设计）：http://43.130.59.228/FFAIWorkspace/workspace/issues/409

---

## 决策汇总速查表

| ID | 主题 | 锁定 | 一句话 |
|---|---|---|---|
| Q1.1 | 注册机制 | C | 装饰器 + `AgentToolsModule.forFeature` 显式收集 + CI 暴露面清单 |
| Q1.2 | inputSchema | A | 保留扁平 3 类型 + parse error 监控 + 量化升级触发 |
| Q1.3 | AgentContext | A | 严格 issue 原 6 字段 + trace 极简 `{ traceId }` |
| Q1.4 | 命名规范 | A | snake_case `<domain>_<action>` + CI 校验两段制 |
| Q1.5 | 审计/限流挂载 | B + B.1 | ToolRegistry.invoke 包装层 + `descriptor.destructive` mode-aware |
| Q1.6 | 跟 #409 时序 | B + B.1 | 柔性卡口 + PR-D lint 合入即双轨期截止 |
| Q2.1 | 代码物理位置 | B | `backend/src/modules/agent/registry/` + 业务模块单一入口 |
| Q2.2 | 30 工具迁移 | B + B.1 | PR-A 全迁完，删 27 wrapper class，无双轨 |
| Q2.3 | 错误处理边界 | B + B.1 | 异常三档（业务/安全/系统）+ sanitize 黑名单 |
| Q3.1 | 测试覆盖 | B | 20 项 L1（含装饰器签名漂移 + 异常边界） |
| Q4.1 | audit/quota 同步性 | B + B.1 | before 同步 + after 异步队列 + 3 次指数退避 + DLQ |

---

## Section 1 — 架构（6 项）

### Q1.1 注册机制

**问题**：业务模块如何向 agent 暴露能力？

| 选项 | 描述 |
|---|---|
| A | 装饰器 `@AgentTool` + NestJS DiscoveryService 全局扫所有 provider（issue 原方案） |
| B | `AgentToolsModule.forFeature([...])` 显式注册（业务 module 列暴露方法） |
| **C** | **装饰器 `@AgentTool` 贴方法 + `forFeature([Service])` 显式登记要扫的 class（推荐 + 锁定）** |

**A 方案被否决的 8 条硬伤**：

1. 评审与调试体验差（启动期反射报错栈帧难看，"Cannot read property X of undefined" 类型）
2. IDE 跳转弱（装饰器 → 反射 → 注册表，链路断）
3. PR review 难（reviewer 要心里跑一遍启动期反射）
4. 启动性能 + 启动失败语义（全局扫 → 错配挂应用，违反 fail-fast 不到位）
5. 反向依赖只从代码层迁到 module 层没真断（agent module 仍要 import ApprovalModule 才能 DI resolve）
6. ToolDescriptor 字段演化更危险（装饰器参数类型检查弱）
7. 命名规范变化爆炸半径更大（全量 grep 业务模块）
8. 测试隔离反而更难（要起整个 NestJS app）

**C 取舍**：

- 拿到 A 的"方法级可见性"（业务工程师改方法时立刻知道"我是 agent 工具"）
- 拿到 B 的"显式扫描边界"（IDE 跳转 / 调试友好 / 启动性能可控）
- 代价：业务模块写 2 处（装饰器贴方法 + module 加 forFeature 一行）—— 对 FFOA 当前规模（3 → ~10 工具）可忽略
- **配套必做**：PR-D 含 CI 自动生成 `docs/agent-surface-area.md` + git diff 卡漂移，补全"全系统鸟瞰"

---

### Q1.2 inputSchema

**问题**：`ToolDescriptor.inputSchema` 当前是扁平 `string/number/boolean`，AI Form Genesis / AI_STAGE 输入是嵌套对象。要不要 PR-A 顺手升级到 JSON Schema 子集？

| 选项 | 描述 |
|---|---|
| **A** | **不升 inputSchema，工具用"string + JSON.parse"模式（锁定）** |
| B | 升级到 JSON Schema 子集（object/array/enum/required）+ ajv 校验（推荐被覆盖） |
| C | 升级 + 强制 30 工具全迁完 |

**根因（为什么"塞 JSON 字符串"是反模式）**：

- 现状 inputSchema 只支持 3 种 primitive，无嵌套/数组/enum
- 嵌套输入只能 `type: 'string'` + invoke 里 `JSON.parse`（approval-submit.tool.ts:35 实例）
- 后果：LLM 编 JSON 经常错；schema 描述退化为自然语言；OpenAI strict mode 校验失效；AI Form Genesis 失败率会很高

**A 锁定后的硬性约束**（必须写进 standards/17 + 监控）：

1. 嵌套输入沿用 `type: 'string'` + JSON.parse —— 是约定不是反模式
2. 业务方法 invoke 首行必须 JSON.parse + 业务层结构校验（ajv / class-validator 自选）
3. AI Form Genesis 实施时知道这条限制，靠 description 把 JSON 结构讲清楚
4. **量化升级触发条件**：
   - ≥3 个工具用 string 塞 JSON
   - 或 `agent_tool_invoke.input_parse_error` 监控 > 5%
   - → 自动开 issue 升级 JSON Schema
5. PR-A 范围加 `input_parse_error` counter

---

### Q1.3 AgentContext 字段

**问题**：业务方法第二参数契约。issue 列 6 字段，现场发现至少缺 4 个常用字段。

| 选项 | 描述 |
|---|---|
| **A** | **严格 issue 原 6 字段（锁定）**：userId / organizationId / surface / sessionId / turnId / trace |
| B | A + 补 locale / permissionMode / planMode / permissions / departmentId+regionId = 11 字段（推荐被覆盖） |
| C | B + confidence / routingTier |

**A 锁定后的硬性约束**：

1. trace 字段类型先用极简 `{ traceId: string }`，不引 OTel SDK
2. **业务方法自己从 session 二次查 locale / permissionMode / planMode / permissions / dept+region**
3. standards/17 必须显式写一节"如何在业务方法内补齐 AgentContext 缺失字段"，给标准代码片段，避免 30 业务方法各自发明轮子
4. **量化升级触发条件**：grep `session.locale` / `session.permissionMode` 在 `@AgentTool` 标注的 service 文件出现 ≥3 处 → 自动开升级 issue

**风险提示**：A 选项破坏成本 30 业务方法签名级，比 Q1.2 高一档。用户接受该风险换"PR-A 最小范围"。

---

### Q1.4 命名规范

**问题**：现网 30 工具全部 snake_case（`approval_submit` / `web_search`）；issue 提议 dot.case（`approval.submit`）。要不要改？

| 选项 | 描述 |
|---|---|
| **A + A.1** | **保留 snake_case + CI 校验 `<domain>_<action>` 两段制（锁定）** |
| B | 改 dot.case（issue 原方案） |
| C | `__` 双下划线命名空间（折中） |

**B 否决理由**：

1. 零工程收益（工具数 ≤100 量级时扁平命名空间够用）
2. 改名爆炸半径覆盖：30 工具 rename + 历史 session 表 tool_use 记录 + 前端 hardcode + LLM cache miss + MCP/client dispatchKey 命名不统一加深
3. 历史 session 兼容需 alias 表 + 双向解析 + 6 个月观察期
4. issue 原意"想要分类感"用 `descriptor.category` 新增字段 + CI 清单按 category 分组就够，**不必动 name 字段**

---

### Q1.5 审计 / 限流挂载

**问题**：业务方法被 agent 调 vs 被 REST 调，audit / quota / destructive / agent_confidence 挂哪？

| 选项 | 描述 |
|---|---|
| A | 业务方法自己判 + 自己 audit（反 DRY） |
| **B + B.1** | **ToolRegistry.invoke 包装层 + AgentContext 单信号 + `descriptor.destructive` 走包装层 mode-aware（锁定）** |
| C | NestJS Interceptor（需 fake ExecutionContext，巧妙逻辑） |

**B 锁定后的实现要点**：

- ToolRegistry.invoke 内部包装层执行序列：`audit.before → quota.check → destructive + mode-aware confirm → 反射调 service.method(input, ctx) → 异常分档 + sanitize → audit.after → metric`
- 业务方法保持薄实现：接 ctx + 干业务 + 返回结果
- destructive 在 descriptor 上声明，包装层统一处理（同 writeAction / controlTool 路径）

---

### Q1.6 跟 #409 时序

**问题**：#410 PR-A + PR-C 是否必须在 #409 PRD 起草前合入？

| 选项 | 描述 |
|---|---|
| A | 硬卡口：先 #410 后 #409 |
| **B + B.1** | **柔性卡口：#409 PRD 并行起草；#409 实施前等 PR-A 合入；PR-D lint 合入即双轨期截止（锁定）** |
| C | 无卡口（反模式扩散风险） |

**B 锁定后的具体安排**：

1. PR-A + PR-C 与 #409 PRD 并行起草（节省 1-2 周）
2. #409 PRD 文档显式标注"实施依赖 #410 PR-A 合入"
3. PR-A 合入瞬间 lint 卡口生效（`agent/tools/` 下不允许新增反向依赖文件）
4. EU AI Act 2026-08-02 时间窗有缓冲

---

## Section 2 — 代码质量（3 项）

### Q2.1 代码物理位置

| 选项 | 描述 |
|---|---|
| A | 三块代码放 `backend/src/modules/agent/tools/`（混乱目录语义） |
| **B** | **`backend/src/modules/agent/registry/` 子目录 + 业务模块统一入口（锁定）** |
| C | `packages/agent-tools-sdk/` 独立 npm 包（过早抽象） |

**B 物理布局**：

```
backend/src/modules/agent/registry/
  agent-tool.decorator.ts    # @AgentTool() 装饰器
  agent-tools.module.ts      # AgentToolsModule.forFeature 静态方法
  tool-registry.service.ts   # 含包装层升级（从 tools/ 迁过来）
  types.ts                   # AgentContext / AgentToolResult / ToolDescriptor
  index.ts                   # 业务模块唯一 import 入口
```

业务模块统一：`import { AgentTool, AgentContext, AgentToolsModule } from '@modules/agent/registry'`

**CI lint**：业务模块只能 import `@modules/agent/registry`，禁 `@modules/agent/{services,tools,providers,...}`。

---

### Q2.2 30 工具迁移策略

| 选项 | 描述 |
|---|---|
| A | issue 原方案：只迁 3 反向依赖工具，27 内部工具不迁（双轨期） |
| **B + B.1** | **PR-A 全 30 工具迁完 + 删 27 wrapper class 文件（锁定）** |
| C | 迁 1 个示例 + 按月批量（双轨期持续数月） |

**A 否决核心理由**：双轨期 = 包装层逻辑只覆盖 3/30 工具 → issue 标准第 6 条"强制 audit"形同虚设。

**B.1 锁定**：全删 wrapper class 文件（如 `approval-submit.tool.ts` 整个删），业务逻辑直接挂业务 service 方法上，零僵尸代码。

**预估改动**：装饰器 + forFeature + 包装层 + types ≈ 5 新文件；30 工具迁移 ≈ 30 文件改动 + 27 删除 ≈ 主题单一的大 PR。按 CLAUDE.md "按主题不按规模" 单 PR 合适。

---

### Q2.3 错误处理边界

| 选项 | 描述 |
|---|---|
| A | 全捕获 → 全 `ToolResult.ok=false`（泄密 + 反标准第 5 条） |
| **B + B.1** | **按异常类型三档 + sanitize 完整黑名单（锁定）** |
| C | 业务方法自己 try/catch 返回 ToolResult（反 DRY + 反 Q1.5） |

**三档逻辑**：

```typescript
catch (err) {
  if (err instanceof HttpException && err.getStatus() < 500) {
    // 档 1 业务校验 → 喂回 LLM
    return { ok: false, errorMessage: this.sanitize(err.message) };
  }
  if (err instanceof ForbiddenException || err instanceof UnauthorizedException) {
    // 档 2 安全 → 脱敏 + audit
    await this.audit.security(...);
    return { ok: false, errorMessage: '权限不足' };
  }
  // 档 3 系统错误 → audit + metric + 抛出 invoke 边界
  await this.audit.systemError(...);
  this.metrics.systemError.inc();
  throw err;
}
```

**B.1 sanitize 黑名单**（完整）：SQL 关键字 / 表名 / 文件绝对路径 / 堆栈 trace / IP / email / token (Bearer 等)。

**软缺口**：sanitize 黑名单不完整 → fuzz 测试覆盖已知模式 + 规则随生产观察持续扩展。

---

## Section 3 — 测试（1 项 + 20 项规格）

### Q3.1 测试覆盖范围

| 选项 | 描述 |
|---|---|
| A | 17 项（核心三档 + 注册路径） |
| **B** | **20 项 = 17 + T18-T20 边界（锁定）** |
| C | 20 + 启动性能基准 + chaos test |

### 20 项 L1 测试规格头

| ID | 测试名 | 验证 |
|---|---|---|
| T1 | `listAsProviderTools` 输出对照前后快照 | 30 工具迁移前/后输出格式 100% 一致 |
| T2 | `AgentToolsModule.forFeature` 注册路径 | service 含 `@AgentTool` → 启动后 `ToolRegistry.list()` 含 descriptor |
| T3 | `forFeature` 漏登记检测 | service 含 `@AgentTool` 但 module 未 forFeature → 启动期 fail-fast |
| T4 | 重名注册防御 | 两 service 同 name → 启动期抛 `Tool already registered` |
| T5 | 包装层 audit 链 | invoke 一次 → audit.before + audit.after 各一次 + 字段齐 |
| T6 | 异常档 1 业务校验 | 抛 `BadRequestException` → `ok=false` + errorMessage 含原因 + 不抛出 |
| T7 | 异常档 2 安全 | 抛 `ForbiddenException('详情...')` → `ok=false` + errorMessage='权限不足' + audit.security 含详情 |
| T8 | 异常档 3 系统 | 抛 `Error('connection refused')` → 抛出 invoke 边界 + audit.systemError + counter+1 |
| T9 | sanitize 黑名单（fuzz） | SQL / 路径 / 堆栈 / IP / email / token → `[REDACTED]` |
| T10 | mode-aware destructive | `READ_ONLY` + `destructive=true` → list 不可见；invoke 调 → Forbidden |
| T11 | Plan mode 屏蔽 writeAction | `REQUIRED` + `writeAction=true` → list 不可见 |
| T12 | AgentContext 字段完整性 | 业务方法第二参数收到 6 字段齐 + 不可变 |
| T13 | `docs/agent-surface-area.md` 自动生成 | 启动后生成 + 含 30 工具 + 按 category 分组 + git diff 卡漂移 |
| T14 | CI lint 业务模块 import 边界 | 故意 `import ... from '@modules/agent/services/x'` → ESLint fail |
| T15 | CI lint forFeature 漏登记 | service 加 @AgentTool 但 module 不 forFeature → ESLint fail |
| T16 | 30 工具迁移后行为不变 | 每个跑 invoke 测试 → output 字段语义不变 |
| T17 | OpenTelemetry trace 透传 | 调用方传 traceId → audit + 业务方法日志同 traceId |
| T18 | 非 Error 抛出兜底 | 业务方法 `throw 'string error'` / `throw null` → 包装层不崩 + 归档 3 |
| T19 | audit 链闭合 | audit.before 成功后业务方法抛错并被档 1/2 catch → audit.after 仍调用 |
| T20 | 装饰器签名漂移 | 签名从 `(dto, ctx)` 改为 `(dto)` → 启动期 fail-fast（TypeScript 编译期或运行期 assert） |

**测试雄心**：T9 sanitize 必须 fuzz（100+ 条随机错误消息看泄密率）。

---

## Section 4 — 性能（1 项）

### Q4.1 audit / quota 同步性

| 选项 | 描述 |
|---|---|
| A | 全同步（+150-300ms / turn，用户感知卡） |
| **B + B.1** | **before 同步 + quota 同步 + after 异步队列 + 3 次指数退避 + DLQ（锁定）** |
| C | 全异步 fire-and-forget（audit 不可靠 → EU AI Act 红线） |

**B 锁定后的实现**：

- audit.before 同步：决定准入 + 拿到 audit_id 供 after 关联；EU AI Act "调用前已落"硬约束
- quota 同步：限流逻辑要求
- audit.after 异步：业务结果已返回 LLM；本地内存队列 + bullmq + DB 三级兜底
- 失败处理：3 次指数退避 → DLQ + 告警
- 复用现有 bullmq 基建（D bucket webhook 同套），零新依赖
- 单 invoke 总开销 +15-25ms，p99 在 LLM 调用 ~500ms-2s 量级下 < 5% 增量

---

## 不在 PR-A 范围的明确延后项（8 项）

| 项 | 延后理由 |
|---|---|
| inputSchema 升级到 JSON Schema | Q1.2=A；监控触发条件自动开升级 issue |
| AgentContext 补 locale / permissions / mode / dept+region | Q1.3=A；3 个重复案例自动开升级 issue |
| `packages/agent-tools-sdk` 抽独立 npm 包 | Q2.1=B；单 backend 进程不需要 |
| 工具命名 dot.case | Q1.4=A；零工程收益 |
| chaos test（audit 挂掉 fallback） | Q3.1=B；留后续工单 |
| `descriptor.category` 字段 | 按业务模块分组已足够 |
| L3 Agent 主动提单 | #409 范畴 |
| MCP / CLI / Client executor 重构 | 独立工单 |

---

## 已有复用清单（9 项）

| 现有能力 | 复用方式 |
|---|---|
| `ToolRegistry.register / unregisterPrefix / list / invoke` | 不重写；新增 `onApplicationBootstrap` 扫描 + invoke 包装层 |
| `availability.surface / permissions / requiredCapabilities` | 直接搬到 `@AgentTool({ availability: ... })` |
| `writeAction / controlTool / dispatchKey` 字段 | 不变；新增 `destructive` 字段 |
| `AgentPlanMode / AgentPermissionMode` Prisma enum | 包装层 mode-aware 直接用 |
| `audit-system` 写链 | before 同步调 + after 异步队列；表不动 |
| `bullmq` 队列基建 | audit.after 复用 webhook 同套 |
| `listAsProviderTools` OpenAI tool 协议适配 | T1 快照保证不变 |
| `defaultCapabilitiesForSurface` | 不变 |
| `ToolInvocation` 类型 | 重命名 → `AgentContext`（字段不变，语义改善） |

---

## 失败模式审视

**0 关键缺口**（无测试 + 无错误处理 + 静默失败）：经 T1-T20 覆盖 + 包装层异常三档 + sanitize + fail-fast，全部失败路径都有处理。

**1 软缺口**：sanitize 黑名单不完整 → fuzz 测试覆盖已知模式 + 规则随生产观察持续扩展（追踪 issue 待开）。

---

## 后续推进顺序

1. **PR-A PoC**（reflect-metadata + `MetadataScanner`（NestJS 提供）+ forFeature 静态方法验证）—— 1-3 天
2. **PR-C draft**（standards/17 + CLAUDE.md / AGENTS.md / docs/modules/agent/02-architecture.md §1.3 交叉引用）—— 1-2 天，可与 PR-A 并行
3. **#409 PRD 并行起草**（标注依赖 #410 PR-A 合入）—— 与 PR-A/PR-C 并行
4. PR-A 合入 → lint 立即生效 → 双轨期截止 → #409 实施门票打开

---

## 跨任务可复用经验

1. **plan-review 输出"决策汇总速查表"是高价值产物**：未来 PRD / 实施 / 回溯任何决策都能 1 屏查到，比读完整对话快 20×
2. **"显式 > 巧妙"在 NestJS 元编程场景特别有效**：DiscoveryService 全局扫的 8 条硬伤 vs forFeature 显式扫描，路线选错代价数月维护成本
3. **"按主题不按规模"拆 PR 准则在 PR-A 这种主题单一的大改动上落地清晰**：30 工具迁移 + 装饰器 + 包装层合 1 PR 是对的，强行拆 PR-B 反而制造双轨期反 DRY
4. **量化升级触发条件 > 时间约束**：A 类"先不升"决策必须配监控指标 + 量化触发，否则等同永久埋雷
5. **审计的同步/异步分层**：before 同步（合规准入）+ after 异步（性能）+ 三级兜底（内存/队列/DLQ）是通用模式，未来其他模块的强合规场景可参考
