# FFAI Agent API 契约

> Backend：`backend/src/modules/agent/controllers/` 共 10 个 controller（含 Phase 2: projects / memories / personas / mcp-admin）。  
> 所有路径**全局前缀 `/api/v1`**（NestJS 配置）。  
> 所有 endpoint 走 JWT auth（`JwtAuthGuard` 全局）+ 全局 `TransformInterceptor` 包装响应为 `{success, data, message}`（SSE 流 endpoint 用 `@SkipTransform`）。

## 鉴权 / 组织上下文

| Header | 来源 | 用途 |
|---|---|---|
| `Authorization: Bearer <jwt>` | 前端 `apiClient` 拦截器从 `localStorage['auth-storage'].state.token` 注入 | JwtAuthGuard 解析 → `req.user` |
| `X-Organization-Id: <uuid>` | 前端 OrganizationContext 切组织时 apiClient 注入 | `resolveOrgId(req)` 兜底优先级第 2 |

`resolveActor(req, explicitOrg?)` → `{ actor, orgId, userId }`，组织 ID 兜底序：显式 → header → JWT `currentOrganizationId` → `organizationRoles` 第一个 key。

## Endpoint 一览

### Sessions（PR2 + Phase 2: project filter / rewind）

| Method | Path | 权限 | Body / Query | Response |
|---|---|---|---|---|
| GET | `/agent/sessions` | 登录用户 | `?limit=N&projectId=<uuid>\|null` | `{items: AgentSession[]}` 倒序，filter `createdById = me + closedAt is null`；**Phase 2**: `projectId=<uuid>` 过滤指定项目下的会话；`projectId=null` 字符串过滤"未归属任何项目" |
| POST | `/agent/sessions` | 登录用户 | `{ title?, projectId?, personaId?, organizationId? }` | `AgentSession`（带 default planMode=OFF / permissionMode=DEFAULT / surface=WEB / status=ACTIVE）；传 `personaId` 后激活 PERSONA-scope memory 注入 + persona.instructions 自动拼到 system prompt |
| GET | `/agent/sessions/:id` | 登录用户（跨 org 403） | — | `AgentSession & { messages: AgentMessage[] sorted by sequence asc }` |
| DELETE | `/agent/sessions/:id` | 登录用户（跨 org 403） | — | 软删：closedAt 标记 + status=CLOSED |
| POST | `/agent/sessions/:id/rewind` *(Phase 2)* | 登录用户（跨 org 403） | `{ afterSequence: number }` | `{ ok: true, deleted: number }`。删除 sequence > afterSequence 的所有 AgentMessage（**trajectory 保留**——append-only INV-4 不可改），用于"编辑历史消息后重跑"——前端把用户消息改完后调本接口截断，再 POST `/agent/messages` 触发新 turn |

### Messages（PR2 + PR4a TAOR + PR4a SSE）

| Method | Path | 权限 | Body | Response |
|---|---|---|---|---|
| POST | `/agent/messages` | 登录用户（跨 org 403） | `{ sessionId, prompt, organizationId? }` | **同步** `{ turnId, messages: AgentMessage[] }`，完整 TAOR loop（含 multi-turn tool_use）|
| POST | `/agent/messages/stream` | 同上 | 同上 | **SSE** `text/event-stream`。事件类型：`message` (含 AgentMessage) / `text_delta` (assistant 增量) / `routing` (RoutingDecision) / `ask_user` (turnId/question/options，halt TAOR loop 等用户回答，前端渲选项卡) / `done` (turnId + totalLatency) / `error` (message) |
| POST | `/agent/messages/:turnId/cancel` | 登录用户（跨 org 403） | — | `{ cancelled: boolean }`。中止跑中的 turn：TAOR loop 下一轮迭代读到 abort 信号 graceful break。已结束/不存在 turn 返 `{cancelled:false}`（200）；跨 org 取消返 403。**已知限制**：当前 activeTurns 是 in-memory Map，多副本部署时 cancel 落到非持有 turn 的副本会静默 cancelled:false；上 UAT/生产前需 Redis pub/sub 或 sticky session（PR0.6 时同步解决） |

SSE 事件格式：
```
event: text_delta
data: {"type":"text_delta","text":"你","iter":1}

event: done
data: {"type":"done","turnId":"...","totalLatencyMs":1234,"iterations":2}
```

### Routing (PR3.5 + PR10.6 admin)

| Method | Path | 权限 | Query / Body | Response |
|---|---|---|---|---|
| GET | `/agent/routing/decisions` | `system:admin` ⚠️ | `?sessionId=...&limit=N` (≤200) | `{items: ModelRoutingDecision[]}` 含 reasoning + cost |
| GET | `/agent/admin/rules` | `system:admin` ⚠️ | — | `{items: ModelRoutingRule[]}` 按 priority asc |
| POST | `/agent/admin/rules` | `system:admin` ⚠️ | `{scope?, scopeRefId?, name, priority?, enabled?, pattern, primary, fallbacks?, reasoning?}` | `ModelRoutingRule`（创建后含 id） |
| PATCH | `/agent/admin/rules/:id` | `system:admin` ⚠️ | 部分字段：`{name?, priority?, enabled?, pattern?, primary?, fallbacks?, reasoning?}` | `ModelRoutingRule` 更新后 |
| DELETE | `/agent/admin/rules/:id` | `system:admin` ⚠️ | — | `{deleted: true, id}`（硬删；audit 进 follow-up） |

### Trajectory（PR4c 审计）

| Method | Path | 权限 | Query | Response |
|---|---|---|---|---|
| GET | `/agent/trajectory/events?sessionId=:id` | `system:admin` ⚠️ | sessionId required | `{items: AgentTrajectoryEvent[]}` 按 sequenceInSession 升序 |
| GET | `/agent/trajectory/verify/:sessionId` | `system:admin` ⚠️ | — | `{ok: true}` or `{ok: false, firstBrokenSeq, reason}` |

### Projects *(Phase 2)*

ChatGPT-style 用户自创"项目工作区"（与 FFOA 业务项目解耦）。

| Method | Path | 权限 | Body | Response |
|---|---|---|---|---|
| GET | `/agent/projects` | 登录用户 | — | `{ items: AgentProject[] }` 倒序，filter `organizationId + createdById = me` |
| POST | `/agent/projects` | 登录用户 | `{ name: string, icon?, color?, instructions? }` | `AgentProject`。`name` 必填 ≤120 字符 |
| PATCH | `/agent/projects/:id` | 登录用户（跨 org / 非 owner 403） | 任一部分字段：`{ name?, icon?, color?, instructions? }` | `AgentProject` 更新后 |
| DELETE | `/agent/projects/:id` | 登录用户（跨 org / 非 owner 403） | — | `{ ok: true }`。单事务：先把该项目内 session.projectId 置 null（保 session），再删项目本体 |

### Memories *(M1 全维度生效)*

per-user / per-org 持久记忆，每 turn 自动注入 system prompt（messages.service.runTurn / runTurnStream 入口拼装；详见 §「Memory 注入规则」）。

| Method | Path | 权限 | Body / Query | Response |
|---|---|---|---|---|
| GET | `/agent/memories` | 登录用户 | `?scope=GLOBAL\|PROJECT\|PERSONA & category=USER\|FEEDBACK\|PROJECT\|REFERENCE` | `{ items: AgentMemory[] }` 倒序；返回 OR(ownerScope=USER ∧ createdById=me) 和 (ownerScope=ORG ∧ organizationId=current)；可选按 scope/category 过滤 |
| POST | `/agent/memories` | 登录用户 | `{ content: string, scope?, category? (默认 USER), projectId?, personaId?, source? }` | `AgentMemory`。强制 `ownerScope=USER`；`content` ≤4000；scope/projectId/personaId 形状校验同前 |
| PATCH | `/agent/memories/:id` | 登录用户（跨 org / 非 owner 403；ORG memory 走 admin 入口） | `{ content?, category? }` | `AgentMemory` 更新后 |
| DELETE | `/agent/memories/:id` | 登录用户（跨 org / 非 owner / ORG memory 走 admin 入口 → 403） | — | `{ ok: true }` 真删 |

### Admin Memories *(M1 step4)*

per-org 共享记忆，admin 配，对 org 内全部用户的每次 turn 自动注入。

| Method | Path | 权限 | Body | Response |
|---|---|---|---|---|
| POST | `/agent/admin/memories` | `Administrator` / `ITAdmin` / `agent.admin` 角色 任一 | `{ content, scope?, category?, projectId?, personaId?, source? }` | `AgentMemory`。强制 `ownerScope=ORG` + `createdById=null`；category 默认 `PROJECT`；scope/形状校验同上 |
| DELETE | `/agent/admin/memories/:id` | 同上 | — | `{ ok: true }`。仅允许删本 org 的 ORG-scope memory（USER-scope 路径走前面那组） |

### Memory 注入规则

`AgentMemoriesService.buildSystemPromptSection(sessionOrgId, userId, ctx?)` —— messages.service 在每 turn LLM 调用前调一次，结果拼到 system prompt 末尾。

**加载条件**（OR）：
- `ownerScope=USER ∧ createdById=userId`（USER 私有，跨 org 走 sanitize）
- `ownerScope=ORG ∧ organizationId=sessionOrgId`（ORG 共享，**严禁跨 org 注入**）

**激活条件**（AND on scope）：
- `scope=GLOBAL` 始终
- `scope=PROJECT` 当 `ctx.projectId` 匹配
- `scope=PERSONA` 当 `ctx.personaId` 匹配（来自 `session.personaId`）

**跨 org sanitize**（INV-1）：USER-owned memory 当 `memory.organizationId ≠ sessionOrgId` 时，走 `utils/memory-sanitize.util.ts` 的 4 条 regex（邮箱 / 货币 / 项目代号 / 长数字）替换为 `[xxx-redacted]`。ORG-owned memory 因查询条件已绑死 organizationId 不可能跨 org。

**容量预算**（CC 对齐）：行数 ≤200 + 字节 ≤25KB 双约束；超过按 updatedAt desc 截断，header 显示"条目过多"提示。

**输出格式**：4 个 category 章节（USER → FEEDBACK → PROJECT → REFERENCE 固定顺序），每条 `- {content}`。

### Memory Auto-Extract *(M1 M2)*

LLM 输出 `<remember category="USER|FEEDBACK|PROJECT|REFERENCE">事实</remember>` tag 时，service 在写 ASSISTANT_TEXT 前调 `extractAndPersist()`：
- 解析 tag → 写 user-scope memory（`source='ai-detected'` / 默认 category USER）
- 同 user 已有完全相同内容 → dedup 不重复
- 单轮最多 3 条
- 剥离 tag 后的 cleanedText 写到 AgentMessage.content（用户看不到 tag）
- AgentMessage.payload 含 `memoriesAutoCreated: N`

### Personas *(Phase 2)*

智能体 "你应该是谁" —— 含 instructions / 工具白名单 / 风格。来源：① 系统预设（createdById=NULL + systemKey，org 内共享；首次 GET 触发本进程内 lazy seed，幂等 createMany skipDuplicates 单 RTT）② 用户自创。

| Method | Path | 权限 | Body | Response |
|---|---|---|---|---|
| GET | `/agent/personas` | 登录用户 | — | `{ items: AgentPersona[] }`；含系统预设（createdById=NULL）+ 自己创建的；首次访问触发本 org seed 7 个系统预设（general / code-reviewer / writer / data-analyst / approver-helper / knowledge-qa / translator）|
| POST | `/agent/personas` | 登录用户 | `{ name: string, icon?, description?, instructions?, allowedTools?: string[] }` | `AgentPersona`（systemKey=null 用户自创） |
| PATCH | `/agent/personas/:id` | 登录用户（跨 org 403；非 owner 403；系统预设仅允许改 enabled/instructions/icon/description） | 任一部分字段：`{ name?, icon?, description?, instructions?, allowedTools?, enabled? }` | `AgentPersona` 更新后。改系统预设的 name/allowedTools 返 403 |
| DELETE | `/agent/personas/:id` | 登录用户（跨 org / 非 owner 403） | — | 用户自创 → 真删 `{ ok: true, soft: false }`；系统预设 → 软隐藏 `enabled=false`（避免下次 seed 又恢复）`{ ok: true, soft: true }` |

### MCP Admin *(Phase 2)*

管理员注册外部 MCP server（GitHub / Notion / Filesystem 等几百个开源 server）。启动时按 enabled servers 并行 spawn 子进程 / 建 SSE 连接，握手后把 server 工具注入 ToolRegistry（name 前缀 `mcp:<serverName>:<toolName>`）。**全部 endpoint 要求 `system:admin`**——MCP server endpoint 直接 spawn 子进程，必须收紧到 admin 避免任意用户注册任意 stdio 命令 → 后端 RCE 面。

| Method | Path | 权限 | Body | Response |
|---|---|---|---|---|
| GET | `/agent/admin/mcp/servers` | `system:admin` ⚠️ | — | `{ items: AgentMcpServer[] }` 倒序 |
| POST | `/agent/admin/mcp/servers` | `system:admin` ⚠️ | `{ name: string, transport: 'stdio'\|'sse', endpoint: string, args?: string[], env?: Record<string,string> }` | `AgentMcpServer`。若 `FFAI_MCP_ENABLED=true` 立即 fire-and-forget `bootOne(created)`（失败仅 warn 不阻塞 create 响应） |
| DELETE | `/agent/admin/mcp/servers/:id` | `system:admin` ⚠️ | — | `{ ok: true }`。关连接 + 删表本体 + 同步调 `ToolRegistry.unregisterPrefix('mcp:<server>:')` 即时清掉 LLM 可见的 tool descriptor（被清数量进 log） |
| GET | `/agent/admin/mcp/status` | `system:admin` ⚠️ | — | `{ connections: Array<{ serverId, serverName, connected, toolCount }> }` 当前进程活跃 MCP 连接 |

### Tools（PR5 + PR4.5 mode 过滤）

| Method | Path | 权限 | Body / Query | Response |
|---|---|---|---|---|
| GET | `/agent/tools` | 登录用户 | `?surface=web&sessionId=...` | `{items: ToolDescriptor[]}` 经 mode + permission + surface 过滤 |
| POST | `/agent/tools/:name` | 登录用户（写 tool 需 permission `approval:create` 等）| `{ input?, sessionId?, turnId?, surface? }` | `ToolResult = { ok: boolean, output?, errorMessage? }` |

**Phase 1 内置 tool（8 个）**：
1. `project_query` → DevTracker.ItemsService.findAll（read，real backend）
2. `knowledge_query` → KnowledgeQaService.ask（read，需 RAGFlow 可达）
3. `approval_submit` → ApprovalService.startApproval（writeAction，需 `approval:create`）
4. `EnterPlanMode` → session.planMode = REQUIRED（controlTool）
5. `ExitPlanMode` → session.planMode = OFF（controlTool）
6. `SetPermissionMode` → session.permissionMode = X（controlTool）
7. `delegate_task` → spawn child session + 跑 1 turn（sub-agent，写 parentSessionId）
8. `file_save` → StorageService.upload to LOCAL/ONEDRIVE binding（writeAction）

**Phase 2 新增工具（10 个）**：

9. `web_search` → 优先 Tavily（`TAVILY_API_KEY` 配时启用，生产推荐）/ 兜底 DuckDuckGo HTML 抓取（read）
10. `web_fetch` → 优先 Tavily /extract / 兜底 native fetch + 简化 HTML 剥离；MAX 10000 字符截断 + truncated 标志（read）
11. `TodoWrite` → 当前 turn 内的轻量计划列表（write，session-scoped，不持久化跨 session）
12. `TaskCreate` → 持久化子任务到 `AgentTaskTracker`（writeAction）
13. `TaskUpdate` → 更新子任务状态 / progress / metadata（writeAction）
14. `TaskList` → 列出当前 session 的 AgentTaskTracker（read）
15. `TaskStop` → 取消子任务（status=CANCELLED）（writeAction）
16. `SendMessage` → 多 Agent 消息传递：向指定子 session 注入 prompt 并触发新 turn（writeAction，sub-agent 间协调；`targetSessionId` 字段避开 controller sessionId merge override）
17. `CronCreate` / `CronList` / `CronUpdate` / `CronDelete` → 用户管理定时任务（CRUD，per-user 10 / per-org 50 配额）

### Artifacts（PR9）

| Method | Path | 权限 | Query | Response |
|---|---|---|---|---|
| GET | `/agent/artifacts?sessionId=:id` | 登录用户（跨 org 403） | sessionId required | `{items: AgentArtifact[]}` 倒序 max 100 |
| GET | `/agent/artifacts/:id` | 登录用户（跨 org 403） | — | `AgentArtifact` |

## Mode 过滤逻辑

`ToolRegistry.list(ctx)` 行为：
- Plan mode REQUIRED → `writeAction=true` tool 被隐藏 + invoke 返 403
- Permission READ_ONLY → 同上
- `controlTool=true` 永远可见（否则用户无法切回 mode）
- `availability.surface` / `availability.permissions` 过滤

## 错误码

- 401 Unauthorized：JWT 缺失/过期
- 403 Forbidden：`@SkipAssertAccess` 内部归属校验失败 / Plan-mode 屏蔽写 tool / `@RequirePermissions` 不通过 / 跨 org 访问
- 400 BadRequest：组织 ID 解析失败 / 必填字段缺失
- 404 NotFound：session / artifact / tool 不存在
- 503 ServiceUnavailable：所有 provider 都 `isAvailable()=false`（合规未通过 + Qwen key 缺）/ TrajectoryAnchor / OneDrive / MCP 等 `isConfigured()=false` 调用

## 测试 / 调试入口

- 端到端冒烟：[`scripts/agent-smoke.sh`](../../../scripts/agent-smoke.sh)（登录 → 建 persona → 建 session 绑 persona → 写 USER + ORG memory → runTurn → 验证 list 含两层 → cleanup）；CI / 部署后可直接跑，支持 `API_BASE` / `API_USER` / `VERBOSE=1` 覆盖
- 前端 admin 页：`/agent/admin/routing`（最近 50 决策）/ `/agent/admin/memories`（ORG 共享 memory CRUD）

## 待补 endpoint（GA 前）

- `PATCH /agent/sessions/:id` 的 `planMode` / `permissionMode` 字段（当前只支持 title / personaId / projectId；mode 切换仍走 mode-control tools）
- 全套 `/agent/admin/quota`（quota 配置 UI 配套）
